Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed3f3cd843 | ||
|
|
168a7a2c36 | ||
|
|
56cbecc3f7 | ||
|
|
d45ea4db68 | ||
|
|
19486150c3 | ||
|
|
3564d24a45 | ||
|
|
7f6d69e0a7 | ||
|
|
f9ccfd821d | ||
|
|
e43db34402 | ||
|
|
20dfc58d63 | ||
|
|
6425a5056a | ||
|
|
6fa33c9f7b | ||
|
|
c131465ff5 | ||
|
|
eb1c5b6de4 | ||
|
|
9f0832a5b5 | ||
|
|
ae623f5316 | ||
|
|
d4afb71798 | ||
|
|
71856bb869 | ||
|
|
24808b0f8c | ||
|
|
aa0b757814 | ||
|
|
ebc7c7df17 | ||
|
|
9116107917 | ||
|
|
b7c0083647 | ||
|
|
b88097c15a | ||
|
|
19a5d04178 | ||
|
|
86c87153b4 | ||
|
|
5e693e2cdd | ||
|
|
9440c651b6 | ||
|
|
ccb44fa253 | ||
|
|
cee0378f8f |
@@ -24,3 +24,10 @@ IP_INTELLIGENCE_PROVIDER=proxycheck
|
||||
PROXYCHECK_API_KEY=
|
||||
IP_INTELLIGENCE_CACHE_HOURS=48
|
||||
BLOCK_HOSTING_IPS=false
|
||||
|
||||
# Optional independent 32-byte base64 RCON keys. When omitted, domain-separated keys are derived from AUTH_SECRET.
|
||||
RCON_CREDENTIAL_KEY=
|
||||
RCON_AUDIT_KEY=
|
||||
|
||||
# Structured Pino logging
|
||||
LOG_LEVEL=info
|
||||
|
||||
@@ -7,7 +7,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -116,9 +115,9 @@ jobs:
|
||||
apt-get install -y docker-ce-cli
|
||||
fi
|
||||
|
||||
- name: Log in to Gitea container registry
|
||||
- name: Log in to Docker Hub
|
||||
if: steps.release.outputs.created == 'true'
|
||||
run: echo "${{ secrets.CONTAINER_REGISTRY_TOKEN }}" | docker login git.garvis.dev -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||
run: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin
|
||||
|
||||
- name: Build and push web image
|
||||
if: steps.release.outputs.created == 'true'
|
||||
@@ -129,11 +128,9 @@ jobs:
|
||||
--platform linux/amd64 \
|
||||
--target runner \
|
||||
--build-arg VERSION="$VERSION" \
|
||||
-t "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}" \
|
||||
-t git.garvis.dev/dmg/minecraft-account-manager:latest \
|
||||
-t "dmgarvis/minecraft-account-manager:${VERSION}" \
|
||||
.
|
||||
docker push "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}"
|
||||
docker push git.garvis.dev/dmg/minecraft-account-manager:latest
|
||||
docker push "dmgarvis/minecraft-account-manager:${VERSION}"
|
||||
|
||||
- name: Build and push Discord bot image
|
||||
if: steps.release.outputs.created == 'true'
|
||||
@@ -144,11 +141,9 @@ jobs:
|
||||
--platform linux/amd64 \
|
||||
--target bot \
|
||||
--build-arg VERSION="$VERSION" \
|
||||
-t "git.garvis.dev/dmg/minecraft-account-manager-bot:${VERSION}" \
|
||||
-t git.garvis.dev/dmg/minecraft-account-manager-bot:latest \
|
||||
-t "dmgarvis/minecraft-account-manager-bot:${VERSION}" \
|
||||
.
|
||||
docker push "git.garvis.dev/dmg/minecraft-account-manager-bot:${VERSION}"
|
||||
docker push git.garvis.dev/dmg/minecraft-account-manager-bot:latest
|
||||
docker push "dmgarvis/minecraft-account-manager-bot:${VERSION}"
|
||||
|
||||
- name: Build and push migration image
|
||||
if: steps.release.outputs.created == 'true'
|
||||
@@ -159,11 +154,9 @@ jobs:
|
||||
--platform linux/amd64 \
|
||||
--target migrate \
|
||||
--build-arg VERSION="$VERSION" \
|
||||
-t "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}" \
|
||||
-t git.garvis.dev/dmg/minecraft-account-manager-migrate:latest \
|
||||
-t "dmgarvis/minecraft-account-manager-migrate:${VERSION}" \
|
||||
.
|
||||
docker push "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}"
|
||||
docker push git.garvis.dev/dmg/minecraft-account-manager-migrate:latest
|
||||
docker push "dmgarvis/minecraft-account-manager-migrate:${VERSION}"
|
||||
|
||||
- name: Create Gitea release and upload Velocity JAR
|
||||
if: steps.release.outputs.created == 'true'
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
+15
-4
@@ -1,6 +1,6 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-alpine AS dependencies
|
||||
FROM node:22-alpine AS manifests
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
@@ -10,10 +10,19 @@ COPY apps/discord-bot/package.json ./apps/discord-bot/package.json
|
||||
COPY packages/auth/package.json ./packages/auth/package.json
|
||||
COPY packages/contracts/package.json ./packages/contracts/package.json
|
||||
COPY packages/database/package.json ./packages/database/package.json
|
||||
COPY packages/logging/package.json ./packages/logging/package.json
|
||||
COPY packages/minecraft/package.json ./packages/minecraft/package.json
|
||||
COPY packages/network/package.json ./packages/network/package.json
|
||||
|
||||
FROM manifests AS dependencies
|
||||
RUN npm ci
|
||||
|
||||
FROM manifests AS bot-dependencies
|
||||
RUN npm ci --omit=dev --workspace @minecraft-account-manager/discord-bot
|
||||
|
||||
FROM manifests AS migration-dependencies
|
||||
RUN npm ci --omit=dev --workspace @minecraft-account-manager/database
|
||||
|
||||
FROM dependencies AS builder
|
||||
COPY . .
|
||||
RUN npm run build --workspace @minecraft-account-manager/web
|
||||
@@ -25,6 +34,7 @@ LABEL org.opencontainers.image.title="Minecraft Account Manager" \
|
||||
org.opencontainers.image.source="https://git.garvis.dev/dmg/minecraft-account-manager"
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
APP_VERSION=${VERSION} \
|
||||
HOSTNAME=0.0.0.0 \
|
||||
PORT=3000
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
@@ -34,13 +44,14 @@ USER app
|
||||
EXPOSE 3000
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
|
||||
FROM dependencies AS bot
|
||||
FROM bot-dependencies AS bot
|
||||
ARG VERSION=development
|
||||
LABEL org.opencontainers.image.title="Minecraft Account Manager Discord Bot" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
org.opencontainers.image.source="https://git.garvis.dev/dmg/minecraft-account-manager"
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_ENV=production \
|
||||
APP_VERSION=${VERSION}
|
||||
RUN addgroup --system app && adduser --system --ingroup app app
|
||||
COPY --chown=app:app package.json package-lock.json tsconfig.base.json ./
|
||||
COPY --chown=app:app apps/discord-bot ./apps/discord-bot
|
||||
@@ -48,7 +59,7 @@ COPY --chown=app:app packages ./packages
|
||||
USER app
|
||||
CMD ["npm", "run", "start", "--workspace", "@minecraft-account-manager/discord-bot"]
|
||||
|
||||
FROM dependencies AS migrate
|
||||
FROM migration-dependencies AS migrate
|
||||
ARG VERSION=development
|
||||
LABEL org.opencontainers.image.title="Minecraft Account Manager Migrations" \
|
||||
org.opencontainers.image.version="${VERSION}" \
|
||||
|
||||
@@ -76,11 +76,13 @@ 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, RCON server-address management and command proxying, and automatic Discord nickname synchronization
|
||||
- Exclusive group admission: unassigned users fall back to protected `everyone`, and administrators manage effective membership, access, recurring UTC login windows, and VPN/proxy/Tor exceptions through confirmed group workflows
|
||||
- Deployment-managed Discord guild ID and invite URL
|
||||
- discord.js bot with `/register` and `/account`
|
||||
- Java Edition online-mode accounts only
|
||||
- Velocity admission checks are fail closed
|
||||
- ProxyCheck.io geolocation and VPN/proxy/Tor detection with a 48-hour PostgreSQL cache
|
||||
- Velocity admission checks are fail closed; disabled group access overrides recurring schedules, which are evaluated only at login
|
||||
- Static denial-message templates support validated player/group variables and next scheduled UTC window guidance
|
||||
- ProxyCheck.io geolocation and VPN/proxy/Tor detection with a 48-hour PostgreSQL cache and group-scoped game-connection exceptions
|
||||
|
||||
See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities, [`docs/api-errors.md`](docs/api-errors.md) for the RFC 9457 API error contract, and [`docs/security-review.md`](docs/security-review.md) for implemented controls and production requirements.
|
||||
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.
|
||||
|
||||
@@ -3,3 +3,4 @@ APP_URL=http://localhost:3000
|
||||
DISCORD_BOT_TOKEN=
|
||||
DISCORD_APPLICATION_ID=
|
||||
DISCORD_GUILD_ID=
|
||||
LOG_LEVEL=info
|
||||
|
||||
@@ -13,12 +13,13 @@
|
||||
"@minecraft-account-manager/auth": "*",
|
||||
"@minecraft-account-manager/contracts": "*",
|
||||
"@minecraft-account-manager/database": "*",
|
||||
"@minecraft-account-manager/logging": "*",
|
||||
"discord.js": "^14.25.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-orm": "^0.45.1"
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"tsx": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,22 @@ import "dotenv/config";
|
||||
import { REST, Routes } from "discord.js";
|
||||
import { commands } from "./commands";
|
||||
import { requiredEnvironment } from "./config";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const token = requiredEnvironment("DISCORD_BOT_TOKEN");
|
||||
const applicationId = requiredEnvironment("DISCORD_APPLICATION_ID");
|
||||
const rest = new REST({ version: "10" }).setToken(token);
|
||||
|
||||
await rest.put(Routes.applicationCommands(applicationId), { body: commands });
|
||||
console.log(`Deployed ${commands.length} global Discord commands.`);
|
||||
try {
|
||||
await rest.put(Routes.applicationCommands(applicationId), { body: commands });
|
||||
logger.info(
|
||||
{ event: "discord.commands_deployed", commandCount: commands.length },
|
||||
"Deployed global Discord commands",
|
||||
);
|
||||
} catch (error) {
|
||||
logger.fatal(
|
||||
{ err: error, event: "discord.commands_deploy_failed" },
|
||||
"Failed to deploy global Discord commands",
|
||||
);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
GatewayIntentBits,
|
||||
} from "discord.js";
|
||||
import { commandNames, requiredEnvironment } from "./config";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const token = requiredEnvironment("DISCORD_BOT_TOKEN");
|
||||
const appUrl = requiredEnvironment("APP_URL");
|
||||
@@ -24,7 +25,10 @@ const authRepository = createAuthRepository(db);
|
||||
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
||||
|
||||
client.once(Events.ClientReady, (readyClient) => {
|
||||
console.log(`Discord bot ready as ${readyClient.user.tag}`);
|
||||
logger.info(
|
||||
{ event: "discord.ready", botUserId: readyClient.user.id, botUsername: readyClient.user.username },
|
||||
"Discord bot is ready",
|
||||
);
|
||||
});
|
||||
|
||||
client.on(Events.InteractionCreate, async (interaction) => {
|
||||
@@ -71,9 +75,17 @@ client.on(Events.InteractionCreate, async (interaction) => {
|
||||
await interaction.editReply("Please wait 30 seconds before requesting another private account link.");
|
||||
return;
|
||||
}
|
||||
console.error("Failed to create Discord account link", error);
|
||||
logger.error(
|
||||
{ err: error, event: "discord.magic_link_failed", command: interaction.commandName },
|
||||
"Failed to create Discord account link",
|
||||
);
|
||||
await interaction.editReply("I could not create an account link. Please try again shortly.");
|
||||
}
|
||||
});
|
||||
|
||||
await client.login(token);
|
||||
try {
|
||||
await client.login(token);
|
||||
} catch (error) {
|
||||
logger.fatal({ err: error, event: "discord.login_failed" }, "Discord bot login failed");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createLogger } from "@minecraft-account-manager/logging";
|
||||
|
||||
export const logger = createLogger("minecraft-account-manager-discord-bot");
|
||||
@@ -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'",
|
||||
@@ -34,6 +34,7 @@ const nextConfig: NextConfig = {
|
||||
transpilePackages: [
|
||||
"@minecraft-account-manager/contracts",
|
||||
"@minecraft-account-manager/database",
|
||||
"@minecraft-account-manager/logging",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
+12
-1
@@ -14,21 +14,32 @@
|
||||
"@minecraft-account-manager/auth": "*",
|
||||
"@minecraft-account-manager/contracts": "*",
|
||||
"@minecraft-account-manager/database": "*",
|
||||
"@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",
|
||||
"rcon-client": "^4.2.5",
|
||||
"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",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@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",
|
||||
"jsdom": "^30.0.1",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.0"
|
||||
|
||||
@@ -1,25 +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 { 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();
|
||||
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);
|
||||
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 });
|
||||
redirect("/account?confirmNickname=1");
|
||||
redirect(nicknameResultUrl(nickname, synchronization));
|
||||
}
|
||||
|
||||
export async function addMinecraftAccount(formData: FormData) {
|
||||
@@ -46,40 +96,60 @@ 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 [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");
|
||||
|
||||
const changed = await db.transaction(async (tx) => {
|
||||
const [account] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
|
||||
and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
|
||||
).limit(1);
|
||||
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(
|
||||
@@ -88,61 +158,62 @@ export async function setPrimaryAccount(formData: FormData) {
|
||||
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");
|
||||
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", { accountId });
|
||||
redirect("/account?confirmNickname=1");
|
||||
|
||||
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,
|
||||
});
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { formatDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||
import { ipIntelligence, ipObservations, minecraftAccounts } from "@minecraft-account-manager/database";
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
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";
|
||||
import { db } from "@/lib/database";
|
||||
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,
|
||||
} from "./actions";
|
||||
|
||||
function queryValue(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
"invalid-name": "Enter a valid name between 1 and 50 characters.",
|
||||
"invalid-username": "Java usernames use 3–16 letters, numbers, or underscores.",
|
||||
@@ -24,14 +31,18 @@ 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,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | undefined>>;
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const user = await requireCurrentUser("/account");
|
||||
const query = await searchParams;
|
||||
const [accounts, observations] = await Promise.all([
|
||||
const error = queryValue(query.error);
|
||||
const unverified = queryValue(query.unverified);
|
||||
const [accounts, observations, discord, availableGroups] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(minecraftAccounts)
|
||||
@@ -50,12 +61,25 @@ export default async function AccountPage({
|
||||
.leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||
.where(eq(ipObservations.userId, user.id))
|
||||
.orderBy(desc(ipObservations.observedAt))
|
||||
.limit(20),
|
||||
.limit(100),
|
||||
discordIdentity(user),
|
||||
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed, isDefault: groups.isDefault })
|
||||
.from(groups)
|
||||
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
|
||||
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
|
||||
.orderBy(desc(groups.isDefault), groups.name),
|
||||
]);
|
||||
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 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">
|
||||
@@ -68,25 +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>
|
||||
|
||||
{query.error && (
|
||||
<p className="mt-8 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">
|
||||
{errorMessages[query.error] ?? "The requested change could not be completed."}
|
||||
{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>
|
||||
)}
|
||||
{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">Discord nickname updated</p>}
|
||||
|
||||
{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">
|
||||
@@ -109,24 +125,24 @@ export default async function AccountPage({
|
||||
</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">Make primary</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>
|
||||
<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>
|
||||
))}
|
||||
{accounts.length === 0 && <p className="py-8 text-muted">No active Minecraft accounts. Add one before joining the server.</p>}
|
||||
</div>
|
||||
|
||||
{query.unverified ? (
|
||||
{unverified ? (
|
||||
<form action={addMinecraftAccount} className="border-l-2 border-accent bg-panel p-6">
|
||||
<h3 className="font-display text-xl font-black uppercase">Mojang couldn’t verify “{query.unverified}”</h3>
|
||||
<h3 className="font-display text-xl font-black uppercase">Mojang couldn’t verify “{unverified}”</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-muted">Continue only if you are certain the spelling is correct.</p>
|
||||
<input name="username" type="hidden" value={query.unverified} /><input name="confirmUnverified" type="hidden" value="yes" />
|
||||
<input name="username" type="hidden" value={unverified} /><input name="confirmUnverified" type="hidden" value="yes" />
|
||||
<button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Add anyway</button>
|
||||
<a className="ml-5 font-mono text-[10px] font-bold uppercase underline" href="/account">Cancel</a>
|
||||
</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>
|
||||
)}
|
||||
@@ -135,11 +151,24 @@ export default async function AccountPage({
|
||||
<section>
|
||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Recent security activity</p>
|
||||
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Access addresses</h2>
|
||||
{observations.length ? (
|
||||
{addressGroups.length ? (
|
||||
<div className="divide-y divide-line font-mono text-xs">
|
||||
{observations.map((observation) => {
|
||||
const summary = intelligenceSummary(observation.intelligence);
|
||||
return <div className="grid grid-cols-[1fr_auto] gap-4 py-4" key={observation.id}><div><p>{observation.ipAddress}</p><p className="mt-1 text-[10px] text-muted">{summary.location ?? "Location unavailable"} · {summary.classification ?? observation.classification}</p></div><span className="text-muted">{observation.source} · {observation.observedAt.toISOString()}</span></div>;
|
||||
<p className="py-3 text-[10px] leading-5 text-muted">Similar IPv4 /24 and IPv6 /64 networks are grouped. Counts cover your 100 most recent observations.</p>
|
||||
{addressGroups.map((group) => {
|
||||
const summary = intelligenceSummary(group.intelligence);
|
||||
return (
|
||||
<div className="grid gap-3 py-4 sm:grid-cols-[1fr_auto] sm:items-start" key={group.network}>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<p className="font-bold">{group.network}</p>
|
||||
<span className="border border-line px-2 py-1 text-[9px] uppercase tracking-wider text-muted">{group.count} {group.count === 1 ? "observation" : "observations"}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-[10px] text-muted">Latest address {group.latestAddress}</p>
|
||||
<p className="mt-1 text-[10px] text-muted">{summary.location ?? "Location unavailable"} · {summary.classification ?? group.classification}</p>
|
||||
</div>
|
||||
<span className="text-[10px] text-muted sm:text-right">{group.sources.join(" + ")}<br />Last seen {group.latestObservedAt.toISOString()}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <p className="py-6 text-sm text-muted">No web or game access addresses have been recorded yet.</p>}
|
||||
@@ -149,11 +178,23 @@ export default async function AccountPage({
|
||||
<aside>
|
||||
<form action={updateFirstName} className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Profile</p>
|
||||
<dl className="mt-5 space-y-3 border-b border-line pb-5 font-mono text-[10px]">
|
||||
<div><dt className="uppercase tracking-wider text-muted">Discord name</dt><dd className="mt-1 break-all font-bold text-ink">{discord.globalName ?? discord.username}</dd></div>
|
||||
<div><dt className="uppercase tracking-wider text-muted">Discord username</dt><dd className="mt-1 break-all text-ink">@{discord.username}</dd></div>
|
||||
<div><dt className="uppercase tracking-wider text-muted">Guild nickname</dt><dd className="mt-1 break-all text-ink">{discord.nickname ?? "No guild nickname"}</dd></div>
|
||||
<div><dt className="uppercase tracking-wider text-muted">Discord ID</dt><dd className="mt-1 break-all text-ink">{discord.id}</dd></div>
|
||||
</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">Discord preview: <strong className="text-ink">{desiredNickname}</strong></p>}
|
||||
{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>
|
||||
{effectiveGroup ? <div className="mt-4 flex flex-wrap items-center justify-between gap-3"><span className="font-mono text-xs font-bold">{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}</span><div className="flex gap-2"><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${effectiveGroup.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{effectiveGroup.accessEnabled ? "Access on" : "Access off"}</span><span className="border border-line px-2 py-1 font-mono text-[9px] font-bold uppercase">VPN {effectiveGroup.anonymizedNetworksAllowed ? "allowed" : "denied"}</span></div></div> : <p className="mt-4 text-sm text-accent">No default access group is configured.</p>}
|
||||
<p className="mt-4 text-xs leading-5 text-muted">Your effective group alone determines Minecraft and VPN/proxy/Tor access.</p>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,32 +1,44 @@
|
||||
"use server";
|
||||
|
||||
import { appSettings } from "@minecraft-account-manager/database";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { appSettings, events } from "@minecraft-account-manager/database";
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { parseAdmissionMessages } from "@/lib/admission-settings";
|
||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export async function saveDiscordSettings(formData: FormData) {
|
||||
await requireAdminSession();
|
||||
export async function saveAdmissionSettings(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const messages = parseAdmissionMessages(formData);
|
||||
if (!messages) redirect("/admin/settings?error=invalid-message");
|
||||
|
||||
const registrationMessage = String(formData.get("registrationMessage") ?? "").trim();
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
await db.transaction(async (tx) => {
|
||||
const [previous] = await tx.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
||||
const changedFields = (Object.keys(messages) as Array<keyof typeof messages>)
|
||||
.filter((field) => previous?.[field] !== messages[field]);
|
||||
await tx.insert(appSettings)
|
||||
.values({ id: "default", ...messages })
|
||||
.onConflictDoUpdate({
|
||||
target: appSettings.id,
|
||||
set: { ...messages, updatedAt: new Date() },
|
||||
});
|
||||
if (changedFields.length) {
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: "games.minecraft.account-manager.settings.admission-messages-updated",
|
||||
subject: "settings/default",
|
||||
time: new Date(),
|
||||
data: { changedFields, adminEmail: admin.email, adminName: admin.name },
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (registrationMessage.length < 10 || registrationMessage.length > 500) {
|
||||
redirect("/admin?error=invalid-message");
|
||||
}
|
||||
|
||||
await db
|
||||
.insert(appSettings)
|
||||
.values({
|
||||
id: "default",
|
||||
registrationMessage,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: appSettings.id,
|
||||
set: {
|
||||
registrationMessage,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { groupAccessWindows, groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { AdminModalForm } from "@/components/admin-modal-form";
|
||||
import { AdminUserTable } from "@/components/admin-user-table";
|
||||
import { GroupPolicyControl } from "@/components/group-policy-control";
|
||||
import { GroupScheduleEditor, GroupScheduleSummary } from "@/components/group-schedule-editor";
|
||||
import { db } from "@/lib/database";
|
||||
import { isEffectiveGroupMember } from "@/lib/group-management";
|
||||
import { assignUserGroupFromRegistry } from "../../users/actions";
|
||||
import { deleteGroup, replaceGroupSchedule, setGroupAccess, setGroupAnonymizedNetworkAccess, updateGroupDetails } from "../actions";
|
||||
|
||||
const savedMessages: Record<string, string> = {
|
||||
created: "Group created.",
|
||||
details: "Group details updated.",
|
||||
access: "Minecraft access policy updated.",
|
||||
"network-access": "VPN, proxy, and Tor policy updated.",
|
||||
schedule: "Weekly access schedule updated.",
|
||||
group: "Member group updated.",
|
||||
};
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
"invalid-group": "Enter a valid name and a description of no more than 500 characters.",
|
||||
"duplicate-group": "A group with that name already exists.",
|
||||
"invalid-group-assignment": "The user or destination group no longer exists. No membership change was applied.",
|
||||
"invalid-schedule": "Use valid, non-overlapping weekly access windows. Start and end cannot be identical.",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function GroupPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ groupId: string }>;
|
||||
searchParams: Promise<{ error?: string; saved?: string }>;
|
||||
}) {
|
||||
const { groupId } = await params;
|
||||
const query = await searchParams;
|
||||
const [group] = await db.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||
if (!group) notFound();
|
||||
|
||||
const [allUsers, allGroups, memberships, accessWindows] = await Promise.all([
|
||||
db.select({
|
||||
id: users.id,
|
||||
firstName: users.firstName,
|
||||
discordUsername: users.discordUsername,
|
||||
discordGlobalName: users.discordGlobalName,
|
||||
discordUserId: users.discordUserId,
|
||||
onboardingCompletedAt: users.onboardingCompletedAt,
|
||||
primaryUsername: minecraftAccounts.username,
|
||||
accountCount: sql<number>`(
|
||||
select count(*)::int from ${minecraftAccounts} account_count
|
||||
where account_count.user_id = ${users.id}
|
||||
and account_count.deleted_at is null
|
||||
)`,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(minecraftAccounts, and(
|
||||
eq(minecraftAccounts.userId, users.id),
|
||||
eq(minecraftAccounts.isPrimary, true),
|
||||
isNull(minecraftAccounts.deletedAt),
|
||||
))
|
||||
.orderBy(users.firstName, users.discordUsername),
|
||||
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault })
|
||||
.from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
|
||||
db.select({ userId: userGroupMemberships.userId, groupId: userGroupMemberships.groupId })
|
||||
.from(userGroupMemberships),
|
||||
db.select({
|
||||
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
|
||||
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
|
||||
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id))
|
||||
.orderBy(groupAccessWindows.startMinuteOfWeek),
|
||||
]);
|
||||
const assignmentByUser = Object.fromEntries(memberships.map((membership) => [membership.userId, membership.groupId]));
|
||||
const memberUsers = allUsers.filter((user) => isEffectiveGroupMember(user.id, assignmentByUser, group));
|
||||
const returnTo = `/admin/groups/${group.id}`;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-12">
|
||||
<Link className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted underline underline-offset-4" href="/admin/groups">← All groups</Link>
|
||||
<header className="mt-7 flex flex-col gap-6 border-b border-line pb-8 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Access group</p>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3"><h1 className="font-display text-5xl font-black uppercase sm:text-7xl">{group.name}</h1>{group.isDefault && <span className="bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase text-canvas">Default</span>}</div>
|
||||
<p className="mt-3 max-w-2xl whitespace-pre-line text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
|
||||
</div>
|
||||
<AdminModalForm
|
||||
action={updateGroupDetails}
|
||||
description={group.isDefault ? "Update the protected default group's description. Its name remains everyone." : "Update the administrator-facing name and description. The internal slug remains stable."}
|
||||
submitLabel="Save details"
|
||||
title={`Edit ${group.name}`}
|
||||
triggerClassName="border border-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider"
|
||||
triggerLabel="Edit group"
|
||||
>
|
||||
<input name="groupId" type="hidden" value={group.id} />
|
||||
<div className="space-y-5">
|
||||
<label className="block text-sm font-bold">Name<input className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent read-only:cursor-not-allowed read-only:text-muted" defaultValue={group.name} maxLength={50} name="name" readOnly={group.isDefault} required /></label>
|
||||
<label className="block text-sm font-bold">Description<textarea className="mt-2 min-h-32 w-full resize-y border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" defaultValue={group.description ?? ""} maxLength={500} name="description" /></label>
|
||||
</div>
|
||||
</AdminModalForm>
|
||||
</header>
|
||||
|
||||
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">{savedMessages[query.saved] ?? "Group updated."}</p>}
|
||||
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errorMessages[query.error] ?? "The group operation failed."}</p>}
|
||||
|
||||
<section aria-labelledby="group-policy-heading" className="mt-10 border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||
<div className="border-b border-line pb-4"><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Admission controls</p><h2 className="mt-2 font-display text-3xl font-black uppercase" id="group-policy-heading">Group policies</h2></div>
|
||||
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||
<PolicyDetail description="Controls whether members can connect to Minecraft. Disabled access always overrides the schedule." label="Minecraft access"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="Minecraft access" returnLocation="detail" /></PolicyDetail>
|
||||
<PolicyDetail description="Allows confirmed VPN, proxy, and Tor connections after access and schedule checks pass." label="VPN / proxy / Tor"><GroupPolicyControl action={setGroupAnonymizedNetworkAccess} enabled={group.anonymizedNetworksAllowed} groupId={group.id} groupName={group.name} memberCount={memberUsers.length} policy="VPN / proxy / Tor" returnLocation="detail" /></PolicyDetail>
|
||||
</div>
|
||||
<div className="mt-7 scroll-mt-6 border-t border-line pt-6" id="group-schedule">
|
||||
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="max-w-2xl"><h3 className="font-mono text-xs font-bold uppercase">Weekly access schedule</h3><p className="mt-2 text-xs leading-5 text-muted">When Minecraft access is enabled, members may log in only during these recurring UTC windows. Existing sessions are not disconnected when a window ends.</p><div className="mt-4"><GroupScheduleSummary windows={accessWindows} /></div></div>
|
||||
<AdminModalForm action={replaceGroupSchedule} description={`Replace the complete weekly access schedule for ${group.name}. Minecraft access must still be enabled.`} submitLabel="Save schedule" title={`Schedule ${group.name}`} triggerClassName="shrink-0 border border-ink px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-wider" triggerLabel="Edit schedule">
|
||||
<input name="groupId" type="hidden" value={group.id} />
|
||||
<GroupScheduleEditor windows={accessWindows} />
|
||||
</AdminModalForm>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-10" aria-labelledby="group-members-heading">
|
||||
<div className="flex items-end justify-between border-b border-line pb-4">
|
||||
<div><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Effective membership</p><h2 className="mt-2 font-display text-3xl font-black uppercase" id="group-members-heading">Members</h2></div>
|
||||
<span className="font-mono text-xs text-muted">{memberUsers.length} {memberUsers.length === 1 ? "member" : "members"}</span>
|
||||
</div>
|
||||
<p className="border-x border-line bg-panel px-5 py-4 text-sm text-muted">{group.isDefault ? <>These users have no explicit assignment and therefore use <strong className="text-ink">everyone</strong>.</> : <>Choose another group to move a member, or choose <strong className="text-ink">everyone</strong> to remove the member from {group.name}. Every change requires confirmation.</>}</p>
|
||||
<div className="mt-5"><AdminUserTable action={assignUserGroupFromRegistry} assignmentByUser={assignmentByUser} emptyMessage="This group has no effective members." groups={allGroups} returnTo={returnTo} users={memberUsers} /></div>
|
||||
</section>
|
||||
|
||||
{!group.isDefault && (
|
||||
<section className="mt-12 flex flex-col gap-5 border border-accent bg-panel p-6 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Danger zone</p><h2 className="mt-2 font-display text-2xl font-black uppercase">Delete {group.name}</h2><p className="mt-2 max-w-2xl text-sm leading-6 text-muted">All {memberUsers.length} effective {memberUsers.length === 1 ? "member" : "members"} will return to everyone.</p></div>
|
||||
<AdminModalForm action={deleteGroup} description={`Permanently delete ${group.name} and return ${memberUsers.length} ${memberUsers.length === 1 ? "member" : "members"} to everyone. This cannot be undone.`} intent="danger" submitLabel="Delete group" title={`Delete ${group.name}?`} triggerClassName="bg-accent px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" triggerLabel="Delete group">
|
||||
<input name="groupId" type="hidden" value={group.id} />
|
||||
<input name="confirmDelete" type="hidden" value="yes" />
|
||||
</AdminModalForm>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyDetail({ children, description, label }: { children: ReactNode; description: string; label: string }) {
|
||||
return <div className="flex items-center justify-between gap-5 border-l-2 border-accent pl-5"><div><h3 className="font-mono text-xs font-bold uppercase">{label}</h3><p className="mt-2 text-xs leading-5 text-muted">{description}</p></div>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const actionState = vi.hoisted(() => ({
|
||||
selected: [] as unknown[][],
|
||||
inserted: [] as unknown[],
|
||||
deleted: 0,
|
||||
authorized: 0,
|
||||
failAudit: false,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/require-admin", () => ({
|
||||
requireAdminSession: async () => {
|
||||
actionState.authorized += 1;
|
||||
return { email: "admin@example.test", name: "Admin" };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (path: string) => {
|
||||
throw new Error(`REDIRECT:${path}`);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/database", () => {
|
||||
function selection(response: unknown[]) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
for (const method of ["from", "where", "orderBy"]) chain[method] = () => chain;
|
||||
chain.limit = () => Promise.resolve(response);
|
||||
chain.then = (resolve: (value: unknown[]) => unknown, reject: (reason: unknown) => unknown) =>
|
||||
Promise.resolve(response).then(resolve, reject);
|
||||
return chain;
|
||||
}
|
||||
const tx = {
|
||||
execute: async () => undefined,
|
||||
select: () => selection(actionState.selected.shift() ?? []),
|
||||
delete: () => ({ where: async () => { actionState.deleted += 1; } }),
|
||||
insert: () => ({
|
||||
values: async (value: unknown) => {
|
||||
if (actionState.failAudit && !Array.isArray(value)) throw new Error("audit unavailable");
|
||||
actionState.inserted.push(value);
|
||||
},
|
||||
}),
|
||||
};
|
||||
return { db: { transaction: async (callback: (transaction: typeof tx) => Promise<unknown>) => callback(tx) } };
|
||||
});
|
||||
|
||||
import { replaceGroupSchedule } from "./actions";
|
||||
|
||||
function scheduleForm() {
|
||||
const formData = new FormData();
|
||||
formData.set("groupId", "11111111-1111-4111-8111-111111111111");
|
||||
formData.append("startMinuteOfWeek", "6960");
|
||||
formData.append("endMinuteOfWeek", "7200");
|
||||
return formData;
|
||||
}
|
||||
|
||||
describe("replaceGroupSchedule", () => {
|
||||
beforeEach(() => {
|
||||
actionState.selected = [
|
||||
[{ id: "11111111-1111-4111-8111-111111111111", name: "Friday friends" }],
|
||||
[{ startMinuteOfWeek: 480, endMinuteOfWeek: 540 }],
|
||||
];
|
||||
actionState.inserted = [];
|
||||
actionState.deleted = 0;
|
||||
actionState.authorized = 0;
|
||||
actionState.failAudit = false;
|
||||
});
|
||||
|
||||
it("reauthorizes and replaces all windows with an audit in one transaction", async () => {
|
||||
await expect(replaceGroupSchedule(scheduleForm())).rejects.toThrow("REDIRECT:/admin/groups/11111111-1111-4111-8111-111111111111?saved=schedule");
|
||||
expect(actionState.authorized).toBe(1);
|
||||
expect(actionState.deleted).toBe(1);
|
||||
expect(actionState.inserted[0]).toEqual([{
|
||||
groupId: "11111111-1111-4111-8111-111111111111",
|
||||
startMinuteOfWeek: 6960,
|
||||
endMinuteOfWeek: 7200,
|
||||
}]);
|
||||
expect(actionState.inserted[1]).toEqual(expect.objectContaining({
|
||||
type: "games.minecraft.account-manager.group.schedule-updated",
|
||||
data: expect.objectContaining({
|
||||
previousWindows: [{ startMinuteOfWeek: 480, endMinuteOfWeek: 540 }],
|
||||
windows: [{ startMinuteOfWeek: 6960, endMinuteOfWeek: 7200 }],
|
||||
adminEmail: "admin@example.test",
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not report success when the atomic audit write fails", async () => {
|
||||
actionState.failAudit = true;
|
||||
await expect(replaceGroupSchedule(scheduleForm())).rejects.toThrow("audit unavailable");
|
||||
expect(actionState.inserted).toEqual([[{
|
||||
groupId: "11111111-1111-4111-8111-111111111111",
|
||||
startMinuteOfWeek: 6960,
|
||||
endMinuteOfWeek: 7200,
|
||||
}]]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,247 @@
|
||||
"use server";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { events, groupAccessWindows, groups, userGroupMemberships } from "@minecraft-account-manager/database";
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import { and, eq, ne, sql } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||
import { db } from "@/lib/database";
|
||||
import { editableGroupName, groupSlug, validateGroupDetails } from "@/lib/group-management";
|
||||
import { parseScheduleWindows } from "@/lib/group-schedule";
|
||||
|
||||
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;
|
||||
|
||||
type Admin = Awaited<ReturnType<typeof requireAdminSession>>;
|
||||
type ReturnLocation = "list" | "detail";
|
||||
|
||||
function groupPath(groupId: string, query?: string) {
|
||||
return `/admin/groups/${encodeURIComponent(groupId)}${query ? `?${query}` : ""}`;
|
||||
}
|
||||
|
||||
function returnLocation(formData: FormData): ReturnLocation {
|
||||
return formData.get("returnLocation") === "list" ? "list" : "detail";
|
||||
}
|
||||
|
||||
function operationPath(groupId: string, location: ReturnLocation, query: string) {
|
||||
return location === "list" ? `/admin/groups?${query}` : groupPath(groupId, query);
|
||||
}
|
||||
|
||||
async function auditContext() {
|
||||
const requestHeaders = await headers();
|
||||
return getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
}
|
||||
|
||||
function auditData(admin: Admin, data: Record<string, unknown>) {
|
||||
return { ...data, adminEmail: admin.email, adminName: admin.name };
|
||||
}
|
||||
|
||||
export async function createGroup(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const details = validateGroupDetails(formData.get("name"), formData.get("description"));
|
||||
if (!details) redirect("/admin/groups?error=invalid-group");
|
||||
const accessEnabled = formData.get("accessEnabled") === "yes";
|
||||
const anonymizedNetworksAllowed = formData.get("anonymizedNetworksAllowed") === "yes";
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
let created: { id: string } | null = null;
|
||||
try {
|
||||
created = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-identity'))`);
|
||||
const [duplicate] = await tx.select({ id: groups.id }).from(groups)
|
||||
.where(sql`lower(${groups.name}) = lower(${details.name})`).limit(1);
|
||||
if (duplicate) return null;
|
||||
const existing = await tx.select({ slug: groups.slug }).from(groups);
|
||||
const slug = groupSlug(details.name, new Set(existing.map((group) => group.slug.toLowerCase())));
|
||||
const [group] = await tx.insert(groups).values({
|
||||
name: details.name,
|
||||
slug,
|
||||
description: details.description || null,
|
||||
accessEnabled,
|
||||
anonymizedNetworksAllowed,
|
||||
isDefault: false,
|
||||
}).returning({ id: groups.id });
|
||||
if (!group) throw new Error("Group insert returned no row");
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: "games.minecraft.account-manager.group.created",
|
||||
subject: `group/${group.id}`,
|
||||
time: new Date(),
|
||||
data: auditData(admin, {
|
||||
name: details.name,
|
||||
slug,
|
||||
accessEnabled,
|
||||
anonymizedNetworksAllowed,
|
||||
}),
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
return group;
|
||||
});
|
||||
} catch {
|
||||
redirect("/admin/groups?error=create-failed");
|
||||
}
|
||||
if (!created) redirect("/admin/groups?error=duplicate-group");
|
||||
redirect(groupPath(created.id, "saved=created"));
|
||||
}
|
||||
|
||||
export async function updateGroupDetails(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const groupId = String(formData.get("groupId") ?? "");
|
||||
const details = validateGroupDetails(formData.get("name"), formData.get("description"));
|
||||
if (!UUID_PATTERN.test(groupId) || !details) redirect(operationPath(groupId, "detail", "error=invalid-group"));
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-identity'))`);
|
||||
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
|
||||
const [current] = await tx.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||
if (!current) return "missing" as const;
|
||||
const name = editableGroupName(current.name, current.isDefault, details.name);
|
||||
if (!current.isDefault) {
|
||||
const [duplicate] = await tx.select({ id: groups.id }).from(groups)
|
||||
.where(and(sql`lower(${groups.name}) = lower(${name})`, ne(groups.id, current.id))).limit(1);
|
||||
if (duplicate) return "duplicate" as const;
|
||||
}
|
||||
const [updated] = await tx.update(groups).set({ name, description: details.description || null, updatedAt: new Date() })
|
||||
.where(eq(groups.id, current.id)).returning({ id: groups.id });
|
||||
if (!updated) return "missing" as const;
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: "games.minecraft.account-manager.group.details-updated",
|
||||
subject: `group/${current.id}`,
|
||||
time: new Date(),
|
||||
data: auditData(admin, {
|
||||
previousName: current.name,
|
||||
name,
|
||||
previousDescription: current.description,
|
||||
description: details.description || null,
|
||||
}),
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
return "updated" as const;
|
||||
});
|
||||
if (result === "missing") redirect("/admin/groups?error=unknown-group");
|
||||
if (result === "duplicate") redirect(groupPath(groupId, "error=duplicate-group"));
|
||||
redirect(groupPath(groupId, "saved=details"));
|
||||
}
|
||||
|
||||
async function updateGroupPolicy(
|
||||
formData: FormData,
|
||||
policy: "access" | "anonymized-networks",
|
||||
) {
|
||||
const admin = await requireAdminSession();
|
||||
const groupId = String(formData.get("groupId") ?? "");
|
||||
const location = returnLocation(formData);
|
||||
if (!UUID_PATTERN.test(groupId)) redirect("/admin/groups?error=unknown-group");
|
||||
const enabled = formData.get("enabled") === "yes";
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
const group = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
|
||||
const [current] = await tx.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||
if (!current) return null;
|
||||
const update = policy === "access" ? { accessEnabled: enabled } : { anonymizedNetworksAllowed: enabled };
|
||||
const [updated] = await tx.update(groups).set({ ...update, updatedAt: new Date() })
|
||||
.where(eq(groups.id, current.id)).returning({ id: groups.id });
|
||||
if (!updated) return null;
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: policy === "access"
|
||||
? "games.minecraft.account-manager.group.access-updated"
|
||||
: "games.minecraft.account-manager.group.anonymized-network-access-updated",
|
||||
subject: `group/${current.id}`,
|
||||
time: new Date(),
|
||||
data: auditData(admin, {
|
||||
name: current.name,
|
||||
previousEnabled: policy === "access" ? current.accessEnabled : current.anonymizedNetworksAllowed,
|
||||
enabled,
|
||||
}),
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
return current;
|
||||
});
|
||||
if (!group) redirect("/admin/groups?error=unknown-group");
|
||||
redirect(operationPath(group.id, location, `saved=${policy === "access" ? "access" : "network-access"}`));
|
||||
}
|
||||
|
||||
export async function setGroupAccess(formData: FormData) {
|
||||
return updateGroupPolicy(formData, "access");
|
||||
}
|
||||
|
||||
export async function setGroupAnonymizedNetworkAccess(formData: FormData) {
|
||||
return updateGroupPolicy(formData, "anonymized-networks");
|
||||
}
|
||||
|
||||
export async function replaceGroupSchedule(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const groupId = String(formData.get("groupId") ?? "");
|
||||
const windows = parseScheduleWindows(formData);
|
||||
if (!UUID_PATTERN.test(groupId) || !windows) redirect(groupPath(groupId, "error=invalid-schedule"));
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
|
||||
const [group] = await tx.select({ id: groups.id, name: groups.name }).from(groups)
|
||||
.where(eq(groups.id, groupId)).limit(1);
|
||||
if (!group) return null;
|
||||
const previous = await tx.select({
|
||||
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
|
||||
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
|
||||
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id));
|
||||
await tx.delete(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id));
|
||||
if (windows.length) {
|
||||
await tx.insert(groupAccessWindows).values(windows.map((window) => ({ ...window, groupId: group.id })));
|
||||
}
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: "games.minecraft.account-manager.group.schedule-updated",
|
||||
subject: `group/${group.id}`,
|
||||
time: new Date(),
|
||||
data: auditData(admin, { name: group.name, previousWindows: previous, windows }),
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
return group;
|
||||
});
|
||||
if (!updated) redirect("/admin/groups?error=unknown-group");
|
||||
redirect(groupPath(updated.id, "saved=schedule"));
|
||||
}
|
||||
|
||||
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 ipAddress = await auditContext();
|
||||
|
||||
const deleted = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-membership'))`);
|
||||
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
|
||||
const [group] = await tx.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||
if (!group || group.isDefault) return null;
|
||||
const members = await tx.select({ userId: userGroupMemberships.userId })
|
||||
.from(userGroupMemberships).where(eq(userGroupMemberships.groupId, group.id));
|
||||
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: auditData(admin, {
|
||||
name: group.name,
|
||||
slug: group.slug,
|
||||
affectedUsers: members.length,
|
||||
fallbackGroup: "everyone",
|
||||
}),
|
||||
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");
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { groupAccessWindows, groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { asc, count, desc } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { AdminModalForm } from "@/components/admin-modal-form";
|
||||
import { GroupPolicyControl } from "@/components/group-policy-control";
|
||||
import { db } from "@/lib/database";
|
||||
import { effectiveGroupMemberCount } from "@/lib/group-management";
|
||||
import { groupScheduleStatus } from "@/lib/group-schedule";
|
||||
import { createGroup, setGroupAccess, setGroupAnonymizedNetworkAccess } from "./actions";
|
||||
|
||||
const errors: Record<string, string> = {
|
||||
"invalid-group": "Enter a group name and an optional description of no more than 500 characters.",
|
||||
"duplicate-group": "A group with that name already exists.",
|
||||
"create-failed": "The group could not be created.",
|
||||
"unknown-group": "That group no longer exists.",
|
||||
"invalid-delete": "Confirm the group deletion before continuing.",
|
||||
"protected-group": "The protected default group cannot be deleted.",
|
||||
};
|
||||
|
||||
const savedMessages: Record<string, string> = {
|
||||
deleted: "Group deleted. Its former members now use the default group.",
|
||||
access: "Minecraft access policy updated.",
|
||||
"network-access": "VPN, proxy, and Tor policy updated.",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function GroupsPage({ searchParams }: { searchParams: Promise<{ error?: string; saved?: string }> }) {
|
||||
const query = await searchParams;
|
||||
const [allGroups, memberships, [registeredUsers], scheduleCounts] = await Promise.all([
|
||||
db.select().from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
|
||||
db.select({ groupId: userGroupMemberships.groupId }).from(userGroupMemberships),
|
||||
db.select({ count: count() }).from(users),
|
||||
db.select({ groupId: groupAccessWindows.groupId, count: count() })
|
||||
.from(groupAccessWindows)
|
||||
.groupBy(groupAccessWindows.groupId),
|
||||
]);
|
||||
const scheduleCountByGroup = new Map(scheduleCounts.map((schedule) => [schedule.groupId, Number(schedule.count)]));
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||
<header className="flex flex-col gap-6 border-b border-line pb-8 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Admission policy</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase">Access groups</h1>
|
||||
<p className="mt-5 max-w-2xl leading-7 text-muted">One effective group controls Minecraft and VPN access. Every policy change asks for confirmation before it applies.</p>
|
||||
</div>
|
||||
<AdminModalForm
|
||||
action={createGroup}
|
||||
description="Create a named access group. Both policies start denied unless you explicitly enable them below."
|
||||
submitLabel="Create group"
|
||||
title="Add access group"
|
||||
triggerClassName="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas"
|
||||
triggerLabel="Add group"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<label className="block text-sm font-bold">Name<input autoComplete="off" className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={50} name="name" required /></label>
|
||||
<label className="block text-sm font-bold">Description<textarea className="mt-2 min-h-28 w-full resize-y border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={500} name="description" /></label>
|
||||
<PolicyCheckbox description="Allow members to connect to Minecraft." label="Minecraft access" name="accessEnabled" />
|
||||
<PolicyCheckbox description="Allow confirmed VPN, proxy, and Tor connections." label="VPN / proxy / Tor exception" name="anonymizedNetworksAllowed" />
|
||||
</div>
|
||||
</AdminModalForm>
|
||||
</header>
|
||||
|
||||
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errors[query.error] ?? "The group operation failed."}</p>}
|
||||
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">{savedMessages[query.saved] ?? "Group updated."}</p>}
|
||||
|
||||
<div className="mt-9 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<table className="w-full min-w-[880px] border-collapse text-left">
|
||||
<caption className="sr-only">Access groups and their effective policies</caption>
|
||||
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
|
||||
<tr><th className="p-4" scope="col">Name</th><th className="p-4" scope="col">Minecraft access</th><th className="p-4" scope="col">Schedule</th><th className="p-4" scope="col">VPN access</th><th className="p-4 text-right" scope="col">Users</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{allGroups.map((group) => {
|
||||
const memberCount = effectiveGroupMemberCount(
|
||||
Number(registeredUsers?.count ?? 0),
|
||||
memberships.map((membership) => membership.groupId),
|
||||
group,
|
||||
);
|
||||
const scheduleStatus = groupScheduleStatus(scheduleCountByGroup.get(group.id) ?? 0);
|
||||
return (
|
||||
<tr className="transition-colors hover:bg-canvas/60" key={group.id}>
|
||||
<th className="p-4 text-left" scope="row">
|
||||
<Link className="font-display text-xl font-black uppercase underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/groups/${group.id}`}>{group.name}</Link>
|
||||
{group.isDefault && <span className="ml-3 bg-ink px-2 py-1 font-mono text-[8px] font-bold uppercase text-canvas">Default</span>}
|
||||
</th>
|
||||
<td className="p-4"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberCount} policy="Minecraft access" returnLocation="list" /></td>
|
||||
<td className="p-4"><Link aria-label={`${scheduleStatus}. Edit schedule for ${group.name}`} className={`font-mono text-[10px] font-bold uppercase underline underline-offset-4 ${scheduleStatus === "Unrestricted" ? "text-muted" : "text-accent"}`} href={`/admin/groups/${group.id}#group-schedule`}>{scheduleStatus}</Link></td>
|
||||
<td className="p-4"><GroupPolicyControl action={setGroupAnonymizedNetworkAccess} enabled={group.anonymizedNetworksAllowed} groupId={group.id} groupName={group.name} memberCount={memberCount} policy="VPN / proxy / Tor" returnLocation="list" /></td>
|
||||
<td className="p-4 text-right font-mono text-sm font-bold">{memberCount}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="mt-4 text-xs leading-5 text-muted">Users without an explicit assignment count toward <strong className="text-ink">everyone</strong>.</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyCheckbox({ description, label, name }: { description: string; label: string; name: string }) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-start justify-between gap-4 border border-line bg-canvas p-4">
|
||||
<span><span className="block font-mono text-xs font-bold uppercase">{label}</span><span className="mt-1 block text-xs leading-5 text-muted">{description}</span></span>
|
||||
<input className="mt-1 size-5 accent-[var(--color-accent)]" name={name} type="checkbox" value="yes" />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -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,11 +31,14 @@ 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">
|
||||
<Link className="font-display text-sm font-black uppercase tracking-[0.2em]" href="/admin">Blocklist / 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>
|
||||
<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 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/rcon">RCON</Link>
|
||||
<Link className="hover:text-accent" href="/admin/events">Events</Link>
|
||||
</nav>
|
||||
<AdminSignOutButton />
|
||||
|
||||
@@ -1,48 +1,216 @@
|
||||
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 { MAP_LOCATION_CLASSIFICATIONS, parseUserLocation, parseUserNetwork } 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.";
|
||||
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: ipIntelligence.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),
|
||||
inArray(ipIntelligence.classification, MAP_LOCATION_CLASSIFICATIONS),
|
||||
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 [];
|
||||
const network = parseUserNetwork(row.intelligence);
|
||||
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,
|
||||
networkProvider: network.provider,
|
||||
networkAsn: network.asn,
|
||||
connectionType: network.connectionType,
|
||||
proxy: network.proxy,
|
||||
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-2 font-mono text-[10px] uppercase tracking-wider text-muted">
|
||||
<div><dt className="inline font-bold text-ink">Guild:</dt> <dd className="inline">{process.env.DISCORD_GUILD_ID ? "configured" : "missing"}</dd></div>
|
||||
<div><dt className="inline font-bold text-ink">Invite:</dt> <dd className="inline">{process.env.DISCORD_INVITE_URL ? "configured" : "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,248 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const actionState = vi.hoisted(() => ({
|
||||
authorized: 0,
|
||||
selected: [] as unknown[],
|
||||
transactionSelected: [] as unknown[],
|
||||
updates: [] as Record<string, unknown>[],
|
||||
inserts: [] as unknown[],
|
||||
audits: [] as Array<{ admin: unknown; subject: string; type: string; data: Record<string, unknown>; correlationId?: string }>,
|
||||
auditFailure: false,
|
||||
executions: [] as Array<{ connection: Record<string, unknown>; command: string }>,
|
||||
gatewayResult: { ok: true, response: "private response" } as
|
||||
| { ok: true; response: string }
|
||||
| { ok: false; reason: "busy" | "timeout" | "unavailable" },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/require-admin", () => ({
|
||||
requireAdminSession: async () => {
|
||||
actionState.authorized += 1;
|
||||
return { email: "admin@example.test", name: "Admin" };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: (path: string) => {
|
||||
throw new Error(`REDIRECT:${path}`);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/database", () => {
|
||||
function selection(result: unknown[]) {
|
||||
const chain = {
|
||||
from: () => chain,
|
||||
where: () => chain,
|
||||
limit: async () => result,
|
||||
};
|
||||
return chain;
|
||||
}
|
||||
const tx = {
|
||||
execute: async () => undefined,
|
||||
select: () => selection(actionState.transactionSelected),
|
||||
update: () => ({
|
||||
set: (value: Record<string, unknown>) => ({
|
||||
where: async () => { actionState.updates.push(value); },
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: async (value: unknown) => { actionState.inserts.push(value); },
|
||||
}),
|
||||
};
|
||||
return {
|
||||
db: {
|
||||
select: () => selection(actionState.selected),
|
||||
transaction: async (callback: (transaction: typeof tx) => Promise<unknown>) => callback(tx),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rcon-validation", () => ({
|
||||
validateRconCommand: (value: unknown) => typeof value === "string" && value.trim() ? value.trim() : null,
|
||||
validateRconConnection: (input: { name?: string; host?: string; port?: number; password?: string }) => {
|
||||
if (!input.name || !input.host || !input.port) return null;
|
||||
return input;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rcon-credentials", () => ({
|
||||
decryptRconPassword: () => "decrypted-password",
|
||||
encryptRconPassword: vi.fn(),
|
||||
rconCommandDigest: () => "hmac-sha256:v1:digest",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rcon-gateway", () => ({
|
||||
executeRcon: async (connection: Record<string, unknown>, command: string) => {
|
||||
actionState.executions.push({ connection, command });
|
||||
return actionState.gatewayResult;
|
||||
},
|
||||
testRconConnection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/audit", () => ({
|
||||
recordAdminSubjectEvent: async (
|
||||
admin: unknown,
|
||||
subject: string,
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
options?: { correlationId?: string },
|
||||
) => {
|
||||
if (actionState.auditFailure) throw new Error("audit unavailable");
|
||||
actionState.audits.push({ admin, subject, type, data, correlationId: options?.correlationId });
|
||||
return "22222222-2222-4222-8222-222222222222";
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
createRconServer,
|
||||
deleteRconServer,
|
||||
executeRconCommand,
|
||||
setRconServerEnabled,
|
||||
testSavedRconServer,
|
||||
updateRconServer,
|
||||
} from "./actions";
|
||||
|
||||
const serverId = "11111111-1111-4111-8111-111111111111";
|
||||
const savedServer = {
|
||||
id: serverId,
|
||||
name: "Season 4",
|
||||
host: "season4.somc.svc.cluster.local",
|
||||
port: 25575,
|
||||
encryptedPassword: "ciphertext",
|
||||
enabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
describe("RCON server actions", () => {
|
||||
beforeEach(() => {
|
||||
actionState.authorized = 0;
|
||||
actionState.selected = [];
|
||||
actionState.transactionSelected = [];
|
||||
actionState.updates = [];
|
||||
actionState.inserts = [];
|
||||
actionState.audits = [];
|
||||
actionState.auditFailure = false;
|
||||
actionState.executions = [];
|
||||
actionState.gatewayResult = { ok: true, response: "private response" };
|
||||
});
|
||||
|
||||
it("independently authorizes every exported operation before accepting input", async () => {
|
||||
await expect(createRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
|
||||
await expect(updateRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
|
||||
await expect(setRconServerEnabled(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=unknown-connection");
|
||||
await expect(deleteRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=confirmation-required");
|
||||
await expect(testSavedRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=connection-unavailable");
|
||||
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, new FormData())).resolves.toEqual({
|
||||
status: "error",
|
||||
message: "Enter one command of at most 1,024 bytes without control characters.",
|
||||
serverId: "",
|
||||
});
|
||||
expect(actionState.authorized).toBe(6);
|
||||
});
|
||||
|
||||
it("preserves the encrypted password on an unrelated connection update", async () => {
|
||||
actionState.transactionSelected = [savedServer];
|
||||
const formData = new FormData();
|
||||
formData.set("serverId", serverId);
|
||||
formData.set("name", "Renamed server");
|
||||
formData.set("host", "season4.somc.svc.cluster.local");
|
||||
formData.set("port", "25575");
|
||||
formData.set("password", "");
|
||||
formData.set("enabled", "yes");
|
||||
|
||||
await expect(updateRconServer(formData)).rejects.toThrow("REDIRECT:/admin/rcon?saved=updated");
|
||||
expect(actionState.updates).toEqual([
|
||||
expect.objectContaining({ encryptedPassword: "ciphertext", enabled: true }),
|
||||
]);
|
||||
expect(JSON.stringify(actionState.inserts)).not.toContain("ciphertext");
|
||||
expect(JSON.stringify(actionState.inserts)).not.toContain("decrypted-password");
|
||||
});
|
||||
|
||||
it("rechecks enabled saved state and records complete command lifecycle audits", async () => {
|
||||
actionState.selected = [savedServer];
|
||||
const formData = new FormData();
|
||||
formData.set("serverId", serverId);
|
||||
formData.set("command", "say private value");
|
||||
|
||||
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
|
||||
status: "success",
|
||||
message: "private response",
|
||||
serverId,
|
||||
});
|
||||
|
||||
expect(actionState.authorized).toBe(1);
|
||||
expect(actionState.executions).toEqual([{
|
||||
connection: expect.objectContaining({ id: serverId, enabled: true, password: "decrypted-password" }),
|
||||
command: "say private value",
|
||||
}]);
|
||||
expect(actionState.audits).toEqual([
|
||||
expect.objectContaining({
|
||||
admin: { email: "admin@example.test", name: "Admin" },
|
||||
subject: `rcon-server/${serverId}`,
|
||||
type: "games.minecraft.account-manager.rcon.command.requested",
|
||||
data: expect.objectContaining({ command: "say private value", verb: "say", commandDigest: "hmac-sha256:v1:digest" }),
|
||||
correlationId: expect.stringMatching(/^[0-9a-f-]{36}$/),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
subject: `rcon-server/${serverId}`,
|
||||
type: "games.minecraft.account-manager.rcon.command.completed",
|
||||
data: expect.objectContaining({ success: true, durationMs: expect.any(Number) }),
|
||||
correlationId: expect.stringMatching(/^[0-9a-f-]{36}$/),
|
||||
}),
|
||||
]);
|
||||
const serializedAudits = JSON.stringify(actionState.audits);
|
||||
expect(actionState.audits[0]?.correlationId).toBe(actionState.audits[1]?.correlationId);
|
||||
expect(serializedAudits).toContain("private value");
|
||||
expect(serializedAudits).not.toContain("private response");
|
||||
expect(serializedAudits).not.toContain("decrypted-password");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["busy", "Another command is already running for this server."],
|
||||
["timeout", "The RCON request timed out."],
|
||||
["unavailable", "The RCON server was unavailable or rejected authentication."],
|
||||
] as const)("returns a safe %s failure without exposing transport details", async (reason, message) => {
|
||||
actionState.selected = [savedServer];
|
||||
actionState.gatewayResult = { ok: false, reason };
|
||||
const formData = new FormData();
|
||||
formData.set("serverId", serverId);
|
||||
formData.set("command", "list");
|
||||
|
||||
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
|
||||
status: "error",
|
||||
message,
|
||||
serverId,
|
||||
});
|
||||
expect(JSON.stringify(actionState.audits)).not.toContain("decrypted-password");
|
||||
});
|
||||
|
||||
it("does not send a command when its requested audit cannot be recorded", async () => {
|
||||
actionState.selected = [savedServer];
|
||||
actionState.auditFailure = true;
|
||||
const formData = new FormData();
|
||||
formData.set("serverId", serverId);
|
||||
formData.set("command", "list");
|
||||
|
||||
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
|
||||
status: "error",
|
||||
message: "Command not sent because its audit record could not be created.",
|
||||
serverId,
|
||||
});
|
||||
expect(actionState.executions).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not execute or audit when the enabled connection is unavailable", async () => {
|
||||
const formData = new FormData();
|
||||
formData.set("serverId", serverId);
|
||||
formData.set("command", "list");
|
||||
|
||||
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
|
||||
status: "error",
|
||||
message: "That RCON connection is disabled or unavailable.",
|
||||
serverId,
|
||||
});
|
||||
expect(actionState.executions).toEqual([]);
|
||||
expect(actionState.audits).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
"use server";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { events, rconServers } from "@minecraft-account-manager/database";
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||
import { db } from "@/lib/database";
|
||||
import { isUniqueConstraintViolation } from "@/lib/database-errors";
|
||||
import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "@/lib/rcon-credentials";
|
||||
import { executeRcon, testRconConnection } from "@/lib/rcon-gateway";
|
||||
import { recordAdminSubjectEvent } from "@/lib/audit";
|
||||
import { validateRconCommand, validateRconConnection } from "@/lib/rcon-validation";
|
||||
|
||||
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;
|
||||
|
||||
type Admin = Awaited<ReturnType<typeof requireAdminSession>>;
|
||||
export type RconCommandState = {
|
||||
status: "idle" | "success" | "error";
|
||||
message: string;
|
||||
serverId: string;
|
||||
};
|
||||
|
||||
function formConnection(formData: FormData, passwordRequired: boolean) {
|
||||
return validateRconConnection({
|
||||
name: formData.get("name"),
|
||||
host: formData.get("host"),
|
||||
port: formData.get("port"),
|
||||
password: formData.get("password"),
|
||||
}, { passwordRequired });
|
||||
}
|
||||
|
||||
async function auditContext() {
|
||||
const requestHeaders = await headers();
|
||||
return getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
}
|
||||
|
||||
function auditData(admin: Admin, data: Record<string, unknown>) {
|
||||
return { ...data, adminEmail: admin.email, adminName: admin.name };
|
||||
}
|
||||
|
||||
function rconPath(query: string) {
|
||||
return `/admin/rcon?${query}`;
|
||||
}
|
||||
|
||||
export async function createRconServer(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const details = formConnection(formData, true);
|
||||
if (!details?.password) redirect(rconPath("error=invalid-connection"));
|
||||
const id = randomUUID();
|
||||
let encryptedPassword: string;
|
||||
try {
|
||||
encryptedPassword = encryptRconPassword(details.password, id);
|
||||
} catch {
|
||||
redirect(rconPath("error=configuration"));
|
||||
}
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(rconServers).values({
|
||||
id,
|
||||
name: details.name,
|
||||
host: details.host,
|
||||
port: details.port,
|
||||
encryptedPassword,
|
||||
enabled: formData.get("enabled") === "yes",
|
||||
});
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: "games.minecraft.account-manager.rcon.connection.created",
|
||||
subject: `rcon-server/${id}`,
|
||||
time: new Date(),
|
||||
data: auditData(admin, { name: details.name, host: details.host, port: details.port }),
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintViolation(error, "rcon_servers_name_uidx")) redirect(rconPath("error=duplicate-name"));
|
||||
redirect(rconPath("error=save-failed"));
|
||||
}
|
||||
redirect(rconPath("saved=created"));
|
||||
}
|
||||
|
||||
export async function updateRconServer(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const serverId = String(formData.get("serverId") ?? "");
|
||||
const details = formConnection(formData, false);
|
||||
if (!UUID_PATTERN.test(serverId) || !details) redirect(rconPath("error=invalid-connection"));
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
let result: string | null;
|
||||
try {
|
||||
result = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select ${rconServers.id} from ${rconServers} where ${rconServers.id} = ${serverId} for update`);
|
||||
const [current] = await tx.select().from(rconServers).where(eq(rconServers.id, serverId)).limit(1);
|
||||
if (!current) return null;
|
||||
let encryptedPassword = current.encryptedPassword;
|
||||
if (details.password) encryptedPassword = encryptRconPassword(details.password, current.id);
|
||||
const enabled = formData.get("enabled") === "yes";
|
||||
await tx.update(rconServers).set({
|
||||
name: details.name,
|
||||
host: details.host,
|
||||
port: details.port,
|
||||
encryptedPassword,
|
||||
enabled,
|
||||
updatedAt: new Date(),
|
||||
}).where(eq(rconServers.id, current.id));
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: "games.minecraft.account-manager.rcon.connection.updated",
|
||||
subject: `rcon-server/${current.id}`,
|
||||
time: new Date(),
|
||||
data: auditData(admin, {
|
||||
name: details.name,
|
||||
host: details.host,
|
||||
port: details.port,
|
||||
enabled,
|
||||
passwordReplaced: Boolean(details.password),
|
||||
}),
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
return current.id;
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintViolation(error, "rcon_servers_name_uidx")) redirect(rconPath("error=duplicate-name"));
|
||||
redirect(rconPath("error=save-failed"));
|
||||
}
|
||||
if (!result) redirect(rconPath("error=unknown-connection"));
|
||||
redirect(rconPath("saved=updated"));
|
||||
}
|
||||
|
||||
export async function setRconServerEnabled(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const serverId = String(formData.get("serverId") ?? "");
|
||||
if (!UUID_PATTERN.test(serverId)) redirect(rconPath("error=unknown-connection"));
|
||||
const enabled = formData.get("enabled") === "yes";
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
const result = await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select ${rconServers.id} from ${rconServers} where ${rconServers.id} = ${serverId} for update`);
|
||||
const [current] = await tx.select().from(rconServers).where(eq(rconServers.id, serverId)).limit(1);
|
||||
if (!current) return "missing" as const;
|
||||
if (enabled && !validateRconConnection({ ...current, password: "placeholder" }, { passwordRequired: true })) return "invalid" as const;
|
||||
await tx.update(rconServers).set({ enabled, updatedAt: new Date() }).where(eq(rconServers.id, current.id));
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.rcon.connection.enabled-updated",
|
||||
subject: `rcon-server/${current.id}`, time: new Date(), data: auditData(admin, { name: current.name, enabled }), ipAddress: ipAddress ?? null,
|
||||
});
|
||||
return "updated" as const;
|
||||
});
|
||||
if (result === "missing") redirect(rconPath("error=unknown-connection"));
|
||||
if (result === "invalid") redirect(rconPath("error=invalid-connection"));
|
||||
redirect(rconPath(`saved=${enabled ? "enabled" : "disabled"}`));
|
||||
}
|
||||
|
||||
export async function deleteRconServer(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const serverId = String(formData.get("serverId") ?? "");
|
||||
if (!UUID_PATTERN.test(serverId) || formData.get("confirmation") !== serverId) redirect(rconPath("error=confirmation-required"));
|
||||
const ipAddress = await auditContext();
|
||||
|
||||
const deleted = await db.transaction(async (tx) => {
|
||||
const [server] = await tx.delete(rconServers).where(eq(rconServers.id, serverId)).returning({ id: rconServers.id, name: rconServers.name });
|
||||
if (!server) return null;
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.rcon.connection.deleted",
|
||||
subject: `rcon-server/${server.id}`, time: new Date(), data: auditData(admin, { name: server.name }), ipAddress: ipAddress ?? null,
|
||||
});
|
||||
return server;
|
||||
});
|
||||
if (!deleted) redirect(rconPath("error=unknown-connection"));
|
||||
redirect(rconPath("saved=deleted"));
|
||||
}
|
||||
|
||||
async function savedConnection(serverId: string, requireEnabled: boolean) {
|
||||
if (!UUID_PATTERN.test(serverId)) return null;
|
||||
const [server] = await db.select().from(rconServers).where(requireEnabled
|
||||
? and(eq(rconServers.id, serverId), eq(rconServers.enabled, true))
|
||||
: eq(rconServers.id, serverId)).limit(1);
|
||||
if (!server) return null;
|
||||
const validated = validateRconConnection({ ...server, password: "placeholder" }, { passwordRequired: true });
|
||||
if (!validated) return null;
|
||||
try {
|
||||
return { ...server, password: decryptRconPassword(server.encryptedPassword, server.id) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function testSavedRconServer(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const serverId = String(formData.get("serverId") ?? "");
|
||||
const server = await savedConnection(serverId, false);
|
||||
if (!server) redirect(rconPath("error=connection-unavailable"));
|
||||
const started = Date.now();
|
||||
const result = await testRconConnection(server);
|
||||
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.connection.tested", {
|
||||
serverId: server.id,
|
||||
name: server.name,
|
||||
success: result.ok,
|
||||
reason: result.ok ? null : result.reason,
|
||||
durationMs: Date.now() - started,
|
||||
});
|
||||
redirect(rconPath(result.ok ? "saved=tested" : `error=test-${result.reason}`));
|
||||
}
|
||||
|
||||
export async function executeRconCommand(
|
||||
_previous: RconCommandState,
|
||||
formData: FormData,
|
||||
): Promise<RconCommandState> {
|
||||
const admin = await requireAdminSession();
|
||||
const serverId = String(formData.get("serverId") ?? "");
|
||||
const command = validateRconCommand(formData.get("command"));
|
||||
if (!command) return { status: "error", message: "Enter one command of at most 1,024 bytes without control characters.", serverId };
|
||||
const server = await savedConnection(serverId, true);
|
||||
if (!server) return { status: "error", message: "That RCON connection is disabled or unavailable.", serverId };
|
||||
const verb = command.split(/\s+/u, 1)[0]!.toLowerCase().slice(0, 64);
|
||||
let commandDigest: string;
|
||||
try {
|
||||
commandDigest = rconCommandDigest(command);
|
||||
} catch {
|
||||
return { status: "error", message: "RCON command auditing is not configured.", serverId };
|
||||
}
|
||||
const started = Date.now();
|
||||
const correlationId = randomUUID();
|
||||
|
||||
try {
|
||||
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.requested", {
|
||||
serverId: server.id,
|
||||
name: server.name,
|
||||
command,
|
||||
verb,
|
||||
commandDigest,
|
||||
}, { correlationId });
|
||||
} catch {
|
||||
return { status: "error", message: "Command not sent because its audit record could not be created.", serverId };
|
||||
}
|
||||
const result = await executeRcon(server, command);
|
||||
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.completed", {
|
||||
serverId: server.id,
|
||||
name: server.name,
|
||||
verb,
|
||||
commandDigest,
|
||||
success: result.ok,
|
||||
reason: result.ok ? null : result.reason,
|
||||
durationMs: Date.now() - started,
|
||||
}, { correlationId });
|
||||
if (!result.ok) {
|
||||
const message = result.reason === "busy"
|
||||
? "Another command is already running for this server."
|
||||
: result.reason === "timeout"
|
||||
? "The RCON request timed out."
|
||||
: "The RCON server was unavailable or rejected authentication.";
|
||||
return { status: "error", message, serverId };
|
||||
}
|
||||
return { status: "success", message: result.response || "Command completed with no response.", serverId };
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { events, rconServers } from "@minecraft-account-manager/database";
|
||||
import { and, desc, eq, ilike, inArray, or, sql, type SQL } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { db } from "@/lib/database";
|
||||
import {
|
||||
buildRconCommandHistory,
|
||||
normalizeRconHistoryFilters,
|
||||
RCON_COMMAND_COMPLETED,
|
||||
RCON_COMMAND_REQUESTED,
|
||||
} from "@/lib/rcon-command-history";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const requestedFields = {
|
||||
id: events.id,
|
||||
time: events.time,
|
||||
correlationId: events.correlationId,
|
||||
data: events.data,
|
||||
};
|
||||
|
||||
export default async function RconHistoryPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const [query, servers] = await Promise.all([
|
||||
searchParams,
|
||||
db.select({ id: rconServers.id, name: rconServers.name }).from(rconServers).orderBy(rconServers.name),
|
||||
]);
|
||||
const filters = normalizeRconHistoryFilters(query, servers.map((server) => server.id));
|
||||
const conditions: SQL[] = [eq(events.type, RCON_COMMAND_REQUESTED)];
|
||||
if (filters.serverId) conditions.push(eq(events.subject, `rcon-server/${filters.serverId}`));
|
||||
if (filters.command) conditions.push(ilike(sql<string>`${events.data} ->> 'command'`, `%${filters.command}%`));
|
||||
if (filters.admin) {
|
||||
conditions.push(or(
|
||||
ilike(sql<string>`${events.data} ->> 'adminEmail'`, `%${filters.admin}%`),
|
||||
ilike(sql<string>`${events.data} ->> 'adminName'`, `%${filters.admin}%`),
|
||||
)!);
|
||||
}
|
||||
|
||||
const requested = await db.select(requestedFields)
|
||||
.from(events)
|
||||
.where(and(...conditions))
|
||||
.orderBy(desc(events.time))
|
||||
.limit(100);
|
||||
const correlationIds = requested.flatMap((event) => event.correlationId ? [event.correlationId] : []);
|
||||
const completed = correlationIds.length
|
||||
? await db.select(requestedFields).from(events).where(and(
|
||||
eq(events.type, RCON_COMMAND_COMPLETED),
|
||||
inArray(events.correlationId, correlationIds),
|
||||
))
|
||||
: [];
|
||||
const history = buildRconCommandHistory(requested, completed);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-6 py-14">
|
||||
<Link className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted underline underline-offset-4" href="/admin/rcon">← RCON console</Link>
|
||||
<header className="mt-7 grid gap-5 border-b-2 border-ink pb-8 lg:grid-cols-[1fr_auto] lg:items-end">
|
||||
<div>
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Persistent audit ledger</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">Command history</h1>
|
||||
<p className="mt-4 max-w-2xl text-sm leading-6 text-muted">Search commands sent through the portal. Responses and RCON credentials are never retained here.</p>
|
||||
</div>
|
||||
<div className="border border-line bg-panel px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-widest">
|
||||
<span className="text-accent">{history.length}</span> matching records
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form className="mt-8 border border-line bg-panel p-5 shadow-[6px_6px_0_var(--color-shadow)]" method="get">
|
||||
<div className="grid gap-5 md:grid-cols-3">
|
||||
<Filter label="Command text" name="command" placeholder="say, whitelist add…" value={filters.command} />
|
||||
<Filter label="Administrator" name="admin" placeholder="name or email" value={filters.admin} />
|
||||
<label className="font-mono text-[10px] font-bold uppercase tracking-wider" htmlFor="history-server">
|
||||
Server
|
||||
<select className="mt-2 block w-full border border-line bg-canvas px-3 py-3 font-sans text-sm font-normal normal-case outline-none focus:border-accent" defaultValue={filters.serverId} id="history-server" name="server">
|
||||
<option value="">All servers</option>
|
||||
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</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">Search history</button>
|
||||
<Link className="self-center font-mono text-[10px] font-bold uppercase underline underline-offset-4" href="/admin/rcon/history">Clear filters</Link>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-8 overflow-x-auto border-2 border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<table className="w-full min-w-[980px] border-collapse text-left">
|
||||
<caption className="sr-only">RCON command audit history</caption>
|
||||
<thead className="border-b-2 border-ink bg-canvas font-mono text-[10px] uppercase tracking-widest text-muted">
|
||||
<tr><th className="p-4" scope="col">Time</th><th className="p-4" scope="col">Server</th><th className="p-4" scope="col">Administrator</th><th className="p-4" scope="col">Command</th><th className="p-4" scope="col">Outcome</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line text-xs">
|
||||
{history.map((entry) => (
|
||||
<tr className="align-top hover:bg-canvas/60" key={entry.eventId}>
|
||||
<td className="whitespace-nowrap p-4 font-mono text-muted"><Link className="underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/events/${entry.eventId}`}><time dateTime={entry.time.toISOString()}>{entry.time.toISOString()}</time></Link></td>
|
||||
<td className="p-4"><span className="font-mono font-bold">{entry.serverName}</span><span className="mt-1 block font-mono text-[9px] text-muted">{entry.serverId}</span></td>
|
||||
<td className="p-4"><span className="font-bold">{entry.adminName ?? "Unknown administrator"}</span><span className="mt-1 block font-mono text-[10px] text-muted">{entry.adminEmail ?? "Email unavailable"}</span></td>
|
||||
<td className="max-w-xl p-4"><code className="whitespace-pre-wrap break-words font-mono text-xs"><span className="mr-2 text-accent">$</span>{entry.command}</code></td>
|
||||
<td className="p-4"><Outcome status={entry.status} />{entry.reason && <span className="mt-2 block font-mono text-[9px] text-muted">{entry.reason}</span>}{entry.durationMs !== null && <span className="mt-1 block font-mono text-[9px] text-muted">{entry.durationMs} ms</span>}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!history.length && <tr><td className="p-10 text-center text-muted" colSpan={5}>No RCON commands match these filters.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function Filter({ label, name, placeholder, value }: { label: string; name: string; placeholder: string; value: string }) {
|
||||
return <label className="font-mono text-[10px] font-bold uppercase tracking-wider" htmlFor={`history-${name}`}>{label}<input className="mt-2 block w-full border border-line bg-canvas px-3 py-3 font-sans text-sm font-normal normal-case outline-none placeholder:text-muted focus:border-accent" defaultValue={value} id={`history-${name}`} maxLength={name === "command" ? 1024 : 320} name={name} placeholder={placeholder} /></label>;
|
||||
}
|
||||
|
||||
function Outcome({ status }: { status: "pending" | "succeeded" | "failed" }) {
|
||||
const className = status === "succeeded" ? "border-signal text-ink" : status === "failed" ? "border-accent text-accent" : "border-line text-muted";
|
||||
return <span className={`inline-block border-l-2 pl-2 font-mono text-[9px] font-bold uppercase tracking-wider ${className}`}>{status}</span>;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { rconServers } from "@minecraft-account-manager/database";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { RconConsole, type RconTerminalNotice } from "@/components/rcon-console";
|
||||
import { db } from "@/lib/database";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const savedMessages: Record<string, string> = {
|
||||
created: "RCON connection created.",
|
||||
updated: "RCON connection updated.",
|
||||
enabled: "RCON connection enabled.",
|
||||
disabled: "RCON connection disabled.",
|
||||
deleted: "RCON connection deleted.",
|
||||
tested: "RCON authentication succeeded.",
|
||||
};
|
||||
|
||||
const errorMessages: Record<string, string> = {
|
||||
"invalid-connection": "Enter a valid DNS hostname, port, name, and password.",
|
||||
"duplicate-name": "Connection names must be unique.",
|
||||
configuration: "RCON credential encryption is not configured.",
|
||||
"save-failed": "The RCON connection could not be saved.",
|
||||
"unknown-connection": "That RCON connection no longer exists.",
|
||||
"confirmation-required": "Confirm the connection before deleting it.",
|
||||
"connection-unavailable": "The connection is invalid or its credential is unavailable.",
|
||||
"test-busy": "Another RCON operation is already using that server.",
|
||||
"test-timeout": "RCON authentication timed out.",
|
||||
"test-unavailable": "The RCON server was unavailable or rejected authentication.",
|
||||
};
|
||||
|
||||
function queryValue(value: string | string[] | undefined) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
export default async function RconPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const query = await searchParams;
|
||||
const saved = queryValue(query.saved);
|
||||
const error = queryValue(query.error);
|
||||
const notice: RconTerminalNotice | undefined = error
|
||||
? { status: "error", message: errorMessages[error] ?? "The RCON operation failed." }
|
||||
: saved
|
||||
? { status: "success", message: savedMessages[saved] ?? "RCON settings saved." }
|
||||
: undefined;
|
||||
const servers = await db.select({
|
||||
id: rconServers.id,
|
||||
name: rconServers.name,
|
||||
host: rconServers.host,
|
||||
port: rconServers.port,
|
||||
enabled: rconServers.enabled,
|
||||
}).from(rconServers).orderBy(asc(rconServers.name));
|
||||
|
||||
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">Server operations</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">RCON</h1>
|
||||
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Select and manage a connection, then run commands through the portal backend. Credentials are never sent to the browser.</p>
|
||||
</header>
|
||||
|
||||
<section className="mt-10">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Command proxy</p>
|
||||
<h2 className="mt-2 font-display text-3xl font-black uppercase">Terminal</h2>
|
||||
</div>
|
||||
<p className="max-w-xl text-xs leading-5 text-muted">Only the latest bounded response is shown. Commands and responses are not saved as console history.</p>
|
||||
</div>
|
||||
<RconConsole notice={notice} servers={servers} />
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { appSettings } from "@minecraft-account-manager/database";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { DEFAULT_ADMISSION_MESSAGES } from "@/lib/admission-settings";
|
||||
import { db } from "@/lib/database";
|
||||
import { saveAdmissionSettings } 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 messages = {
|
||||
registrationMessage: settings?.registrationMessage ?? DEFAULT_ADMISSION_MESSAGES.registrationMessage,
|
||||
groupAccessDeniedMessage: settings?.groupAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage,
|
||||
vpnDeniedMessage: settings?.vpnDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage,
|
||||
scheduledAccessDeniedMessage: settings?.scheduledAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.scheduledAccessDeniedMessage,
|
||||
};
|
||||
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={saveAdmissionSettings} className="border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)] sm:p-9">
|
||||
{query.saved && <p className="mb-6 border-l-2 border-signal pl-4 font-mono text-xs font-bold uppercase tracking-wider" role="status">Settings saved</p>}
|
||||
{query.error && <p className="mb-6 border-l-2 border-accent pl-4 text-sm" role="alert">Check the configuration value and try again.</p>}
|
||||
|
||||
<fieldset className="space-y-7">
|
||||
<legend className="font-display text-2xl font-black uppercase">Minecraft denial messages</legend>
|
||||
<p className="text-sm leading-6 text-muted">Each plain-text template is returned for one admission outcome. Messages must be between 10 and 500 characters. Registration, group, and network templates support <code>{"{player}"}</code> and <code>{"{group}"}</code>.</p>
|
||||
<AdmissionMessageField description="Shown when the Minecraft identity is not registered. The unresolved group is everyone." label="Registration required" name="registrationMessage" value={messages.registrationMessage} />
|
||||
<AdmissionMessageField description="Shown when the effective group has Minecraft access disabled." label="Group access disabled" name="groupAccessDeniedMessage" value={messages.groupAccessDeniedMessage} />
|
||||
<AdmissionMessageField description="Shown outside a scheduled access window. Also supports {next_start} and {next_end}; generated times explicitly use UTC." label="Scheduled access denied" name="scheduledAccessDeniedMessage" value={messages.scheduledAccessDeniedMessage} />
|
||||
<AdmissionMessageField description="Shown for VPN, proxy, or Tor connections when the effective group has no exception." label="VPN, proxy, or Tor denied" name="vpnDeniedMessage" value={messages.vpnDeniedMessage} />
|
||||
</fieldset>
|
||||
<button className="mt-8 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-[0.16em] text-canvas hover:bg-accent" type="submit">Save configuration</button>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function AdmissionMessageField({
|
||||
description,
|
||||
label,
|
||||
name,
|
||||
value,
|
||||
}: {
|
||||
description: string;
|
||||
label: string;
|
||||
name: "registrationMessage" | "groupAccessDeniedMessage" | "scheduledAccessDeniedMessage" | "vpnDeniedMessage";
|
||||
value: string;
|
||||
}) {
|
||||
const descriptionId = `${name}-description`;
|
||||
return (
|
||||
<div>
|
||||
<label className="block font-mono text-xs font-bold uppercase tracking-wider" htmlFor={name}>{label}</label>
|
||||
<p className="mt-2 text-xs leading-5 text-muted" id={descriptionId}>{description}</p>
|
||||
<textarea
|
||||
aria-describedby={descriptionId}
|
||||
className="mt-3 min-h-28 w-full resize-y border border-line bg-canvas px-4 py-3 text-sm leading-6 outline-none focus:border-accent"
|
||||
defaultValue={value}
|
||||
id={name}
|
||||
maxLength={500}
|
||||
minLength={10}
|
||||
name={name}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
|
||||
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||
import { events, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
|
||||
import { and, desc, eq, isNull, or } from "drizzle-orm";
|
||||
import { events, groups, ipIntelligence, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { accessAddressDetails, 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,
|
||||
@@ -31,37 +35,75 @@ 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] = 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),
|
||||
recentEventsQuery,
|
||||
db
|
||||
.select()
|
||||
.from(events)
|
||||
.where(or(eq(events.subject, `user/${user.id}`), eq(events.actorUserId, user.id)))
|
||||
.orderBy(desc(events.time))
|
||||
.limit(30),
|
||||
db
|
||||
.select()
|
||||
.select({
|
||||
id: ipObservations.id,
|
||||
ipAddress: ipObservations.ipAddress,
|
||||
source: ipObservations.source,
|
||||
classification: ipObservations.classification,
|
||||
observedAt: ipObservations.observedAt,
|
||||
intelligence: ipIntelligence.rawResponse,
|
||||
})
|
||||
.from(ipObservations)
|
||||
.leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||
.where(eq(ipObservations.userId, user.id))
|
||||
.orderBy(desc(ipObservations.observedAt))
|
||||
.limit(20),
|
||||
.limit(100),
|
||||
discordIdentity(user),
|
||||
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed, isDefault: groups.isDefault })
|
||||
.from(groups)
|
||||
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
|
||||
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
|
||||
.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);
|
||||
const primary = accounts.find((account) => account.isPrimary);
|
||||
const nickname = user.firstName
|
||||
? formatManagedDiscordNickname(user.firstName, primary?.username ?? null)
|
||||
@@ -74,7 +116,12 @@ export default async function AdminUserPage({
|
||||
<div>
|
||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">User record</p>
|
||||
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">{user.firstName ?? "Name needed"}</h1>
|
||||
<p className="mt-3 font-mono text-xs text-muted">@{user.discordUsername} · {user.discordUserId}</p>
|
||||
<dl className="mt-4 grid gap-x-8 gap-y-2 font-mono text-[10px] text-muted sm:grid-cols-2">
|
||||
<div><dt className="uppercase tracking-wider">Discord name</dt><dd className="mt-1 text-xs text-ink">{discord.globalName ?? discord.username}</dd></div>
|
||||
<div><dt className="uppercase tracking-wider">Discord username</dt><dd className="mt-1 text-xs text-ink">@{discord.username}</dd></div>
|
||||
<div><dt className="uppercase tracking-wider">Guild nickname</dt><dd className="mt-1 text-xs text-ink">{discord.nickname ?? "No guild nickname"}</dd></div>
|
||||
<div><dt className="uppercase tracking-wider">Discord ID</dt><dd className="mt-1 break-all text-xs text-ink">{discord.id}</dd></div>
|
||||
</dl>
|
||||
</div>
|
||||
<div className="border-l-2 border-accent pl-5">
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Expected Discord nickname</p>
|
||||
@@ -83,8 +130,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">
|
||||
@@ -120,10 +167,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 couldn’t 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 couldn’t 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>
|
||||
@@ -131,7 +178,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>
|
||||
)}
|
||||
@@ -139,11 +186,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>
|
||||
@@ -160,11 +212,30 @@ export default async function AdminUserPage({
|
||||
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider" type="submit">Save and synchronize</button>
|
||||
</form>
|
||||
|
||||
<section className="border border-line bg-panel p-6">
|
||||
<div className="flex items-center justify-between gap-3"><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Access groups</p><Link className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" href="/admin/groups">Manage</Link></div>
|
||||
{effectiveGroup ? <div className="mt-4 flex flex-wrap items-center justify-between gap-3"><Link className="font-mono text-xs font-bold underline decoration-line underline-offset-4" href={`/admin/groups/${effectiveGroup.id}`}>{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}</Link><div className="flex gap-2"><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${effectiveGroup.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{effectiveGroup.accessEnabled ? "Access on" : "Access off"}</span><span className="border border-line px-2 py-1 font-mono text-[9px] font-bold uppercase">VPN {effectiveGroup.anonymizedNetworksAllowed ? "allowed" : "denied"}</span></div></div> : <p className="mt-4 text-sm text-accent">No effective group configured.</p>}
|
||||
</section>
|
||||
|
||||
<section className="border border-line bg-panel p-6">
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Recent addresses</p>
|
||||
<p className="mt-3 text-[10px] leading-5 text-muted">Grouped by IPv4 /24 or IPv6 /64 network across the 100 most recent observations.</p>
|
||||
<div className="mt-4 divide-y divide-line">
|
||||
{observations.map((observation) => <div className="py-3" key={observation.id}><p className="font-mono text-xs">{observation.ipAddress}</p><p className="mt-1 font-mono text-[9px] text-muted">{observation.source} · {observation.observedAt.toISOString()}</p></div>)}
|
||||
{!observations.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
|
||||
{addressGroups.map((group) => {
|
||||
const details = accessAddressDetails(group);
|
||||
return (
|
||||
<div className="py-3" key={group.network}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="font-mono text-xs font-bold">{group.network}</p>
|
||||
<span className="font-mono text-[9px] text-muted">×{group.count}</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[9px] text-muted">{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}</p>
|
||||
<p className="mt-1 break-all font-mono text-[9px] text-muted">Latest {group.latestAddress}</p>
|
||||
<p className="mt-1 text-xs text-muted">{details.location} · <span className="font-mono uppercase">{details.classification}</span></p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!addressGroups.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
"use server";
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
formatManagedDiscordNickname,
|
||||
lookupJavaProfile,
|
||||
updateGuildNickname,
|
||||
} from "@minecraft-account-manager/minecraft";
|
||||
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
|
||||
import { and, eq, isNull, ne } from "drizzle-orm";
|
||||
import { events, groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import { and, eq, isNull, ne, sql } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { recordAdminEvent } from "@/lib/audit";
|
||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||
import { db } from "@/lib/database";
|
||||
import { adminGroupReturnPath } from "@/lib/group-management";
|
||||
|
||||
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
@@ -72,6 +76,67 @@ async function recordSyncFailure(
|
||||
);
|
||||
}
|
||||
|
||||
export async function assignUserGroupFromRegistry(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const userId = String(formData.get("userId") ?? "");
|
||||
const groupId = String(formData.get("groupId") ?? "");
|
||||
const returnTo = formData.get("returnTo");
|
||||
if (!UUID_PATTERN.test(userId) || !UUID_PATTERN.test(groupId)) redirect(adminGroupReturnPath(returnTo, "error=invalid-group-assignment"));
|
||||
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-membership'))`);
|
||||
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
|
||||
await tx.execute(sql`select ${users.id} from ${users} where ${users.id} = ${userId} for update`);
|
||||
const [[user], [targetGroup]] = await Promise.all([
|
||||
tx.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
|
||||
tx.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
|
||||
]);
|
||||
if (!user || !targetGroup) throw new Error("User or destination group no longer exists");
|
||||
const [membership] = await tx.select({ groupId: userGroupMemberships.groupId })
|
||||
.from(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id)).limit(1);
|
||||
if (membership && membership.groupId !== targetGroup.id) {
|
||||
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${membership.groupId} for update`);
|
||||
}
|
||||
const [previous] = await tx.select({ id: groups.id, name: groups.name })
|
||||
.from(userGroupMemberships)
|
||||
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
||||
.where(eq(userGroupMemberships.userId, user.id))
|
||||
.limit(1);
|
||||
if (targetGroup.isDefault) {
|
||||
await tx.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
|
||||
} else {
|
||||
await tx.insert(userGroupMemberships).values({ userId: user.id, groupId: targetGroup.id })
|
||||
.onConflictDoUpdate({
|
||||
target: userGroupMemberships.userId,
|
||||
set: { groupId: targetGroup.id },
|
||||
});
|
||||
}
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: "/web/admin",
|
||||
type: "games.minecraft.account-manager.group.assignment-updated",
|
||||
subject: `user/${user.id}`,
|
||||
time: new Date(),
|
||||
data: {
|
||||
groupId: targetGroup.id,
|
||||
groupName: targetGroup.name,
|
||||
previousGroupId: previous?.id ?? null,
|
||||
previousGroupName: previous?.name ?? "everyone",
|
||||
adminEmail: admin.email,
|
||||
adminName: admin.name,
|
||||
},
|
||||
ipAddress: ipAddress ?? null,
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
redirect(adminGroupReturnPath(returnTo, "error=invalid-group-assignment"));
|
||||
}
|
||||
redirect(adminGroupReturnPath(returnTo, "saved=group"));
|
||||
}
|
||||
|
||||
export async function updateUserName(formData: FormData) {
|
||||
const admin = await requireAdminSession();
|
||||
const userId = String(formData.get("userId") ?? "");
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
|
||||
import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||
import { and, asc, desc, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
||||
import { AdminUserTable } from "@/components/admin-user-table";
|
||||
import { db } from "@/lib/database";
|
||||
import { assignUserGroupFromRegistry } from "./actions";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminUsersPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<{ q?: string; error?: string }>;
|
||||
searchParams: Promise<{ q?: string; error?: string; saved?: string }>;
|
||||
}) {
|
||||
const query = await searchParams;
|
||||
const search = query.q?.trim().slice(0, 100) ?? "";
|
||||
@@ -15,6 +18,7 @@ export default async function AdminUsersPage({
|
||||
? or(
|
||||
ilike(users.firstName, pattern),
|
||||
ilike(users.discordUsername, pattern),
|
||||
ilike(users.discordGlobalName, pattern),
|
||||
eq(users.discordUserId, search),
|
||||
sql`exists (
|
||||
select 1 from ${minecraftAccounts}
|
||||
@@ -28,32 +32,41 @@ export default async function AdminUsersPage({
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const results = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
firstName: users.firstName,
|
||||
discordUsername: users.discordUsername,
|
||||
discordUserId: users.discordUserId,
|
||||
onboardingCompletedAt: users.onboardingCompletedAt,
|
||||
primaryUsername: minecraftAccounts.username,
|
||||
accountCount: sql<number>`(
|
||||
select count(*)::int from ${minecraftAccounts} account_count
|
||||
where account_count.user_id = ${users.id}
|
||||
and account_count.deleted_at is null
|
||||
)`,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(
|
||||
minecraftAccounts,
|
||||
and(
|
||||
eq(minecraftAccounts.userId, users.id),
|
||||
eq(minecraftAccounts.isPrimary, true),
|
||||
isNull(minecraftAccounts.deletedAt),
|
||||
),
|
||||
)
|
||||
.where(where)
|
||||
.orderBy(users.firstName, users.discordUsername)
|
||||
.limit(100);
|
||||
const [results, allGroups, memberships] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: users.id,
|
||||
firstName: users.firstName,
|
||||
discordUsername: users.discordUsername,
|
||||
discordGlobalName: users.discordGlobalName,
|
||||
discordUserId: users.discordUserId,
|
||||
onboardingCompletedAt: users.onboardingCompletedAt,
|
||||
primaryUsername: minecraftAccounts.username,
|
||||
accountCount: sql<number>`(
|
||||
select count(*)::int from ${minecraftAccounts} account_count
|
||||
where account_count.user_id = ${users.id}
|
||||
and account_count.deleted_at is null
|
||||
)`,
|
||||
})
|
||||
.from(users)
|
||||
.leftJoin(
|
||||
minecraftAccounts,
|
||||
and(
|
||||
eq(minecraftAccounts.userId, users.id),
|
||||
eq(minecraftAccounts.isPrimary, true),
|
||||
isNull(minecraftAccounts.deletedAt),
|
||||
),
|
||||
)
|
||||
.where(where)
|
||||
.orderBy(users.firstName, users.discordUsername)
|
||||
.limit(100),
|
||||
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault })
|
||||
.from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
|
||||
db.select({ userId: userGroupMemberships.userId, groupId: userGroupMemberships.groupId })
|
||||
.from(userGroupMemberships),
|
||||
]);
|
||||
const assignmentByUser = Object.fromEntries(memberships.map((membership) => [membership.userId, membership.groupId]));
|
||||
const returnTo = `/admin/users${search ? `?${new URLSearchParams({ q: search }).toString()}` : ""}`;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||
@@ -64,6 +77,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"
|
||||
@@ -74,26 +88,11 @@ export default async function AdminUsersPage({
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">The requested user could not be found.</p>}
|
||||
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{query.error === "invalid-group-assignment" ? "The user or group no longer exists. No group change was applied." : "The requested user could not be found."}</p>}
|
||||
{query.saved === "group" && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">User group updated.</p>}
|
||||
|
||||
<div className="mt-8 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<table className="w-full min-w-[760px] border-collapse text-left">
|
||||
<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>
|
||||
</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>
|
||||
<td className="p-4"><div className="font-mono text-xs">@{user.discordUsername}</div><div className="mt-1 font-mono text-[9px] text-muted">{user.discordUserId}</div></td>
|
||||
<td className="p-4 font-mono text-xs">{user.primaryUsername ?? "—"}</td>
|
||||
<td className="p-4 font-mono text-xs">{user.accountCount}</td>
|
||||
<td className="p-4"><span className={`border px-2 py-1 font-mono text-[9px] uppercase tracking-wider ${user.onboardingCompletedAt ? "border-line text-muted" : "border-accent text-accent"}`}>{user.onboardingCompletedAt ? "Ready" : "Onboarding"}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
{!results.length && <tr><td className="p-8 text-muted" colSpan={5}>No users match that search.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-8">
|
||||
<AdminUserTable action={assignUserGroupFromRegistry} assignmentByUser={assignmentByUser} emptyMessage="No users match that search." groups={allGroups} returnTo={returnTo} users={results} />
|
||||
</div>
|
||||
<p className="mt-4 font-mono text-[9px] uppercase tracking-widest text-muted">Showing up to 100 users</p>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import { hashToken } from "@minecraft-account-manager/auth";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const databaseState = vi.hoisted(() => ({
|
||||
responses: [] as unknown[][],
|
||||
inserted: [] as Array<Record<string, unknown>>,
|
||||
isolationLevel: "",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/database", () => {
|
||||
function selection(response: unknown[]) {
|
||||
const chain: Record<string, unknown> = {};
|
||||
for (const method of ["from", "where", "innerJoin", "leftJoin", "orderBy"]) {
|
||||
chain[method] = () => chain;
|
||||
}
|
||||
chain.limit = () => Promise.resolve(response);
|
||||
chain.then = (resolve: (value: unknown[]) => unknown, reject: (reason: unknown) => unknown) =>
|
||||
Promise.resolve(response).then(resolve, reject);
|
||||
return chain;
|
||||
}
|
||||
const tx = {
|
||||
select: () => selection(databaseState.responses.shift() ?? []),
|
||||
insert: () => ({
|
||||
values: (value: Record<string, unknown>) => {
|
||||
databaseState.inserted.push(value);
|
||||
return Promise.resolve();
|
||||
},
|
||||
}),
|
||||
delete: () => ({ where: () => Promise.resolve() }),
|
||||
update: () => ({ set: () => ({ where: () => Promise.resolve() }) }),
|
||||
};
|
||||
return {
|
||||
db: {
|
||||
select: () => selection(databaseState.responses.shift() ?? []),
|
||||
transaction: async (callback: (transaction: typeof tx) => Promise<unknown>, options: { isolationLevel?: string }) => {
|
||||
databaseState.isolationLevel = options?.isolationLevel ?? "";
|
||||
return callback(tx);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/ip-intelligence", () => ({
|
||||
getIpIntelligence: async () => ({ classification: "clear" }),
|
||||
toAuditIpData: () => ({ classification: "clear" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
const messages = {
|
||||
registrationMessage: "Register {player} in {group}.",
|
||||
groupAccessDeniedMessage: "Disabled {player} in {group}.",
|
||||
vpnDeniedMessage: "Network denied for {player} in {group}.",
|
||||
scheduledAccessDeniedMessage: "Scheduled {player} in {group}: {next_start} / {next_end}.",
|
||||
};
|
||||
|
||||
function utcMinuteOfWeek(value: Date) {
|
||||
return ((value.getUTCDay() + 6) % 7) * 1440 + value.getUTCHours() * 60 + value.getUTCMinutes();
|
||||
}
|
||||
|
||||
function normalized(value: number) {
|
||||
return (value + 10080) % 10080;
|
||||
}
|
||||
|
||||
function request() {
|
||||
return new Request("http://localhost/api/velocity/access", {
|
||||
method: "POST",
|
||||
headers: { authorization: "Bearer route-secret", "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
requestId: "11111111-1111-4111-8111-111111111111",
|
||||
serverId: "velocity-main",
|
||||
minecraftUuid: "0123456789abcdef0123456789abcdef",
|
||||
username: "AlexMC",
|
||||
ipAddress: "203.0.113.10",
|
||||
occurredAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function arrange(group: { accessEnabled: boolean; anonymizedNetworksAllowed: boolean }, windows: Array<{ startMinuteOfWeek: number; endMinuteOfWeek: number }>) {
|
||||
databaseState.responses = [
|
||||
[{ secretHash: hashToken("route-secret") }],
|
||||
[messages],
|
||||
[{ id: "account-id", userId: "user-id", minecraftUuid: "0123456789abcdef0123456789abcdef", username: "AlexMC" }],
|
||||
[{ id: "group-id", name: "Friday friends", ...group }],
|
||||
[{ id: "everyone-id", name: "everyone", accessEnabled: false, anonymizedNetworksAllowed: false }],
|
||||
windows,
|
||||
];
|
||||
}
|
||||
|
||||
describe("Velocity scheduled admission integration", () => {
|
||||
beforeEach(() => {
|
||||
databaseState.responses = [];
|
||||
databaseState.inserted = [];
|
||||
databaseState.isolationLevel = "";
|
||||
});
|
||||
|
||||
it("loads effective-group windows and returns a rendered schedule denial", async () => {
|
||||
const minute = utcMinuteOfWeek(new Date());
|
||||
arrange(
|
||||
{ accessEnabled: true, anonymizedNetworksAllowed: false },
|
||||
[{ startMinuteOfWeek: normalized(minute + 60), endMinuteOfWeek: normalized(minute + 120) }],
|
||||
);
|
||||
|
||||
const response = await POST(request());
|
||||
const body = await response.json();
|
||||
expect(body.allowed).toBe(false);
|
||||
expect(body.message).toMatch(/^Scheduled AlexMC in Friday friends: .* UTC \/ .* UTC\.$/);
|
||||
expect(databaseState.isolationLevel).toBe("repeatable read");
|
||||
expect(databaseState.inserted).toContainEqual(expect.objectContaining({
|
||||
type: "games.minecraft.account-manager.game.login.denied",
|
||||
data: expect.objectContaining({ reason: "schedule_disallowed", accessGroup: "Friday friends" }),
|
||||
}));
|
||||
});
|
||||
|
||||
it("fails closed for malformed persisted windows while disabled access retains precedence", async () => {
|
||||
const malformed = [
|
||||
{ startMinuteOfWeek: 100, endMinuteOfWeek: 200 },
|
||||
{ startMinuteOfWeek: 150, endMinuteOfWeek: 250 },
|
||||
];
|
||||
arrange({ accessEnabled: true, anonymizedNetworksAllowed: true }, malformed);
|
||||
expect(await (await POST(request())).json()).toMatchObject({ allowed: false, message: expect.stringContaining("unavailable") });
|
||||
expect(databaseState.inserted).toContainEqual(expect.objectContaining({
|
||||
data: expect.objectContaining({ reason: "schedule_disallowed" }),
|
||||
}));
|
||||
|
||||
databaseState.inserted = [];
|
||||
arrange({ accessEnabled: false, anonymizedNetworksAllowed: true }, malformed);
|
||||
expect(await (await POST(request())).json()).toEqual({ allowed: false, message: "Disabled AlexMC in Friday friends." });
|
||||
expect(databaseState.inserted).toContainEqual(expect.objectContaining({
|
||||
data: expect.objectContaining({ reason: "group_access_disabled" }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -1,23 +1,28 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { 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,
|
||||
events,
|
||||
groupAccessWindows,
|
||||
groups,
|
||||
ipObservations,
|
||||
minecraftAccounts,
|
||||
pluginCredentials,
|
||||
pluginRequests,
|
||||
userGroupMemberships,
|
||||
} from "@minecraft-account-manager/database";
|
||||
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES, renderAdmissionMessage } from "@/lib/admission-settings";
|
||||
import { db } from "@/lib/database";
|
||||
import { isUniqueConstraintViolation } from "@/lib/database-errors";
|
||||
import { evaluateRegisteredPlayerAdmission } from "@/lib/game-admission-policy";
|
||||
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
||||
|
||||
const MAX_CLOCK_SKEW_MS = 45_000;
|
||||
const DEFAULT_DENIAL_MESSAGE = "Please register your Minecraft account before joining.";
|
||||
|
||||
function methodNotAllowed(request: Request) {
|
||||
const response = problemResponse(problemDetails(
|
||||
@@ -108,36 +113,16 @@ async function handleVelocityAccess(request: Request) {
|
||||
}
|
||||
|
||||
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
||||
const denialMessage = settings?.registrationMessage ?? DEFAULT_DENIAL_MESSAGE;
|
||||
const admissionMessages = {
|
||||
registrationMessage: settings?.registrationMessage ?? DEFAULT_ADMISSION_MESSAGES.registrationMessage,
|
||||
groupAccessDeniedMessage: settings?.groupAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage,
|
||||
vpnDeniedMessage: settings?.vpnDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage,
|
||||
scheduledAccessDeniedMessage: settings?.scheduledAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.scheduledAccessDeniedMessage,
|
||||
};
|
||||
|
||||
let [knownAccount] = await db
|
||||
.select({ id: minecraftAccounts.id })
|
||||
.from(minecraftAccounts)
|
||||
.where(
|
||||
and(
|
||||
eq(minecraftAccounts.minecraftUuid, input.minecraftUuid),
|
||||
isNull(minecraftAccounts.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!knownAccount) {
|
||||
[knownAccount] = await db
|
||||
.select({ id: minecraftAccounts.id })
|
||||
.from(minecraftAccounts)
|
||||
.where(
|
||||
and(
|
||||
isNull(minecraftAccounts.minecraftUuid),
|
||||
sql`lower(${minecraftAccounts.username}) = lower(${input.username})`,
|
||||
isNull(minecraftAccounts.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
}
|
||||
|
||||
const intelligence = knownAccount
|
||||
? await getIpIntelligence(input.ipAddress)
|
||||
: { classification: "unknown" as const, provider: null };
|
||||
const intelligence = await getIpIntelligence(input.ipAddress);
|
||||
const auditIpData = toAuditIpData(intelligence);
|
||||
const decisionAt = new Date();
|
||||
|
||||
const decision = await db.transaction(async (tx) => {
|
||||
await tx.delete(pluginRequests).where(lt(pluginRequests.expiresAt, new Date()));
|
||||
@@ -206,9 +191,82 @@ async function handleVelocityAccess(request: Request) {
|
||||
classification: intelligence.classification,
|
||||
observedAt: occurredAt,
|
||||
});
|
||||
return { allowed: false as const, message: denialMessage };
|
||||
return {
|
||||
allowed: false as const,
|
||||
message: renderAdmissionMessage(admissionDenialMessage("not_registered", admissionMessages), {
|
||||
player: input.username,
|
||||
group: "everyone",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const [explicitGroup] = await tx
|
||||
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed })
|
||||
.from(userGroupMemberships)
|
||||
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
||||
.where(eq(userGroupMemberships.userId, account.userId))
|
||||
.limit(1);
|
||||
const [defaultGroup] = await tx
|
||||
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed })
|
||||
.from(groups)
|
||||
.where(eq(groups.isDefault, true))
|
||||
.limit(1);
|
||||
const effectiveGroup = resolveEffectiveGroup(explicitGroup ?? null, defaultGroup ?? null);
|
||||
const accessWindows = effectiveGroup
|
||||
? await tx.select({
|
||||
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
|
||||
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
|
||||
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, effectiveGroup.id))
|
||||
: [];
|
||||
const policyDecision = evaluateRegisteredPlayerAdmission({
|
||||
group: effectiveGroup ?? null,
|
||||
windows: accessWindows,
|
||||
classification: intelligence.classification,
|
||||
now: decisionAt,
|
||||
player: input.username,
|
||||
messages: admissionMessages,
|
||||
});
|
||||
if (!policyDecision.allowed) {
|
||||
const denialReason = policyDecision.reason;
|
||||
await tx.insert(events).values({
|
||||
id: randomUUID(),
|
||||
source: `/velocity/${input.serverId}`,
|
||||
type: "games.minecraft.account-manager.game.login.denied",
|
||||
subject: `minecraft-account/${account.id}`,
|
||||
time: occurredAt,
|
||||
actorUserId: account.userId,
|
||||
data: {
|
||||
username: input.username,
|
||||
reason: denialReason,
|
||||
accessGroup: effectiveGroup?.name ?? null,
|
||||
accessGroupId: effectiveGroup?.id ?? null,
|
||||
nextScheduleWindow: policyDecision.nextWindow ? {
|
||||
start: policyDecision.nextWindow.start.toISOString(),
|
||||
end: policyDecision.nextWindow.end.toISOString(),
|
||||
} : null,
|
||||
ipIntelligence: auditIpData,
|
||||
},
|
||||
ipAddress: input.ipAddress,
|
||||
correlationId: input.requestId,
|
||||
});
|
||||
await tx.insert(ipObservations).values({
|
||||
userId: account.userId,
|
||||
minecraftAccountId: account.id,
|
||||
source: "game",
|
||||
ipAddress: input.ipAddress,
|
||||
minecraftUuid: input.minecraftUuid,
|
||||
username: input.username,
|
||||
classification: intelligence.classification,
|
||||
observedAt: occurredAt,
|
||||
});
|
||||
return {
|
||||
allowed: false as const,
|
||||
message: policyDecision.message,
|
||||
};
|
||||
}
|
||||
|
||||
if (!effectiveGroup) throw new Error("Effective access group is unavailable after admission approval");
|
||||
|
||||
if (account.minecraftUuid !== input.minecraftUuid || account.username !== input.username) {
|
||||
await tx
|
||||
.update(minecraftAccounts)
|
||||
@@ -258,13 +316,15 @@ async function handleVelocityAccess(request: Request) {
|
||||
previousUsername: account.username === input.username ? null : account.username,
|
||||
uuidBackfilled: account.minecraftUuid === null,
|
||||
ipIntelligence: auditIpData,
|
||||
accessGroup: effectiveGroup.name,
|
||||
accessGroupId: effectiveGroup.id,
|
||||
},
|
||||
ipAddress: input.ipAddress,
|
||||
correlationId: input.requestId,
|
||||
});
|
||||
|
||||
return { allowed: true as const, message: "Account approved." };
|
||||
});
|
||||
}, { isolationLevel: "repeatable read" });
|
||||
|
||||
return NextResponse.json(decision);
|
||||
}
|
||||
@@ -284,7 +344,10 @@ export async function POST(request: Request) {
|
||||
));
|
||||
}
|
||||
|
||||
console.error("Velocity access request failed");
|
||||
logger.error(
|
||||
{ err: error, event: "velocity.access_failed", instance },
|
||||
"Velocity access request failed",
|
||||
);
|
||||
return problemResponse(problemDetails(
|
||||
"urn:error:service-unavailable",
|
||||
"Service unavailable",
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { createAuthRepository, ipObservations, recordEvent } from "@minecraft-ac
|
||||
import { getClientIp } from "@minecraft-account-manager/network";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { applicationUrl } from "@/lib/application-url";
|
||||
import { db } from "@/lib/database";
|
||||
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
||||
|
||||
@@ -38,7 +39,7 @@ export async function GET(request: NextRequest) {
|
||||
]);
|
||||
|
||||
const destination = result.user.firstName ? "/account" : "/welcome";
|
||||
const response = NextResponse.redirect(new URL(destination, request.url));
|
||||
const response = NextResponse.redirect(applicationUrl(destination));
|
||||
response.cookies.set(SESSION_COOKIE_NAME, result.sessionToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
@@ -49,7 +50,7 @@ export async function GET(request: NextRequest) {
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidLoginCodeError) {
|
||||
return NextResponse.redirect(new URL("/auth/error", request.url));
|
||||
return NextResponse.redirect(applicationUrl("/auth/error"));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -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,55 @@ 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;
|
||||
}
|
||||
|
||||
.map-user-cluster {
|
||||
display: grid !important;
|
||||
place-items: center;
|
||||
border: 3px solid var(--panel);
|
||||
border-radius: 999px;
|
||||
background: var(--accent);
|
||||
color: var(--panel);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 0 0 1px var(--ink);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
color: var(--panel);
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
describe("health endpoint", () => {
|
||||
it("reports process readiness without requiring external services", async () => {
|
||||
afterEach(() => vi.unstubAllEnvs());
|
||||
|
||||
it("reports process readiness and the immutable build version without requiring external services", async () => {
|
||||
vi.stubEnv("APP_VERSION", "1.19.0");
|
||||
|
||||
const response = GET();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(await response.json()).toEqual({ status: "ok" });
|
||||
expect(await response.json()).toEqual({ status: "ok", version: "1.19.0" });
|
||||
});
|
||||
|
||||
it("reports a development version when no build version is supplied", async () => {
|
||||
vi.stubEnv("APP_VERSION", "");
|
||||
|
||||
expect(await GET().json()).toEqual({ status: "ok", version: "development" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export function GET(): Response {
|
||||
return Response.json(
|
||||
{ status: "ok" },
|
||||
{ status: "ok", version: process.env.APP_VERSION?.trim() || "development" },
|
||||
{
|
||||
headers: {
|
||||
"Cache-Control": "no-store",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="8" fill="#171916"/>
|
||||
<path d="M9 9h21v21H9z" fill="#bc3f24"/>
|
||||
<path d="M34 9h21v21H34zM9 34h21v21H9z" fill="#eee8d8"/>
|
||||
<path d="M34 34h21v21H34z" fill="#b5d452"/>
|
||||
<path d="M21 18h13v8H26v4h8v8H21z" fill="#171916"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 325 B |
@@ -1,16 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import { SiteFooter } from "@/components/site-footer";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Blocklist — Minecraft Account Manager",
|
||||
title: "SoMC Portal — Minecraft Account Manager",
|
||||
description: "Connect your Discord identity to approved Minecraft accounts.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
<body className="flex min-h-screen flex-col">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@ export default async function HomePage({
|
||||
<div className="terrain" aria-hidden="true" />
|
||||
<div className="relative mx-auto flex min-h-screen max-w-7xl flex-col px-6 pb-10 pt-7 sm:px-10 lg:px-16">
|
||||
<header className="flex items-center justify-between border-b border-line pb-5">
|
||||
<a className="flex items-center gap-3" href="#top" aria-label="Blocklist home">
|
||||
<a className="flex items-center gap-3" href="#top" aria-label="SoMC Portal home">
|
||||
<span className="grid size-9 place-items-center border border-accent bg-accent text-sm font-black text-canvas shadow-[4px_4px_0_var(--color-shadow)]">
|
||||
B
|
||||
</span>
|
||||
<span className="font-display text-sm font-bold uppercase tracking-[0.22em]">Blocklist</span>
|
||||
<span className="font-display text-sm font-bold uppercase tracking-[0.22em]">SoMC Portal</span>
|
||||
</a>
|
||||
<span className="hidden items-center gap-2 font-mono text-xs uppercase tracking-widest text-muted sm:flex">
|
||||
<span className="size-2 bg-signal shadow-[0_0_12px_var(--color-signal)]" />
|
||||
@@ -84,10 +84,6 @@ export default async function HomePage({
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<footer className="flex flex-col gap-3 border-t border-line pt-5 font-mono text-[10px] uppercase tracking-[0.18em] text-muted sm:flex-row sm:items-center sm:justify-between">
|
||||
<span>Java Edition only</span>
|
||||
<span>Unknown players are denied by default</span>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AdminModalForm } from "./admin-modal-form";
|
||||
|
||||
beforeEach(() => {
|
||||
HTMLDialogElement.prototype.showModal = function showModal() { this.open = true; };
|
||||
HTMLDialogElement.prototype.close = function close() {
|
||||
this.open = false;
|
||||
this.dispatchEvent(new Event("close"));
|
||||
};
|
||||
});
|
||||
|
||||
describe("AdminModalForm", () => {
|
||||
it("renders an accessible trigger, labelled dialog, cancellation, and pending-capable submit control", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AdminModalForm
|
||||
action={async () => undefined}
|
||||
description="Review this policy change before applying it."
|
||||
submitLabel="Apply policy"
|
||||
title="Change access policy"
|
||||
triggerLabel="Change"
|
||||
>
|
||||
<input name="groupId" type="hidden" value="group-one" />
|
||||
</AdminModalForm>,
|
||||
);
|
||||
expect(markup).toContain("Change access policy");
|
||||
expect(markup).toContain("Review this policy change before applying it.");
|
||||
expect(markup).toContain("<dialog");
|
||||
expect(markup).toContain("aria-haspopup=\"dialog\"");
|
||||
expect(markup).toContain("Cancel");
|
||||
expect(markup).toContain("Apply policy");
|
||||
});
|
||||
|
||||
it("opens, cancels, and prevents dismissal while the action is pending", async () => {
|
||||
let finishAction!: () => void;
|
||||
const action = vi.fn(() => new Promise<void>((resolve) => { finishAction = resolve; }));
|
||||
render(<AdminModalForm action={action} description="Confirm it." submitLabel="Apply policy" title="Change access policy" triggerLabel="Change" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Change" }));
|
||||
const dialog = screen.getByRole("dialog") as HTMLDialogElement;
|
||||
expect(dialog.open).toBe(true);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apply policy" }));
|
||||
await waitFor(() => expect(action).toHaveBeenCalledOnce());
|
||||
expect((screen.getByRole("button", { name: "Change" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
const cancelEvent = new Event("cancel", { bubbles: false, cancelable: true });
|
||||
dialog.dispatchEvent(cancelEvent);
|
||||
expect(cancelEvent.defaultPrevented).toBe(true);
|
||||
expect(dialog.open).toBe(true);
|
||||
|
||||
finishAction();
|
||||
await waitFor(() => expect((screen.getByRole("button", { name: "Change" }) as HTMLButtonElement).disabled).toBe(false));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(dialog.open).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode, RefObject } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
export function AdminModalForm({
|
||||
action,
|
||||
children,
|
||||
description,
|
||||
intent = "default",
|
||||
submitLabel,
|
||||
title,
|
||||
triggerClassName,
|
||||
triggerLabel,
|
||||
triggerPressed,
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
children?: ReactNode;
|
||||
description: string;
|
||||
intent?: "default" | "danger";
|
||||
submitLabel: string;
|
||||
title: string;
|
||||
triggerClassName?: string;
|
||||
triggerLabel: string;
|
||||
triggerPressed?: boolean;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [dialogGeneration, setDialogGeneration] = useState(0);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
aria-haspopup="dialog"
|
||||
aria-pressed={triggerPressed}
|
||||
className={triggerClassName ?? "font-mono text-[10px] font-bold uppercase underline underline-offset-4"}
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setDialogGeneration((generation) => generation + 1);
|
||||
dialogRef.current?.showModal();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{triggerLabel}
|
||||
</button>
|
||||
<dialog
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
className="admin-modal m-auto max-h-[90vh] w-[min(92vw,36rem)] overflow-y-auto border border-ink bg-panel p-0 text-ink shadow-[10px_10px_0_var(--color-shadow)] backdrop:bg-ink/70"
|
||||
onCancel={(event) => { if (submitting) event.preventDefault(); }}
|
||||
ref={dialogRef}
|
||||
>
|
||||
<form action={action} className="p-6 sm:p-8" onSubmit={() => setSubmitting(true)}>
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-[0.2em] text-accent">Confirm operation</p>
|
||||
<h2 className="mt-3 font-display text-3xl font-black uppercase" id={titleId}>{title}</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-muted" id={descriptionId}>{description}</p>
|
||||
{children && <div className="mt-6" key={dialogGeneration}>{children}</div>}
|
||||
<ModalActions dialogRef={dialogRef} intent={intent} onPendingChange={setSubmitting} submitLabel={submitLabel} />
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ModalActions({
|
||||
dialogRef,
|
||||
intent,
|
||||
onPendingChange,
|
||||
submitLabel,
|
||||
}: {
|
||||
dialogRef: RefObject<HTMLDialogElement | null>;
|
||||
intent: "default" | "danger";
|
||||
onPendingChange: (pending: boolean) => void;
|
||||
submitLabel: string;
|
||||
}) {
|
||||
const { pending } = useFormStatus();
|
||||
const observedPending = useRef(false);
|
||||
useEffect(() => {
|
||||
if (pending) {
|
||||
observedPending.current = true;
|
||||
onPendingChange(true);
|
||||
} else if (observedPending.current) {
|
||||
observedPending.current = false;
|
||||
onPendingChange(false);
|
||||
}
|
||||
}, [onPendingChange, pending]);
|
||||
return (
|
||||
<div className="mt-8 flex flex-wrap justify-end gap-3 border-t border-line pt-5">
|
||||
<button
|
||||
className="border border-line px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider disabled:opacity-50"
|
||||
disabled={pending}
|
||||
onClick={() => dialogRef.current?.close()}
|
||||
type="button"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className={`px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas disabled:cursor-wait disabled:opacity-60 ${intent === "danger" ? "bg-accent" : "bg-ink"}`}
|
||||
disabled={pending}
|
||||
type="submit"
|
||||
>
|
||||
{pending ? "Applying…" : submitLabel}
|
||||
</button>
|
||||
<span aria-live="polite" className="sr-only">{pending ? "Operation in progress." : ""}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AdminUserTable } from "./admin-user-table";
|
||||
|
||||
describe("AdminUserTable", () => {
|
||||
it("renders reusable identity and confirmed group controls", () => {
|
||||
const markup = renderToStaticMarkup(<AdminUserTable
|
||||
action={async () => undefined}
|
||||
assignmentByUser={{ user1: "ops" }}
|
||||
emptyMessage="No members."
|
||||
groups={[{ id: "everyone", name: "everyone", isDefault: true }, { id: "ops", name: "Ops", isDefault: false }]}
|
||||
returnTo="/admin/groups/11111111-1111-4111-8111-111111111111"
|
||||
users={[{
|
||||
id: "user1",
|
||||
firstName: "Alex",
|
||||
discordUsername: "alex",
|
||||
discordGlobalName: "Alex Global",
|
||||
discordUserId: "123",
|
||||
onboardingCompletedAt: new Date("2026-08-01T00:00:00Z"),
|
||||
primaryUsername: "AlexMC",
|
||||
accountCount: 2,
|
||||
}]}
|
||||
/>);
|
||||
expect(markup).toContain("Alex Global");
|
||||
expect(markup).toContain("AlexMC");
|
||||
expect(markup).toContain("Accounts");
|
||||
expect(markup).toContain("Group for Alex");
|
||||
expect(markup).toContain("Confirm move");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import Link from "next/link";
|
||||
import { UserGroupSelect } from "./user-group-select";
|
||||
|
||||
export interface AdminUserRow {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
discordUsername: string;
|
||||
discordGlobalName: string | null;
|
||||
discordUserId: string;
|
||||
onboardingCompletedAt: Date | null;
|
||||
primaryUsername: string | null;
|
||||
accountCount: number;
|
||||
}
|
||||
|
||||
export function AdminUserTable({
|
||||
action,
|
||||
assignmentByUser,
|
||||
emptyMessage,
|
||||
groups,
|
||||
returnTo,
|
||||
users,
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
assignmentByUser: Record<string, string>;
|
||||
emptyMessage: string;
|
||||
groups: Array<{ id: string; name: string; isDefault: boolean }>;
|
||||
returnTo: string;
|
||||
users: AdminUserRow[];
|
||||
}) {
|
||||
const defaultGroup = groups.find((group) => group.isDefault);
|
||||
return (
|
||||
<div className="overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<table className="w-full min-w-[900px] border-collapse text-left">
|
||||
<caption className="sr-only">Registered portal users and effective groups</caption>
|
||||
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
|
||||
<tr><th className="p-4" scope="col">User</th><th className="p-4" scope="col">Discord</th><th className="p-4" scope="col">Primary</th><th className="p-4" scope="col">Accounts</th><th className="p-4" scope="col">Group</th><th className="p-4" scope="col">Status</th></tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{users.map((user) => (
|
||||
<tr className="transition-colors hover:bg-canvas/60" key={user.id}>
|
||||
<th className="p-4 text-left" scope="row"><Link className="font-display text-lg font-black underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? "Name needed"}</Link></th>
|
||||
<td className="p-4"><div className="font-mono text-xs font-bold">{user.discordGlobalName ?? user.discordUsername}</div><div className="mt-1 font-mono text-[10px] text-muted">@{user.discordUsername}</div><div className="mt-1 font-mono text-[9px] text-muted">{user.discordUserId}</div></td>
|
||||
<td className="p-4 font-mono text-xs">{user.primaryUsername ?? "—"}</td>
|
||||
<td className="p-4 font-mono text-xs">{user.accountCount}</td>
|
||||
<td className="p-4">{defaultGroup ? <UserGroupSelect action={action} effectiveGroupId={assignmentByUser[user.id] ?? defaultGroup.id} groups={groups} returnTo={returnTo} userId={user.id} userLabel={user.firstName ?? user.discordUsername} /> : <span className="text-xs text-accent">Default group missing</span>}</td>
|
||||
<td className="p-4"><span className={`border px-2 py-1 font-mono text-[9px] uppercase tracking-wider ${user.onboardingCompletedAt ? "border-line text-muted" : "border-accent text-accent"}`}>{user.onboardingCompletedAt ? "Ready" : "Onboarding"}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
{!users.length && <tr><td className="p-8 text-muted" colSpan={6}>{emptyMessage}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { AdminModalForm } from "./admin-modal-form";
|
||||
|
||||
export function GroupPolicyControl({
|
||||
action,
|
||||
enabled,
|
||||
groupId,
|
||||
groupName,
|
||||
memberCount,
|
||||
policy,
|
||||
returnLocation,
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
enabled: boolean;
|
||||
groupId: string;
|
||||
groupName: string;
|
||||
memberCount: number;
|
||||
policy: string;
|
||||
returnLocation: "list" | "detail";
|
||||
}) {
|
||||
const nextState = enabled ? "deny" : "allow";
|
||||
return (
|
||||
<AdminModalForm
|
||||
action={action}
|
||||
description={`${nextState === "allow" ? "Allow" : "Deny"} ${policy.toLowerCase()} for ${memberCount} effective ${memberCount === 1 ? "member" : "members"} of ${groupName}.`}
|
||||
submitLabel={`${nextState === "allow" ? "Allow" : "Deny"} access`}
|
||||
title={`${nextState === "allow" ? "Allow" : "Deny"} ${policy}?`}
|
||||
triggerClassName={`min-w-24 border px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider ${enabled ? "border-signal bg-signal text-ink" : "border-accent bg-transparent text-accent"}`}
|
||||
triggerLabel={enabled ? "Allowed" : "Denied"}
|
||||
triggerPressed={enabled}
|
||||
>
|
||||
<input name="groupId" type="hidden" value={groupId} />
|
||||
<input name="enabled" type="hidden" value={enabled ? "no" : "yes"} />
|
||||
<input name="returnLocation" type="hidden" value={returnLocation} />
|
||||
</AdminModalForm>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { AdminModalForm } from "./admin-modal-form";
|
||||
import { GroupScheduleEditor, GroupScheduleSummary } from "./group-schedule-editor";
|
||||
|
||||
beforeEach(() => {
|
||||
HTMLDialogElement.prototype.showModal = function showModal() { this.open = true; };
|
||||
HTMLDialogElement.prototype.close = function close() {
|
||||
this.open = false;
|
||||
this.dispatchEvent(new Event("close"));
|
||||
};
|
||||
});
|
||||
|
||||
describe("GroupScheduleEditor", () => {
|
||||
it("shows UTC authority, browser-local equivalents, and repeatable windows", () => {
|
||||
const { container } = render(<GroupScheduleEditor windows={[{
|
||||
startMinuteOfWeek: 6960,
|
||||
endMinuteOfWeek: 7199,
|
||||
}]} />);
|
||||
|
||||
expect(screen.getByText(/stored and enforced in UTC/i)).toBeTruthy();
|
||||
expect(screen.getAllByRole("group", { name: /access window/i })).toHaveLength(1);
|
||||
expect(container.querySelectorAll('input[name="startMinuteOfWeek"]')).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /add window/i }));
|
||||
expect(screen.getAllByRole("group", { name: /access window/i })).toHaveLength(2);
|
||||
expect(container.querySelectorAll('input[name="startMinuteOfWeek"]')).toHaveLength(2);
|
||||
|
||||
fireEvent.click(screen.getAllByRole("button", { name: /remove window/i })[0]!);
|
||||
expect(screen.getAllByRole("group", { name: /access window/i })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("discards an abandoned draft when its confirmation dialog is reopened", () => {
|
||||
render(<AdminModalForm action={async () => undefined} description="Confirm schedule." submitLabel="Save schedule" title="Schedule group" triggerLabel="Edit schedule"><GroupScheduleEditor windows={[{ startMinuteOfWeek: 6960, endMinuteOfWeek: 7200 }]} /></AdminModalForm>);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit schedule" }));
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Add window" }));
|
||||
expect(within(dialog).getAllByRole("group", { name: /access window/i })).toHaveLength(2);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Edit schedule" }));
|
||||
expect(within(dialog).getAllByRole("group", { name: /access window/i })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("summarizes an unrestricted group and configured local equivalents", () => {
|
||||
const { container, rerender } = render(<GroupScheduleSummary windows={[]} />);
|
||||
expect(container.textContent).toMatch(/no schedule restrictions/i);
|
||||
|
||||
rerender(<GroupScheduleSummary windows={[{ startMinuteOfWeek: 6960, endMinuteOfWeek: 7199 }]} />);
|
||||
expect(container.textContent).toMatch(/current browser-local equivalent/i);
|
||||
expect(container.textContent).toContain("Friday 20:00 UTC");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useSyncExternalStore } from "react";
|
||||
import {
|
||||
formatWeeklyMinute,
|
||||
localWindowToUtc,
|
||||
utcWindowToLocal,
|
||||
type WeeklyAccessWindow,
|
||||
} from "@/lib/group-schedule";
|
||||
|
||||
const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const;
|
||||
|
||||
interface EditableWindow extends WeeklyAccessWindow {
|
||||
key: number;
|
||||
}
|
||||
|
||||
function minuteParts(minuteOfWeek: number) {
|
||||
const day = Math.floor(minuteOfWeek / 1440);
|
||||
const minute = minuteOfWeek % 1440;
|
||||
return {
|
||||
day,
|
||||
time: `${String(Math.floor(minute / 60)).padStart(2, "0")}:${String(minute % 60).padStart(2, "0")}`,
|
||||
};
|
||||
}
|
||||
|
||||
function withDay(minuteOfWeek: number, day: number) {
|
||||
return day * 1440 + (minuteOfWeek % 1440);
|
||||
}
|
||||
|
||||
function withTime(minuteOfWeek: number, time: string) {
|
||||
const [hour, minute] = time.split(":").map(Number);
|
||||
return Math.floor(minuteOfWeek / 1440) * 1440 + (hour ?? 0) * 60 + (minute ?? 0);
|
||||
}
|
||||
|
||||
const subscribeToBrowserClock = () => () => undefined;
|
||||
|
||||
function useBrowserClock() {
|
||||
const offset = useSyncExternalStore(
|
||||
subscribeToBrowserClock,
|
||||
() => new Date().getTimezoneOffset(),
|
||||
() => 0,
|
||||
);
|
||||
const zone = useSyncExternalStore(
|
||||
subscribeToBrowserClock,
|
||||
() => Intl.DateTimeFormat().resolvedOptions().timeZone || "browser local time",
|
||||
() => "UTC",
|
||||
);
|
||||
return { offset, zone };
|
||||
}
|
||||
|
||||
export function GroupScheduleEditor({ windows }: { windows: WeeklyAccessWindow[] }) {
|
||||
const { offset, zone } = useBrowserClock();
|
||||
const [editable, setEditable] = useState<EditableWindow[]>(
|
||||
windows.map((window, key) => ({ ...window, key })),
|
||||
);
|
||||
const nextKey = useRef(windows.length);
|
||||
|
||||
function update(key: number, field: "startMinuteOfWeek" | "endMinuteOfWeek", value: number) {
|
||||
setEditable((current) => current.map((window) => {
|
||||
if (window.key !== key) return window;
|
||||
const local = { ...utcWindowToLocal(window, offset), [field]: value };
|
||||
return { ...localWindowToUtc(local, offset), key };
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm leading-6 text-muted">
|
||||
Schedules are stored and enforced in UTC. The editor shows the current browser-local equivalent in <strong className="text-ink">{zone}</strong>; it may shift when your local daylight-saving offset changes.
|
||||
</p>
|
||||
{!editable.length && <p className="border-l-2 border-signal pl-4 text-sm">No windows means no schedule restrictions while Minecraft access is enabled.</p>}
|
||||
{editable.map((window, index) => {
|
||||
const local = utcWindowToLocal(window, offset);
|
||||
const start = minuteParts(local.startMinuteOfWeek);
|
||||
const end = minuteParts(local.endMinuteOfWeek);
|
||||
return (
|
||||
<fieldset aria-label={`Access window ${index + 1}`} className="border border-line p-4" key={window.key}>
|
||||
<legend className="px-2 font-mono text-[10px] font-bold uppercase tracking-wider">Access window {index + 1}</legend>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<ScheduleBoundary day={start.day} label="Starts" onDay={(day) => update(window.key, "startMinuteOfWeek", withDay(local.startMinuteOfWeek, day))} onTime={(time) => update(window.key, "startMinuteOfWeek", withTime(local.startMinuteOfWeek, time))} time={start.time} />
|
||||
<ScheduleBoundary day={end.day} label="Ends (exclusive)" onDay={(day) => update(window.key, "endMinuteOfWeek", withDay(local.endMinuteOfWeek, day))} onTime={(time) => update(window.key, "endMinuteOfWeek", withTime(local.endMinuteOfWeek, time))} time={end.time} />
|
||||
</div>
|
||||
<input name="startMinuteOfWeek" type="hidden" value={window.startMinuteOfWeek} />
|
||||
<input name="endMinuteOfWeek" type="hidden" value={window.endMinuteOfWeek} />
|
||||
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="font-mono text-[9px] uppercase text-muted">UTC: {formatWeeklyMinute(window.startMinuteOfWeek)}–{formatWeeklyMinute(window.endMinuteOfWeek)}</p>
|
||||
<button className="font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4" onClick={() => setEditable((current) => current.filter((item) => item.key !== window.key))} type="button">Remove window {index + 1}</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
className="border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={editable.length >= 50}
|
||||
onClick={() => {
|
||||
const key = nextKey.current++;
|
||||
setEditable((current) => [...current, {
|
||||
key,
|
||||
...localWindowToUtc({
|
||||
startMinuteOfWeek: 4 * 1440 + 20 * 60,
|
||||
endMinuteOfWeek: 5 * 1440,
|
||||
}, offset),
|
||||
}]);
|
||||
}}
|
||||
type="button"
|
||||
>Add window</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleBoundary({ day, label, onDay, onTime, time }: {
|
||||
day: number;
|
||||
label: string;
|
||||
onDay: (day: number) => void;
|
||||
onTime: (time: string) => void;
|
||||
time: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<span className="block text-xs font-bold">{label}</span>
|
||||
<div className="mt-2 grid grid-cols-[1fr_auto] gap-2">
|
||||
<label><span className="sr-only">{label} weekday</span><select className="w-full border border-line bg-canvas px-3 py-2 text-sm" onChange={(event) => onDay(Number(event.target.value))} value={day}>{DAYS.map((name, value) => <option key={name} value={value}>{name}</option>)}</select></label>
|
||||
<label><span className="sr-only">{label} time</span><input className="border border-line bg-canvas px-3 py-2 text-sm" onChange={(event) => onTime(event.target.value)} required type="time" value={time} /></label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GroupScheduleSummary({ windows }: { windows: WeeklyAccessWindow[] }) {
|
||||
const { offset, zone } = useBrowserClock();
|
||||
if (!windows.length) return <p className="text-sm text-muted">No schedule restrictions. Enabled members may attempt to join at any time.</p>;
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted">Current browser-local equivalent: {zone}. UTC remains authoritative.</p>
|
||||
<ol className="mt-3 space-y-2">
|
||||
{windows.map((window, index) => {
|
||||
const local = utcWindowToLocal(window, offset);
|
||||
return <li className="border-l-2 border-accent pl-3 text-sm" key={`${window.startMinuteOfWeek}-${window.endMinuteOfWeek}-${index}`}><span className="font-bold">{formatWeeklyMinute(local.startMinuteOfWeek)}–{formatWeeklyMinute(local.endMinuteOfWeek)}</span><span className="mt-1 block font-mono text-[9px] uppercase text-muted">{formatWeeklyMinute(window.startMinuteOfWeek)} UTC–{formatWeeklyMinute(window.endMinuteOfWeek)} UTC</span></li>;
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { groupMapLocations } from "@/lib/user-location-map";
|
||||
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: '© <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 group of groupMapLocations(locations)) {
|
||||
const firstUser = group.locations[0]!;
|
||||
const isGrouped = group.count > 1;
|
||||
const marker = isGrouped
|
||||
? leaflet.marker([group.latitude, group.longitude], {
|
||||
icon: leaflet.divIcon({
|
||||
className: "map-user-cluster",
|
||||
html: `<span aria-hidden="true">${group.count}</span>`,
|
||||
iconAnchor: [18, 18],
|
||||
iconSize: [36, 36],
|
||||
}),
|
||||
keyboard: true,
|
||||
}).addTo(map)
|
||||
: leaflet.circleMarker([group.latitude, group.longitude], {
|
||||
radius: 8,
|
||||
color: "#eee8d8",
|
||||
weight: 3,
|
||||
fillColor: "#a32f1b",
|
||||
fillOpacity: 1,
|
||||
}).addTo(map);
|
||||
const tooltip = document.createElement("span");
|
||||
tooltip.textContent = isGrouped
|
||||
? `${group.count} users · ${group.nicknames.join(" · ")}`
|
||||
: `${firstUser.nickname} · ${firstUser.location}`;
|
||||
marker.bindTooltip(tooltip, { direction: "top" });
|
||||
|
||||
if (isGrouped) {
|
||||
const popup = document.createElement("div");
|
||||
const heading = document.createElement("strong");
|
||||
heading.textContent = `${group.count} users near ${firstUser.location}`;
|
||||
popup.append(heading);
|
||||
const list = document.createElement("ul");
|
||||
for (const user of group.locations) {
|
||||
const item = document.createElement("li");
|
||||
const link = document.createElement("a");
|
||||
link.href = `/admin/users/${user.userId}`;
|
||||
link.textContent = user.nickname;
|
||||
item.append(link);
|
||||
list.append(item);
|
||||
}
|
||||
popup.append(list);
|
||||
marker.bindPopup(popup);
|
||||
} else {
|
||||
marker.on("click", () => window.location.assign(`/admin/users/${firstUser.userId}`));
|
||||
}
|
||||
|
||||
const element = marker.getElement();
|
||||
const label = isGrouped
|
||||
? `${group.count} users near ${firstUser.location}: ${group.nicknames.join(", ")}`
|
||||
: `${firstUser.nickname}, ${firstUser.location}`;
|
||||
element?.setAttribute("aria-label", label);
|
||||
element?.setAttribute("role", isGrouped ? "button" : "link");
|
||||
element?.setAttribute("tabindex", "0");
|
||||
if (isGrouped) {
|
||||
element?.setAttribute("aria-haspopup", "dialog");
|
||||
element?.setAttribute("aria-expanded", "false");
|
||||
marker.on("popupopen", () => element?.setAttribute("aria-expanded", "true"));
|
||||
marker.on("popupclose", () => element?.setAttribute("aria-expanded", "false"));
|
||||
}
|
||||
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();
|
||||
if (isGrouped) marker.openPopup();
|
||||
else window.location.assign(`/admin/users/${firstUser.userId}`);
|
||||
}
|
||||
});
|
||||
bounds.push([group.latitude, group.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,134 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const actionMocks = vi.hoisted(() => ({
|
||||
execute: vi.fn(async (_previous: unknown, formData: FormData) => ({
|
||||
status: "success" as const,
|
||||
message: `Executed ${String(formData.get("command") ?? "")}`,
|
||||
serverId: String(formData.get("serverId") ?? ""),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/admin/(console)/rcon/actions", () => ({
|
||||
createRconServer: vi.fn(),
|
||||
deleteRconServer: vi.fn(),
|
||||
executeRconCommand: actionMocks.execute,
|
||||
setRconServerEnabled: vi.fn(),
|
||||
testSavedRconServer: vi.fn(),
|
||||
updateRconServer: vi.fn(),
|
||||
}));
|
||||
|
||||
import { RconConsole } from "./rcon-console";
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
const server = {
|
||||
id: "11111111-1111-4111-8111-111111111111",
|
||||
name: "Season 4",
|
||||
host: "season4.somc.svc.cluster.local",
|
||||
port: 25575,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const creative = {
|
||||
...server,
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
name: "Creative",
|
||||
host: "creative.example.com",
|
||||
};
|
||||
|
||||
describe("RconConsole", () => {
|
||||
it("renders one wide terminal workspace with connection controls and modal forms", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole servers={[server]} />);
|
||||
expect(markup).toContain('aria-label="RCON terminal"');
|
||||
expect(markup).toContain('for="rcon-console-server"');
|
||||
expect(markup).toContain('for="rcon-command"');
|
||||
expect(markup).toContain("w-full");
|
||||
expect(markup).toContain("Season 4");
|
||||
expect(markup).toContain("server://");
|
||||
expect(markup).toContain("Awaiting command");
|
||||
expect(markup).toContain("Add");
|
||||
expect(markup).toContain("Edit");
|
||||
expect(markup).toContain("Test");
|
||||
expect(markup).toContain("Disable");
|
||||
expect(markup).toContain("Delete");
|
||||
expect(markup).toContain("Add RCON connection");
|
||||
expect(markup).toContain('href="/admin/rcon/history"');
|
||||
expect(markup).toContain("Command history");
|
||||
expect(markup).toContain("Edit Season 4");
|
||||
expect(markup).toContain("Delete Season 4?");
|
||||
expect(markup).toContain("Enter ↵");
|
||||
expect(markup).not.toContain("Latest response");
|
||||
});
|
||||
|
||||
it("renders connection operation notices inside the terminal viewport", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole notice={{ status: "error", message: "RCON authentication timed out." }} servers={[server]} />);
|
||||
expect(markup).toContain("RCON authentication timed out.");
|
||||
expect(markup).toContain('role="alert"');
|
||||
});
|
||||
|
||||
it("keeps the terminal and add action available when no connection exists", () => {
|
||||
const markup = renderToStaticMarkup(<RconConsole servers={[]} />);
|
||||
expect(markup).toContain('aria-label="RCON terminal"');
|
||||
expect(markup).toContain("No connections configured");
|
||||
expect(markup).toContain("Add");
|
||||
expect(markup).not.toContain("Edit");
|
||||
expect(markup).not.toContain("Delete");
|
||||
});
|
||||
|
||||
it("navigates page-memory command history and restores the unsent draft", async () => {
|
||||
render(<RconConsole servers={[server]} />);
|
||||
const input = screen.getByLabelText("Command") as HTMLInputElement;
|
||||
|
||||
fireEvent.change(input, { target: { value: "list" } });
|
||||
fireEvent.submit(input.form!);
|
||||
await waitFor(() => expect(screen.getByText("Executed list")).toBeTruthy());
|
||||
expect(document.activeElement).toBe(input);
|
||||
expect(input.value).toBe("");
|
||||
|
||||
fireEvent.change(input, { target: { value: "say hello" } });
|
||||
fireEvent.submit(input.form!);
|
||||
await waitFor(() => expect(screen.getByText("Executed say hello")).toBeTruthy());
|
||||
|
||||
fireEvent.change(input, { target: { value: "draft command" } });
|
||||
fireEvent.keyDown(input, { key: "ArrowUp" });
|
||||
expect(input.value).toBe("say hello");
|
||||
fireEvent.keyDown(input, { key: "ArrowUp" });
|
||||
expect(input.value).toBe("list");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
expect(input.value).toBe("say hello");
|
||||
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||
expect(input.value).toBe("draft command");
|
||||
});
|
||||
|
||||
it("retains chronological command and response exchanges in the terminal transcript", async () => {
|
||||
render(<RconConsole servers={[server]} />);
|
||||
const input = screen.getByLabelText("Command") as HTMLInputElement;
|
||||
|
||||
let listResponses = 0;
|
||||
for (const command of ["list", "say hello", "list"]) {
|
||||
fireEvent.change(input, { target: { value: command } });
|
||||
fireEvent.submit(input.form!);
|
||||
if (command === "list") listResponses += 1;
|
||||
await waitFor(() => expect(screen.getAllByText(`Executed ${command}`)).toHaveLength(command === "list" ? listResponses : 1));
|
||||
}
|
||||
|
||||
const transcript = screen.getByLabelText("Terminal transcript");
|
||||
const text = transcript.textContent ?? "";
|
||||
expect(text.indexOf("$ list")).toBeLessThan(text.indexOf("Executed list"));
|
||||
expect(text.indexOf("Executed list")).toBeLessThan(text.indexOf("$ say hello"));
|
||||
expect(text.indexOf("$ say hello")).toBeLessThan(text.indexOf("Executed say hello"));
|
||||
expect(screen.getAllByText("Executed list")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("returns focus to the command prompt after changing servers", async () => {
|
||||
render(<RconConsole servers={[server, creative]} />);
|
||||
const select = screen.getByLabelText("Server");
|
||||
select.focus();
|
||||
fireEvent.change(select, { target: { value: creative.id } });
|
||||
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText("Command")));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
createRconServer,
|
||||
deleteRconServer,
|
||||
executeRconCommand,
|
||||
setRconServerEnabled,
|
||||
testSavedRconServer,
|
||||
type RconCommandState,
|
||||
updateRconServer,
|
||||
} from "@/app/admin/(console)/rcon/actions";
|
||||
import { AdminModalForm } from "@/components/admin-modal-form";
|
||||
|
||||
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
|
||||
const MAX_COMMAND_HISTORY = 50;
|
||||
const MAX_TRANSCRIPT_EXCHANGES = 50;
|
||||
|
||||
type TranscriptExchange = {
|
||||
id: number;
|
||||
serverName: string;
|
||||
command: string;
|
||||
status: "pending" | "success" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type RconServerOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type RconTerminalNotice = {
|
||||
status: "success" | "error";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function RconConsole({
|
||||
notice,
|
||||
servers,
|
||||
}: {
|
||||
notice?: RconTerminalNotice;
|
||||
servers: RconServerOption[];
|
||||
}) {
|
||||
const [selectedId, setSelectedId] = useState(servers[0]?.id ?? "");
|
||||
const [state, action, pending] = useActionState(executeRconCommand, initialState);
|
||||
const [command, setCommand] = useState("");
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||
const [transcript, setTranscript] = useState<TranscriptExchange[]>([]);
|
||||
const draftRef = useRef("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const nextExchangeIdRef = useRef(0);
|
||||
const pendingExchangeIdRef = useRef<number | null>(null);
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pending && state.status !== "idle") inputRef.current?.focus();
|
||||
}, [pending, state.status]);
|
||||
|
||||
useEffect(() => {
|
||||
const exchangeId = pendingExchangeIdRef.current;
|
||||
if (exchangeId === null || state.status === "idle") return;
|
||||
const resultStatus: TranscriptExchange["status"] = state.status === "error" ? "error" : "success";
|
||||
setTranscript((current) => current.map((exchange) => exchange.id === exchangeId
|
||||
? { ...exchange, status: resultStatus, message: state.message }
|
||||
: exchange));
|
||||
pendingExchangeIdRef.current = null;
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (transcriptRef.current) transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
|
||||
}, [transcript]);
|
||||
|
||||
function navigateHistory(direction: "older" | "newer") {
|
||||
if (!history.length) return;
|
||||
if (direction === "older") {
|
||||
const nextIndex = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
|
||||
if (historyIndex === null) draftRef.current = command;
|
||||
setHistoryIndex(nextIndex);
|
||||
setCommand(history[nextIndex]!);
|
||||
return;
|
||||
}
|
||||
if (historyIndex === null) return;
|
||||
if (historyIndex < history.length - 1) {
|
||||
const nextIndex = historyIndex + 1;
|
||||
setHistoryIndex(nextIndex);
|
||||
setCommand(history[nextIndex]!);
|
||||
} else {
|
||||
setHistoryIndex(null);
|
||||
setCommand(draftRef.current);
|
||||
}
|
||||
}
|
||||
|
||||
function rememberSubmittedCommand() {
|
||||
const submitted = command.trim();
|
||||
if (!submitted || !selected) return;
|
||||
const exchangeId = ++nextExchangeIdRef.current;
|
||||
pendingExchangeIdRef.current = exchangeId;
|
||||
const exchange: TranscriptExchange = {
|
||||
id: exchangeId,
|
||||
serverName: selected.name,
|
||||
command: submitted,
|
||||
status: "pending",
|
||||
message: "Command in progress…",
|
||||
};
|
||||
setTranscript((current) => [...current, exchange].slice(-MAX_TRANSCRIPT_EXCHANGES));
|
||||
setHistory((current) => [...current, submitted].slice(-MAX_COMMAND_HISTORY));
|
||||
setHistoryIndex(null);
|
||||
draftRef.current = "";
|
||||
setCommand("");
|
||||
}
|
||||
|
||||
return (
|
||||
<section aria-label="RCON terminal" className="mt-8 w-full overflow-hidden border-2 border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||
<div className="flex flex-col gap-4 border-b-2 border-ink bg-canvas px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||
<div className="flex items-center gap-2 font-mono text-[10px] font-bold uppercase tracking-wider text-muted">
|
||||
<span aria-hidden="true" className={`size-2 rounded-full shadow-[0_0_0_1px_var(--color-ink)] ${selected?.enabled ? "bg-signal" : "bg-line"}`} />
|
||||
<span>server://</span>
|
||||
</div>
|
||||
{servers.length ? (
|
||||
<label className="flex min-w-0 items-center gap-2 font-mono text-[9px] font-bold uppercase tracking-wider" htmlFor="rcon-console-server">
|
||||
<span className="sr-only">Server</span>
|
||||
<select
|
||||
className="max-w-full border border-line bg-panel px-3 py-2 font-mono text-xs font-bold normal-case outline-none focus:border-accent"
|
||||
id="rcon-console-server"
|
||||
onChange={(event) => setSelectedId(event.target.value)}
|
||||
value={selected?.id}
|
||||
>
|
||||
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}{server.enabled ? "" : " — disabled"}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
) : (
|
||||
<span className="font-mono text-xs font-bold text-muted">no-target</span>
|
||||
)}
|
||||
{selected && <span className="font-mono text-[9px] text-muted">{selected.host}:{selected.port}</span>}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link className="border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider hover:border-ink" href="/admin/rcon/history">Command history</Link>
|
||||
<ConnectionModal mode="add" />
|
||||
{selected && (
|
||||
<>
|
||||
<form action={testSavedRconServer}>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<HeaderButton label="Test" />
|
||||
</form>
|
||||
<form action={setRconServerEnabled}>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<input name="enabled" type="hidden" value={selected.enabled ? "no" : "yes"} />
|
||||
<HeaderButton label={selected.enabled ? "Disable" : "Enable"} />
|
||||
</form>
|
||||
<ConnectionModal mode="edit" server={selected} />
|
||||
<AdminModalForm
|
||||
action={deleteRconServer}
|
||||
description={`Delete ${selected.name} and its encrypted credential. This cannot be undone.`}
|
||||
intent="danger"
|
||||
submitLabel="Delete connection"
|
||||
title={`Delete ${selected.name}?`}
|
||||
triggerClassName="border border-accent px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-accent"
|
||||
triggerLabel="Delete"
|
||||
>
|
||||
<input name="serverId" type="hidden" value={selected.id} />
|
||||
<input name="confirmation" type="hidden" value={selected.id} />
|
||||
</AdminModalForm>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div aria-label="Terminal transcript" aria-live="polite" aria-relevant="additions text" className="min-h-72 max-h-[32rem] overflow-auto p-5 font-mono text-xs leading-5" ref={transcriptRef} role="status">
|
||||
{notice && (
|
||||
<div className={`mb-5 border-l-2 pl-3 ${notice.status === "error" ? "border-accent" : "border-signal"}`} role={notice.status === "error" ? "alert" : "status"}>
|
||||
<p className={`text-[9px] font-bold uppercase tracking-wider ${notice.status === "error" ? "text-accent" : "text-muted"}`}>{notice.status === "error" ? "Connection error" : "Connection update"}</p>
|
||||
<p className="mt-2">{notice.message}</p>
|
||||
</div>
|
||||
)}
|
||||
{!transcript.length && <TerminalIdle selected={selected} />}
|
||||
<div className="space-y-6">
|
||||
{transcript.map((exchange) => (
|
||||
<article className="border-l-2 border-line pl-3" key={exchange.id}>
|
||||
<p className="break-words">
|
||||
<span className="mr-2 text-[9px] font-bold uppercase tracking-wider text-muted">server://{exchange.serverName}</span>
|
||||
<span className="text-accent">$</span> {exchange.command}
|
||||
</p>
|
||||
<div className={`mt-2 ${exchange.status === "error" ? "text-accent" : "text-ink"}`} role={exchange.status === "error" ? "alert" : undefined}>
|
||||
{exchange.status === "pending" ? <p className="text-muted">Command in progress…</p> : <pre className="whitespace-pre-wrap break-words font-mono text-xs leading-5">{exchange.message}</pre>}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3" onSubmit={rememberSubmittedCommand}>
|
||||
<input name="serverId" type="hidden" value={selected?.id ?? ""} />
|
||||
<span aria-hidden="true" className="font-mono text-lg font-black text-accent">$</span>
|
||||
<label className="sr-only" htmlFor="rcon-command">Command</label>
|
||||
<input
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
className="min-w-0 flex-1 bg-transparent px-1 py-2 font-mono text-sm outline-none placeholder:text-muted focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={!selected?.enabled || pending}
|
||||
id="rcon-command"
|
||||
key={selected?.id ?? "no-server"}
|
||||
maxLength={1024}
|
||||
name="command"
|
||||
onChange={(event) => setCommand(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
navigateHistory(event.key === "ArrowUp" ? "older" : "newer");
|
||||
}
|
||||
}}
|
||||
placeholder={selected ? (selected.enabled ? "list" : "Enable this connection to run commands") : "Add a connection to begin"}
|
||||
ref={inputRef}
|
||||
required
|
||||
spellCheck={false}
|
||||
value={command}
|
||||
/>
|
||||
<button className="border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas disabled:cursor-not-allowed disabled:opacity-50" disabled={!selected?.enabled || pending} type="submit">{pending ? "Running…" : "Enter ↵"}</button>
|
||||
</form>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalIdle({ selected }: { selected?: RconServerOption }) {
|
||||
if (!selected) return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready</p><p className="mt-3">No connections configured. Use Add to create a server connection.</p></>;
|
||||
if (!selected.enabled) return <><p className="text-[9px] font-bold uppercase tracking-wider text-accent">Disabled — {selected.name}</p><p className="mt-3">Enable this connection before testing commands.</p></>;
|
||||
return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready — {selected.name}</p><p className="mt-3">Awaiting command</p></>;
|
||||
}
|
||||
|
||||
function HeaderButton({ label }: { label: string }) {
|
||||
return <button className="border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider hover:border-ink" type="submit">{label}</button>;
|
||||
}
|
||||
|
||||
function ConnectionModal({
|
||||
mode,
|
||||
server,
|
||||
}: {
|
||||
mode: "add" | "edit";
|
||||
server?: RconServerOption;
|
||||
}) {
|
||||
const editing = mode === "edit" ? server : undefined;
|
||||
return (
|
||||
<AdminModalForm
|
||||
action={editing ? updateRconServer : createRconServer}
|
||||
description={editing ? `Update ${editing.name}. Leave the password blank to preserve its encrypted credential.` : "Add an internal or external RCON server address. The password is encrypted before storage."}
|
||||
submitLabel={editing ? "Save connection" : "Add connection"}
|
||||
title={editing ? `Edit ${editing.name}` : "Add RCON connection"}
|
||||
triggerClassName={editing ? "border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider" : "border border-ink bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas"}
|
||||
triggerLabel={editing ? "Edit" : "Add"}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{editing && <input name="serverId" type="hidden" value={editing.id} />}
|
||||
<ConnectionFields defaults={editing} prefix={editing?.id ?? "new"} />
|
||||
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase">
|
||||
<input className="size-4" defaultChecked={editing?.enabled ?? false} name="enabled" type="checkbox" value="yes" />
|
||||
{editing ? "Enabled" : "Enable immediately"}
|
||||
</label>
|
||||
</div>
|
||||
</AdminModalForm>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionFields({
|
||||
defaults,
|
||||
prefix,
|
||||
}: {
|
||||
defaults?: { name: string; host: string; port: number };
|
||||
prefix: string;
|
||||
}) {
|
||||
const fieldClass = "mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent";
|
||||
return (
|
||||
<>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-name`}>Name<input className={fieldClass} defaultValue={defaults?.name} id={`${prefix}-rcon-name`} maxLength={100} name="name" required /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-host`}>Server address<input autoCapitalize="none" autoCorrect="off" className={fieldClass} defaultValue={defaults?.host} id={`${prefix}-rcon-host`} maxLength={253} name="host" placeholder="minecraft.example.com" required spellCheck={false} /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-port`}>Port<input className={fieldClass} defaultValue={defaults?.port ?? 25575} id={`${prefix}-rcon-port`} max={65535} min={1} name="port" required type="number" /></label>
|
||||
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-password`}>{defaults ? "Replacement password" : "Password"}<input autoComplete="new-password" className={fieldClass} id={`${prefix}-rcon-password`} maxLength={512} name="password" required={!defaults} type="password" />{defaults && <span className="mt-2 block font-sans text-[10px] font-normal normal-case text-muted">Leave blank to preserve the current password.</span>}</label>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SiteFooter } from "./site-footer";
|
||||
|
||||
describe("SiteFooter", () => {
|
||||
it("credits DMG Games with the requested sponsor link", () => {
|
||||
const markup = renderToStaticMarkup(<SiteFooter />);
|
||||
|
||||
expect(markup).toContain("Social Minecraft is sponsored by");
|
||||
expect(markup).toContain('href="https://dmg.games"');
|
||||
expect(markup).toContain("DMG Games.");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
export function SiteFooter() {
|
||||
return (
|
||||
<footer className="border-t border-line bg-panel px-6 py-6 text-center font-mono text-[10px] uppercase tracking-[0.16em] text-muted">
|
||||
Social Minecraft is sponsored by{" "}
|
||||
<a
|
||||
className="font-bold text-ink underline decoration-accent underline-offset-4 transition-colors hover:text-accent"
|
||||
href="https://dmg.games"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
DMG Games.
|
||||
</a>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { UserGroupSelect } from "./user-group-select";
|
||||
|
||||
const groups = [{ id: "group-everyone", name: "everyone" }, { id: "group-ops", name: "Ops" }];
|
||||
|
||||
beforeEach(() => {
|
||||
HTMLDialogElement.prototype.showModal = function showModal() { this.open = true; };
|
||||
HTMLDialogElement.prototype.close = function close() {
|
||||
this.open = false;
|
||||
this.dispatchEvent(new Event("close"));
|
||||
};
|
||||
});
|
||||
|
||||
describe("UserGroupSelect", () => {
|
||||
it("renders the effective group and preserves the return path", () => {
|
||||
const markup = renderToStaticMarkup(<UserGroupSelect
|
||||
action={async () => undefined}
|
||||
effectiveGroupId="group-ops"
|
||||
groups={groups}
|
||||
returnTo="/admin/users?q=alex%20smith"
|
||||
userId="user-one"
|
||||
userLabel="Alex"
|
||||
/>);
|
||||
|
||||
expect(markup).toContain('aria-label="Group for Alex"');
|
||||
expect(markup).toContain('<option value="group-ops" selected="">Ops</option>');
|
||||
expect(markup).toContain('<input type="hidden" name="returnTo" value="/admin/users?q=alex%20smith"/>');
|
||||
expect(markup).toContain("Changing this selection opens a confirmation dialog.");
|
||||
expect(markup).toContain("Confirm move");
|
||||
});
|
||||
|
||||
it("requires confirmation and restores the effective group when cancelled", () => {
|
||||
render(<UserGroupSelect action={async () => undefined} effectiveGroupId="group-ops" groups={groups} returnTo="/admin/users" userId="user-one" userLabel="Alex" />);
|
||||
const select = screen.getByRole("combobox", { name: "Group for Alex" }) as HTMLSelectElement;
|
||||
|
||||
fireEvent.change(select, { target: { value: "group-everyone" } });
|
||||
const dialog = screen.getByRole("dialog") as HTMLDialogElement;
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(select.value).toBe("group-everyone");
|
||||
expect(select.disabled).toBe(true);
|
||||
expect(screen.getByText(/from/).textContent).toContain("Ops");
|
||||
expect(screen.getByText(/from/).textContent).toContain("everyone");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(dialog.open).toBe(false);
|
||||
expect(select.value).toBe("group-ops");
|
||||
expect(select.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import type { RefObject } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useFormStatus } from "react-dom";
|
||||
|
||||
export function UserGroupSelect({
|
||||
action,
|
||||
effectiveGroupId,
|
||||
groups,
|
||||
returnTo,
|
||||
userId,
|
||||
userLabel,
|
||||
}: {
|
||||
action: (formData: FormData) => Promise<void>;
|
||||
effectiveGroupId: string;
|
||||
groups: Array<{ id: string; name: string }>;
|
||||
returnTo: string;
|
||||
userId: string;
|
||||
userLabel: string;
|
||||
}) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
const titleId = useId();
|
||||
const descriptionId = useId();
|
||||
const helpId = useId();
|
||||
const [selectedGroupId, setSelectedGroupId] = useState(effectiveGroupId);
|
||||
const [proposedGroupId, setProposedGroupId] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const currentGroup = groups.find((group) => group.id === effectiveGroupId);
|
||||
const proposedGroup = groups.find((group) => group.id === proposedGroupId);
|
||||
|
||||
function resetSelection() {
|
||||
setSelectedGroupId(effectiveGroupId);
|
||||
setProposedGroupId(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<span className="sr-only" id={helpId}>Changing this selection opens a confirmation dialog.</span>
|
||||
<select
|
||||
aria-describedby={helpId}
|
||||
aria-label={`Group for ${userLabel}`}
|
||||
className="max-w-44 border border-line bg-canvas px-3 py-2 font-mono text-xs outline-none focus:border-accent disabled:cursor-wait disabled:opacity-60"
|
||||
disabled={proposedGroupId !== null || submitting}
|
||||
onChange={(event) => {
|
||||
const nextGroupId = event.currentTarget.value;
|
||||
if (nextGroupId === effectiveGroupId) return;
|
||||
setSelectedGroupId(nextGroupId);
|
||||
setProposedGroupId(nextGroupId);
|
||||
dialogRef.current?.showModal();
|
||||
}}
|
||||
value={selectedGroupId}
|
||||
>
|
||||
{groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
|
||||
</select>
|
||||
<dialog
|
||||
aria-describedby={descriptionId}
|
||||
aria-labelledby={titleId}
|
||||
className="admin-modal m-auto w-[min(92vw,34rem)] border border-ink bg-panel p-0 text-ink shadow-[10px_10px_0_var(--color-shadow)] backdrop:bg-ink/70"
|
||||
onCancel={(event) => { if (submitting) event.preventDefault(); }}
|
||||
onClose={() => { if (!submitting) resetSelection(); }}
|
||||
ref={dialogRef}
|
||||
>
|
||||
<form action={action} className="p-6 sm:p-8" onSubmit={() => setSubmitting(true)}>
|
||||
<input name="userId" type="hidden" value={userId} />
|
||||
<input name="groupId" type="hidden" value={proposedGroupId ?? effectiveGroupId} />
|
||||
<input name="returnTo" type="hidden" value={returnTo} />
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-[0.2em] text-accent">Confirm membership</p>
|
||||
<h2 className="mt-3 font-display text-3xl font-black uppercase" id={titleId}>Move {userLabel}?</h2>
|
||||
<p className="mt-3 text-sm leading-6 text-muted" id={descriptionId}>
|
||||
Change the effective group from <strong className="text-ink">{currentGroup?.name ?? "unknown"}</strong> to <strong className="text-ink">{proposedGroup?.name ?? "unknown"}</strong>. Their access policy changes immediately.
|
||||
</p>
|
||||
<AssignmentActions dialogRef={dialogRef} onPendingChange={setSubmitting} />
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AssignmentActions({ dialogRef, onPendingChange }: { dialogRef: RefObject<HTMLDialogElement | null>; onPendingChange: (pending: boolean) => void }) {
|
||||
const { pending } = useFormStatus();
|
||||
const observedPending = useRef(false);
|
||||
useEffect(() => {
|
||||
if (pending) {
|
||||
observedPending.current = true;
|
||||
onPendingChange(true);
|
||||
} else if (observedPending.current) {
|
||||
observedPending.current = false;
|
||||
onPendingChange(false);
|
||||
}
|
||||
}, [onPendingChange, pending]);
|
||||
return (
|
||||
<div className="mt-8 flex justify-end gap-3 border-t border-line pt-5">
|
||||
<button className="border border-line px-5 py-3 font-mono text-[10px] font-bold uppercase" disabled={pending} onClick={() => dialogRef.current?.close()} type="button">Cancel</button>
|
||||
<button className="bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase text-canvas disabled:cursor-wait disabled:opacity-60" disabled={pending} type="submit">{pending ? "Moving…" : "Confirm move"}</button>
|
||||
<span aria-live="polite" className="sr-only">{pending ? "Group change in progress." : ""}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
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",
|
||||
networkProvider: "Comcast Cable Communications, LLC",
|
||||
networkAsn: "AS7922",
|
||||
connectionType: "Residential",
|
||||
proxy: false,
|
||||
source: "game",
|
||||
observedAt: new Date("2026-08-01T12:00:00Z"),
|
||||
}, {
|
||||
userId: "22222222-2222-4222-8222-222222222222",
|
||||
name: "Alex",
|
||||
discordUsername: "alex",
|
||||
nickname: "Alex (AlexMC)",
|
||||
latitude: 37.4057,
|
||||
longitude: -122.0774,
|
||||
location: "Mountain View, California, US",
|
||||
classification: "vpn",
|
||||
networkProvider: "Proton AG",
|
||||
networkAsn: "AS62371",
|
||||
connectionType: "VPN",
|
||||
proxy: true,
|
||||
source: "web",
|
||||
observedAt: new Date("2026-08-01T13: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("Alex (AlexMC)");
|
||||
expect(markup).toContain("2 users near Mountain View, California, US");
|
||||
expect(markup).toMatch(/<text[^>]*>2<\/text>/);
|
||||
expect(markup).toContain('<details class="mt-5 border-t border-line pt-4" id="map-location-list">');
|
||||
expect(markup).not.toContain('id="map-location-list" open');
|
||||
expect(markup).toContain("Mountain View, California, US");
|
||||
expect(markup).toContain("Comcast Cable Communications, LLC");
|
||||
expect(markup).toContain("AS7922");
|
||||
expect(markup).toContain("Residential");
|
||||
expect(markup).toContain("Proton AG");
|
||||
expect(markup).toContain(">Proxy/VPN<");
|
||||
expect(markup).toContain(">Yes<");
|
||||
expect(markup).toContain(">No<");
|
||||
expect(markup).toContain("World overview");
|
||||
expect(markup).toContain("Interactive OpenStreetMap");
|
||||
expect(markup).toContain("OpenStreetMap, which receives your IP address");
|
||||
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,117 @@
|
||||
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 { groupMapLocations } from "@/lib/user-location-map";
|
||||
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;
|
||||
networkProvider: string | null;
|
||||
networkAsn: string | null;
|
||||
connectionType: string | null;
|
||||
proxy: boolean | null;
|
||||
source: string;
|
||||
observedAt: Date;
|
||||
}
|
||||
|
||||
export function UserWorldMap({ locations, unavailableCount }: { locations: UserMapLocation[]; unavailableCount: number }) {
|
||||
const locationGroups = groupMapLocations(locations);
|
||||
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>
|
||||
{locationGroups.map((group) => {
|
||||
const projected = projection([group.longitude, group.latitude]);
|
||||
if (!projected) return null;
|
||||
const x = Math.min(WIDTH - 16, Math.max(16, projected[0]));
|
||||
const y = Math.min(HEIGHT - 16, Math.max(16, projected[1]));
|
||||
const markerRadius = group.count > 1 ? 13 : 7;
|
||||
const longestNickname = Math.max(...group.nicknames.map((nickname) => nickname.length));
|
||||
const tooltipColumns = Math.ceil(group.nicknames.length / 10);
|
||||
const tooltipRows = Math.ceil(group.nicknames.length / tooltipColumns);
|
||||
const tooltipWidth = Math.min(WIDTH - 8, Math.max(110, longestNickname * 8 + 24) * tooltipColumns);
|
||||
const tooltipColumnWidth = tooltipWidth / tooltipColumns;
|
||||
const tooltipHeight = tooltipRows * 18 + 10;
|
||||
const tooltipX = Math.min(WIDTH - tooltipWidth - 4, Math.max(4, x - tooltipWidth / 2));
|
||||
const preferredTooltipY = y > tooltipHeight + 18 ? y - tooltipHeight - 12 : y + 18;
|
||||
const tooltipY = Math.min(HEIGHT - tooltipHeight - 4, Math.max(4, preferredTooltipY));
|
||||
const firstUser = group.locations[0]!;
|
||||
const label = group.count === 1
|
||||
? `${firstUser.nickname}, ${firstUser.location}`
|
||||
: `${group.count} users near ${firstUser.location}: ${group.nicknames.join(", ")}`;
|
||||
return (
|
||||
<a aria-label={label} className="map-marker-link" href={group.count === 1 ? `/admin/users/${firstUser.userId}` : "#map-location-list"} key={group.key}>
|
||||
<circle className="map-marker-target" cx={x} cy={y} fill="none" pointerEvents="stroke" r={markerRadius} stroke="transparent" strokeWidth="24" vectorEffect="non-scaling-stroke">
|
||||
<title>{label}</title>
|
||||
</circle>
|
||||
<circle className="map-marker" cx={x} cy={y} fill="var(--accent)" pointerEvents="none" r={markerRadius} stroke="var(--panel)" strokeWidth="3" />
|
||||
{group.count > 1 && <text aria-hidden="true" dominantBaseline="middle" fill="var(--panel)" fontFamily="var(--font-mono)" fontSize="12" fontWeight="700" pointerEvents="none" textAnchor="middle" x={x} y={y}>{group.count}</text>}
|
||||
<g aria-hidden="true" className="map-marker-tooltip" pointerEvents="none">
|
||||
<rect fill="var(--ink)" height={tooltipHeight} rx="2" width={tooltipWidth} x={tooltipX} y={tooltipY} />
|
||||
{group.nicknames.map((nickname, index) => {
|
||||
const column = Math.floor(index / tooltipRows);
|
||||
const row = index % tooltipRows;
|
||||
return <text dominantBaseline="middle" fill="var(--panel)" fontFamily="var(--font-mono)" fontSize="12" key={`${nickname}-${index}`} textAnchor="middle" x={tooltipX + tooltipColumnWidth * (column + 0.5)} y={tooltipY + 14 + row * 18}>{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" id="map-location-list">
|
||||
<summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase underline underline-offset-4">View accessible location list</summary>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="w-full min-w-[980px] border-collapse text-left text-xs">
|
||||
<caption className="sr-only">Latest approximate registered-user locations and enriched network details</caption>
|
||||
<thead className="border-b border-line font-mono text-[9px] uppercase tracking-wider text-muted"><tr><th className="py-3 pr-4" scope="col">User</th><th className="p-3" scope="col">Location</th><th className="p-3" scope="col">Network</th><th className="p-3" scope="col">Connection</th><th className="p-3" scope="col">Proxy/VPN</th><th className="p-3" scope="col">Risk</th><th className="p-3" scope="col">Source</th><th className="py-3 pl-4" scope="col">Last observed</th></tr></thead>
|
||||
<tbody className="divide-y divide-line">
|
||||
{locations.map((user) => <tr key={user.userId}><th className="py-3 pr-4 text-left" scope="row"><Link className="font-mono font-bold underline underline-offset-4" href={`/admin/users/${user.userId}`}>{user.nickname}</Link><span className="mt-1 block font-mono text-[9px] font-normal text-muted">@{user.discordUsername}</span></th><td className="p-3">{user.location}</td><td className="p-3"><span className="block">{user.networkProvider ?? "Unknown"}</span>{user.networkAsn && <span className="mt-1 block font-mono text-[9px] text-muted">{user.networkAsn}</span>}</td><td className="p-3">{user.connectionType ?? "Unknown"}</td><td className="p-3 font-mono font-bold uppercase">{user.proxy === null ? "Unknown" : user.proxy ? "Yes" : "No"}</td><td className="p-3 font-mono uppercase">{user.classification}</td><td className="p-3">{user.source}</td><td className="py-3 pl-4 font-mono text-[9px]"><time dateTime={user.observedAt.toISOString()}>{user.observedAt.toISOString()}</time></td></tr>)}
|
||||
{!locations.length && <tr><td className="py-6 text-muted" colSpan={8}>No user observations currently include valid coordinates.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { accessAddressDetails, groupAccessAddresses } from "./access-address-groups";
|
||||
|
||||
describe("groupAccessAddresses", () => {
|
||||
it("collapses repeated observations from the same network into one recent summary", () => {
|
||||
const groups = groupAccessAddresses([
|
||||
{ id: "old", ipAddress: "198.51.100.21", source: "web", classification: "clear", observedAt: new Date("2026-08-01T10:00:00Z"), intelligence: null },
|
||||
{ id: "new", ipAddress: "198.51.100.240", source: "game", classification: "clear", observedAt: new Date("2026-08-01T12:00:00Z"), intelligence: { provider: "proxycheck" } },
|
||||
{ id: "other", ipAddress: "203.0.113.9", source: "web", classification: "vpn", observedAt: new Date("2026-08-01T11:00:00Z"), intelligence: null },
|
||||
]);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0]).toMatchObject({
|
||||
network: "198.51.100.0/24",
|
||||
latestAddress: "198.51.100.240",
|
||||
sources: ["game", "web"],
|
||||
count: 2,
|
||||
classification: "clear",
|
||||
intelligence: { provider: "proxycheck" },
|
||||
});
|
||||
expect(groups[0]?.latestObservedAt.toISOString()).toBe("2026-08-01T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it("presents the latest enriched location and classification with observation fallbacks", () => {
|
||||
expect(accessAddressDetails({
|
||||
classification: "vpn",
|
||||
intelligence: {
|
||||
classification: "vpn",
|
||||
location: { city: "Toronto", region: "Ontario", countryCode: "CA" },
|
||||
},
|
||||
})).toEqual({ location: "Toronto, Ontario, CA", classification: "vpn" });
|
||||
|
||||
expect(accessAddressDetails({ classification: "hosting", intelligence: null })).toEqual({
|
||||
location: "Location unavailable",
|
||||
classification: "hosting",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { addressGroup } from "@minecraft-account-manager/network";
|
||||
import { intelligenceSummary } from "./event-ip-summary";
|
||||
|
||||
type AccessObservation = {
|
||||
id: string;
|
||||
ipAddress: string;
|
||||
source: string;
|
||||
classification: string;
|
||||
observedAt: Date;
|
||||
intelligence: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function accessAddressDetails(observation: Pick<AccessObservation, "classification" | "intelligence">) {
|
||||
const summary = intelligenceSummary(observation.intelligence);
|
||||
return {
|
||||
location: summary.location ?? "Location unavailable",
|
||||
classification: summary.classification ?? observation.classification,
|
||||
};
|
||||
}
|
||||
|
||||
export type AccessAddressGroup = {
|
||||
network: string;
|
||||
latestAddress: string;
|
||||
sources: string[];
|
||||
count: number;
|
||||
firstObservedAt: Date;
|
||||
latestObservedAt: Date;
|
||||
classification: string;
|
||||
intelligence: Record<string, unknown> | null;
|
||||
};
|
||||
|
||||
export function groupAccessAddresses(observations: AccessObservation[]) {
|
||||
const groups = new Map<string, AccessAddressGroup & { sourceSet: Set<string> }>();
|
||||
|
||||
for (const observation of observations) {
|
||||
const network = addressGroup(observation.ipAddress);
|
||||
const existing = groups.get(network);
|
||||
if (!existing) {
|
||||
groups.set(network, {
|
||||
network,
|
||||
latestAddress: observation.ipAddress,
|
||||
sources: [],
|
||||
sourceSet: new Set([observation.source]),
|
||||
count: 1,
|
||||
firstObservedAt: observation.observedAt,
|
||||
latestObservedAt: observation.observedAt,
|
||||
classification: observation.classification,
|
||||
intelligence: observation.intelligence,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.count += 1;
|
||||
existing.sourceSet.add(observation.source);
|
||||
if (observation.observedAt < existing.firstObservedAt) {
|
||||
existing.firstObservedAt = observation.observedAt;
|
||||
}
|
||||
if (observation.observedAt > existing.latestObservedAt) {
|
||||
existing.latestAddress = observation.ipAddress;
|
||||
existing.latestObservedAt = observation.observedAt;
|
||||
existing.classification = observation.classification;
|
||||
existing.intelligence = observation.intelligence;
|
||||
}
|
||||
}
|
||||
|
||||
return [...groups.values()]
|
||||
.map(({ sourceSet, ...group }) => ({ ...group, sources: [...sourceSet].sort() }))
|
||||
.sort((left, right) => right.latestObservedAt.getTime() - left.latestObservedAt.getTime());
|
||||
}
|
||||
@@ -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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES, parseAdmissionMessages, renderAdmissionMessage } from "./admission-settings";
|
||||
|
||||
describe("admission message settings", () => {
|
||||
it("normalizes independently configured denial templates with allowed variables", () => {
|
||||
const formData = validMessages();
|
||||
formData.set("registrationMessage", " Register {player} before joining {group}. ");
|
||||
formData.set("scheduledAccessDeniedMessage", "{player}, {group} may join from {next_start} to {next_end}.");
|
||||
|
||||
expect(parseAdmissionMessages(formData)).toEqual({
|
||||
registrationMessage: "Register {player} before joining {group}.",
|
||||
groupAccessDeniedMessage: "{player} cannot access the server with {group}.",
|
||||
vpnDeniedMessage: "VPN access for {player} in {group} requires an exception.",
|
||||
scheduledAccessDeniedMessage: "{player}, {group} may join from {next_start} to {next_end}.",
|
||||
});
|
||||
});
|
||||
|
||||
it("selects the configured message for each admission denial reason", () => {
|
||||
expect(admissionDenialMessage("not_registered", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.registrationMessage);
|
||||
expect(admissionDenialMessage("group_access_disabled", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage);
|
||||
expect(admissionDenialMessage("schedule_disallowed", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.scheduledAccessDeniedMessage);
|
||||
expect(admissionDenialMessage("anonymized_network_disallowed", DEFAULT_ADMISSION_MESSAGES)).toBe(DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage);
|
||||
});
|
||||
|
||||
it("renders static variables without evaluating expressions", () => {
|
||||
expect(renderAdmissionMessage("{player} uses {group}; next: {next_start}–{next_end}.", {
|
||||
player: "AlexMC",
|
||||
group: "Friday friends",
|
||||
next_start: "2026-08-07 20:00 UTC",
|
||||
next_end: "2026-08-07 23:59 UTC",
|
||||
})).toBe("AlexMC uses Friday friends; next: 2026-08-07 20:00 UTC–2026-08-07 23:59 UTC.");
|
||||
});
|
||||
|
||||
it("rejects missing, short, overlong, control-character, or unsupported-variable messages", () => {
|
||||
for (const invalid of ["short", "a".repeat(501), "Denied\nInjected", "Denied for {next_start}.", "Denied for {unknown}."] as const) {
|
||||
const formData = validMessages();
|
||||
formData.set("vpnDeniedMessage", invalid);
|
||||
expect(parseAdmissionMessages(formData)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function validMessages() {
|
||||
const formData = new FormData();
|
||||
formData.set("registrationMessage", "Register {player} before joining {group}.");
|
||||
formData.set("groupAccessDeniedMessage", "{player} cannot access the server with {group}.");
|
||||
formData.set("vpnDeniedMessage", "VPN access for {player} in {group} requires an exception.");
|
||||
formData.set("scheduledAccessDeniedMessage", "{group} may join from {next_start} to {next_end}.");
|
||||
return formData;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export interface AdmissionMessages {
|
||||
registrationMessage: string;
|
||||
groupAccessDeniedMessage: string;
|
||||
vpnDeniedMessage: string;
|
||||
scheduledAccessDeniedMessage: string;
|
||||
}
|
||||
|
||||
export type AdmissionDenialReason =
|
||||
| "not_registered"
|
||||
| "group_access_disabled"
|
||||
| "schedule_disallowed"
|
||||
| "anonymized_network_disallowed";
|
||||
|
||||
export const DEFAULT_ADMISSION_MESSAGES: AdmissionMessages = {
|
||||
registrationMessage: "Please register your Minecraft account before joining.",
|
||||
groupAccessDeniedMessage: "Your account group does not currently have server access. Contact a host if you believe this is a mistake.",
|
||||
vpnDeniedMessage: "VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception.",
|
||||
scheduledAccessDeniedMessage: "Your group is only allowed access from {next_start} to {next_end}.",
|
||||
} as const;
|
||||
|
||||
export function admissionDenialMessage(reason: AdmissionDenialReason, messages: AdmissionMessages) {
|
||||
if (reason === "not_registered") return messages.registrationMessage;
|
||||
if (reason === "group_access_disabled") return messages.groupAccessDeniedMessage;
|
||||
if (reason === "schedule_disallowed") return messages.scheduledAccessDeniedMessage;
|
||||
return messages.vpnDeniedMessage;
|
||||
}
|
||||
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
||||
const TEMPLATE_VARIABLE = /\{([a-z_]+)\}/g;
|
||||
const COMMON_VARIABLES = new Set(["player", "group"]);
|
||||
const SCHEDULE_VARIABLES = new Set(["player", "group", "next_start", "next_end"]);
|
||||
|
||||
type MessageName = keyof AdmissionMessages;
|
||||
|
||||
function messageValue(formData: FormData, name: MessageName, allowedVariables: Set<string>) {
|
||||
const value = String(formData.get(name) ?? "").trim();
|
||||
if (value.length < 10 || value.length > 500 || CONTROL_CHARACTERS.test(value)) return null;
|
||||
const withoutVariables = value.replace(TEMPLATE_VARIABLE, (match, variable: string) =>
|
||||
allowedVariables.has(variable) ? "" : match);
|
||||
return /[{}]/.test(withoutVariables) ? null : value;
|
||||
}
|
||||
|
||||
export function parseAdmissionMessages(formData: FormData) {
|
||||
const registrationMessage = messageValue(formData, "registrationMessage", COMMON_VARIABLES);
|
||||
const groupAccessDeniedMessage = messageValue(formData, "groupAccessDeniedMessage", COMMON_VARIABLES);
|
||||
const vpnDeniedMessage = messageValue(formData, "vpnDeniedMessage", COMMON_VARIABLES);
|
||||
const scheduledAccessDeniedMessage = messageValue(formData, "scheduledAccessDeniedMessage", SCHEDULE_VARIABLES);
|
||||
if (!registrationMessage || !groupAccessDeniedMessage || !vpnDeniedMessage || !scheduledAccessDeniedMessage) return null;
|
||||
return { registrationMessage, groupAccessDeniedMessage, vpnDeniedMessage, scheduledAccessDeniedMessage };
|
||||
}
|
||||
|
||||
export function renderAdmissionMessage(template: string, variables: Record<string, string>) {
|
||||
return template.replace(TEMPLATE_VARIABLE, (match, variable: string) => variables[variable] ?? match);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applicationUrl } from "./application-url";
|
||||
|
||||
describe("applicationUrl", () => {
|
||||
it("builds browser redirects from the configured public application URL", () => {
|
||||
expect(applicationUrl("/welcome", "https://portal.somc.club"))
|
||||
.toEqual(new URL("https://portal.somc.club/welcome"));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export function applicationUrl(path: string, baseUrl = process.env.APP_URL) {
|
||||
if (!baseUrl) throw new Error("APP_URL is required to build public application URLs");
|
||||
return new URL(path, baseUrl);
|
||||
}
|
||||
@@ -5,23 +5,34 @@ import { db } from "@/lib/database";
|
||||
|
||||
const UI_ACCESSED = "games.minecraft.account-manager.ui.accessed";
|
||||
|
||||
export async function recordAdminEvent(
|
||||
export async function recordAdminSubjectEvent(
|
||||
admin: { email: string | null; name: string | null },
|
||||
targetUserId: string,
|
||||
subject: string,
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
options: { correlationId?: string } = {},
|
||||
) {
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
return recordEvent(db, {
|
||||
type,
|
||||
source: "/web/admin",
|
||||
subject: `user/${targetUserId}`,
|
||||
subject,
|
||||
ipAddress: ipAddress ?? undefined,
|
||||
correlationId: options.correlationId,
|
||||
data: { ...data, adminEmail: admin.email, adminName: admin.name },
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordAdminEvent(
|
||||
admin: { email: string | null; name: string | null },
|
||||
targetUserId: string,
|
||||
type: string,
|
||||
data: Record<string, unknown>,
|
||||
) {
|
||||
return recordAdminSubjectEvent(admin, `user/${targetUserId}`, type, data);
|
||||
}
|
||||
|
||||
export async function recordUserEvent(
|
||||
user: { id: string },
|
||||
type: string,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { getGuildMemberIdentity } from "@minecraft-account-manager/minecraft";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
export async function discordIdentity(input: {
|
||||
discordUserId: string;
|
||||
discordUsername: string;
|
||||
discordGlobalName: string | null;
|
||||
}) {
|
||||
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
||||
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
|
||||
const fallback = {
|
||||
id: input.discordUserId,
|
||||
username: input.discordUsername,
|
||||
globalName: input.discordGlobalName,
|
||||
nickname: null as string | null,
|
||||
live: false,
|
||||
};
|
||||
if (!guildId || !botToken) return fallback;
|
||||
|
||||
try {
|
||||
return { ...await getGuildMemberIdentity({
|
||||
guildId,
|
||||
discordUserId: input.discordUserId,
|
||||
botToken,
|
||||
}), live: true };
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ err: error, event: "discord.identity_lookup_failed", discordUserId: input.discordUserId },
|
||||
"Discord guild identity lookup failed",
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DEFAULT_ADMISSION_MESSAGES } from "./admission-settings";
|
||||
import { evaluateRegisteredPlayerAdmission } from "./game-admission-policy";
|
||||
|
||||
const group = {
|
||||
name: "Friday friends",
|
||||
accessEnabled: true,
|
||||
anonymizedNetworksAllowed: false,
|
||||
};
|
||||
const fridayWindow = { startMinuteOfWeek: 6960, endMinuteOfWeek: 7200 };
|
||||
|
||||
describe("registered player admission policy", () => {
|
||||
it("lets disabled Minecraft access override an active schedule", () => {
|
||||
const decision = evaluateRegisteredPlayerAdmission({
|
||||
group: { ...group, accessEnabled: false },
|
||||
windows: [fridayWindow],
|
||||
classification: "clear",
|
||||
now: new Date("2026-08-07T21:00:00Z"),
|
||||
player: "AlexMC",
|
||||
messages: DEFAULT_ADMISSION_MESSAGES,
|
||||
});
|
||||
expect(decision.reason).toBe("group_access_disabled");
|
||||
});
|
||||
|
||||
it("denies an enabled group outside its schedule with the next UTC window", () => {
|
||||
const decision = evaluateRegisteredPlayerAdmission({
|
||||
group,
|
||||
windows: [fridayWindow],
|
||||
classification: "vpn",
|
||||
now: new Date("2026-08-08T01:00:00Z"),
|
||||
player: "AlexMC",
|
||||
messages: {
|
||||
...DEFAULT_ADMISSION_MESSAGES,
|
||||
scheduledAccessDeniedMessage: "{player} in {group}: {next_start}–{next_end}.",
|
||||
},
|
||||
});
|
||||
expect(decision.reason).toBe("schedule_disallowed");
|
||||
expect(decision.message).toBe("AlexMC in Friday friends: 2026-08-14 20:00 UTC–2026-08-15 00:00 UTC.");
|
||||
});
|
||||
|
||||
it("applies network policy only after group access and schedule pass", () => {
|
||||
const denied = evaluateRegisteredPlayerAdmission({
|
||||
group,
|
||||
windows: [fridayWindow],
|
||||
classification: "vpn",
|
||||
now: new Date("2026-08-07T21:00:00Z"),
|
||||
player: "AlexMC",
|
||||
messages: DEFAULT_ADMISSION_MESSAGES,
|
||||
});
|
||||
expect(denied.reason).toBe("anonymized_network_disallowed");
|
||||
|
||||
expect(evaluateRegisteredPlayerAdmission({
|
||||
group: { ...group, anonymizedNetworksAllowed: true },
|
||||
windows: [fridayWindow],
|
||||
classification: "vpn",
|
||||
now: new Date("2026-08-07T21:00:00Z"),
|
||||
player: "AlexMC",
|
||||
messages: DEFAULT_ADMISSION_MESSAGES,
|
||||
}).allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { gameAdmissionDenialReason } from "@minecraft-account-manager/auth";
|
||||
import {
|
||||
admissionDenialMessage,
|
||||
renderAdmissionMessage,
|
||||
type AdmissionMessages,
|
||||
} from "./admission-settings";
|
||||
import {
|
||||
evaluateGroupSchedule,
|
||||
formatScheduleInstant,
|
||||
type WeeklyAccessWindow,
|
||||
} from "./group-schedule";
|
||||
|
||||
interface EffectiveGroupPolicy {
|
||||
name: string;
|
||||
accessEnabled: boolean;
|
||||
anonymizedNetworksAllowed: boolean;
|
||||
}
|
||||
|
||||
interface RegisteredPlayerAdmissionInput {
|
||||
group: EffectiveGroupPolicy | null;
|
||||
windows: WeeklyAccessWindow[];
|
||||
classification: "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor";
|
||||
now: Date;
|
||||
player: string;
|
||||
messages: AdmissionMessages;
|
||||
}
|
||||
|
||||
export function evaluateRegisteredPlayerAdmission(input: RegisteredPlayerAdmissionInput) {
|
||||
const schedule = evaluateGroupSchedule(input.windows, input.now);
|
||||
const reason = gameAdmissionDenialReason(input.group, input.classification, schedule.allowed);
|
||||
if (!reason) return { allowed: true as const, reason: null, message: null, nextWindow: null };
|
||||
return {
|
||||
allowed: false as const,
|
||||
reason,
|
||||
message: renderAdmissionMessage(admissionDenialMessage(reason, input.messages), {
|
||||
player: input.player,
|
||||
group: input.group?.name ?? "everyone",
|
||||
next_start: schedule.nextWindow ? formatScheduleInstant(schedule.nextWindow.start) : "unavailable",
|
||||
next_end: schedule.nextWindow ? formatScheduleInstant(schedule.nextWindow.end) : "unavailable",
|
||||
}),
|
||||
nextWindow: schedule.nextWindow,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { adminGroupReturnPath, editableGroupName, effectiveGroupMemberCount, isEffectiveGroupMember, groupSlug, validateGroupDetails } from "./group-management";
|
||||
|
||||
describe("group management", () => {
|
||||
it("generates a collision-safe internal slug from the display name", () => {
|
||||
expect(groupSlug(" Trusted Öps Team! ", new Set(["trusted-ops-team", "trusted-ops-team-2"])))
|
||||
.toBe("trusted-ops-team-3");
|
||||
expect(groupSlug("🔥", new Set())).toBe("group");
|
||||
});
|
||||
|
||||
it("allows only local Users and group-detail return paths", () => {
|
||||
expect(adminGroupReturnPath("/admin/users?q=alex", "saved=group")).toBe("/admin/users?q=alex&saved=group");
|
||||
expect(adminGroupReturnPath("/admin/groups/11111111-1111-4111-8111-111111111111", "saved=group"))
|
||||
.toBe("/admin/groups/11111111-1111-4111-8111-111111111111?saved=group");
|
||||
expect(adminGroupReturnPath("https://evil.example/admin/users", "saved=group")).toBe("/admin/users?saved=group");
|
||||
expect(adminGroupReturnPath("/admin/settings", "error=invalid-group-assignment")).toBe("/admin/users?error=invalid-group-assignment");
|
||||
});
|
||||
|
||||
it("counts and filters explicit and default effective memberships", () => {
|
||||
const assignments = { one: "ops", two: "builders" };
|
||||
expect(effectiveGroupMemberCount(4, Object.values(assignments), { id: "everyone", isDefault: true })).toBe(2);
|
||||
expect(effectiveGroupMemberCount(4, Object.values(assignments), { id: "ops", isDefault: false })).toBe(1);
|
||||
expect(isEffectiveGroupMember("three", assignments, { id: "everyone", isDefault: true })).toBe(true);
|
||||
expect(isEffectiveGroupMember("one", assignments, { id: "ops", isDefault: false })).toBe(true);
|
||||
expect(isEffectiveGroupMember("two", assignments, { id: "ops", isDefault: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the protected default group name fixed", () => {
|
||||
expect(editableGroupName("everyone", true, "Renamed")).toBe("everyone");
|
||||
expect(editableGroupName("Ops", false, "Trusted hosts")).toBe("Trusted hosts");
|
||||
});
|
||||
|
||||
it("validates and normalizes editable group details", () => {
|
||||
expect(validateGroupDetails(" Trusted hosts ", " Can use managed VPNs. ")).toEqual({
|
||||
name: "Trusted hosts",
|
||||
description: "Can use managed VPNs.",
|
||||
});
|
||||
expect(validateGroupDetails("", "description")).toBeNull();
|
||||
expect(validateGroupDetails("bad\nname", "description")).toBeNull();
|
||||
expect(validateGroupDetails("Valid", "x".repeat(501))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
const NAME_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f]/;
|
||||
const TEXT_CONTROL_CHARACTERS = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/;
|
||||
|
||||
export function validateGroupDetails(nameValue: unknown, descriptionValue: unknown) {
|
||||
const name = String(nameValue ?? "").trim();
|
||||
const description = String(descriptionValue ?? "").trim();
|
||||
if (
|
||||
name.length < 1 ||
|
||||
name.length > 50 ||
|
||||
NAME_CONTROL_CHARACTERS.test(name) ||
|
||||
description.length > 500 ||
|
||||
TEXT_CONTROL_CHARACTERS.test(description)
|
||||
) return null;
|
||||
return { name, description };
|
||||
}
|
||||
|
||||
export function adminGroupReturnPath(
|
||||
value: unknown,
|
||||
result: "saved=group" | "error=invalid-group-assignment",
|
||||
) {
|
||||
const requested = String(value ?? "");
|
||||
let pathname = "/admin/users";
|
||||
const parameters = new URLSearchParams();
|
||||
if (requested.startsWith("/")) {
|
||||
const url = new URL(requested, "http://internal");
|
||||
if (url.pathname === "/admin/users") {
|
||||
const search = url.searchParams.get("q")?.trim().slice(0, 100);
|
||||
if (search) parameters.set("q", search);
|
||||
} else if (/^\/admin\/groups\/[0-9a-f-]{36}$/i.test(url.pathname)) {
|
||||
pathname = url.pathname;
|
||||
}
|
||||
}
|
||||
const [key, resultValue] = result.split("=", 2) as ["saved" | "error", string];
|
||||
parameters.set(key, resultValue);
|
||||
return `${pathname}?${parameters.toString()}`;
|
||||
}
|
||||
|
||||
export function editableGroupName(currentName: string, isDefault: boolean, requestedName: string) {
|
||||
return isDefault ? currentName : requestedName;
|
||||
}
|
||||
|
||||
export function effectiveGroupMemberCount(
|
||||
totalUsers: number,
|
||||
assignedGroupIds: string[],
|
||||
group: { id: string; isDefault: boolean },
|
||||
) {
|
||||
return group.isDefault
|
||||
? Math.max(0, totalUsers - assignedGroupIds.length)
|
||||
: assignedGroupIds.filter((groupId) => groupId === group.id).length;
|
||||
}
|
||||
|
||||
export function isEffectiveGroupMember(
|
||||
userId: string,
|
||||
assignmentByUser: Record<string, string>,
|
||||
group: { id: string; isDefault: boolean },
|
||||
) {
|
||||
return group.isDefault ? !assignmentByUser[userId] : assignmentByUser[userId] === group.id;
|
||||
}
|
||||
|
||||
export function groupSlug(name: string, existingSlugs: Set<string>) {
|
||||
const normalized = name
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "") || "group";
|
||||
const base = normalized.slice(0, 50).replace(/-+$/g, "") || "group";
|
||||
if (!existingSlugs.has(base)) return base;
|
||||
for (let suffix = 2; suffix < 10_000; suffix += 1) {
|
||||
const suffixText = `-${suffix}`;
|
||||
const candidate = `${base.slice(0, 50 - suffixText.length).replace(/-+$/g, "")}${suffixText}`;
|
||||
if (!existingSlugs.has(candidate)) return candidate;
|
||||
}
|
||||
throw new Error("Could not generate a unique group slug");
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
evaluateGroupSchedule,
|
||||
groupScheduleStatus,
|
||||
localWindowToUtc,
|
||||
parseScheduleWindows,
|
||||
utcWindowToLocal,
|
||||
type WeeklyAccessWindow,
|
||||
} from "./group-schedule";
|
||||
|
||||
const fridayEvening: WeeklyAccessWindow = {
|
||||
startMinuteOfWeek: 4 * 24 * 60 + 20 * 60,
|
||||
endMinuteOfWeek: 4 * 24 * 60 + 23 * 60 + 59,
|
||||
};
|
||||
|
||||
describe("weekly group access schedules", () => {
|
||||
it("summarizes whether a group has configured windows", () => {
|
||||
expect(groupScheduleStatus(0)).toBe("Unrestricted");
|
||||
expect(groupScheduleStatus(1)).toBe("1 window");
|
||||
expect(groupScheduleStatus(3)).toBe("3 windows");
|
||||
});
|
||||
|
||||
it("allows an enabled group at any time when no schedule is configured", () => {
|
||||
expect(evaluateGroupSchedule([], new Date("2026-08-07T19:00:00Z"))).toEqual({
|
||||
allowed: true,
|
||||
nextWindow: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows only inside a UTC window and identifies the next window when denied", () => {
|
||||
expect(evaluateGroupSchedule([fridayEvening], new Date("2026-08-07T21:30:00Z")).allowed).toBe(true);
|
||||
|
||||
const denied = evaluateGroupSchedule([fridayEvening], new Date("2026-08-08T01:00:00Z"));
|
||||
expect(denied.allowed).toBe(false);
|
||||
expect(denied.nextWindow).toEqual({
|
||||
start: new Date("2026-08-14T20:00:00.000Z"),
|
||||
end: new Date("2026-08-14T23:59:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
it("uses inclusive starts and exclusive ends across the UTC week boundary", () => {
|
||||
const sundayNight = { startMinuteOfWeek: 6 * 1440 + 23 * 60, endMinuteOfWeek: 2 * 60 };
|
||||
expect(evaluateGroupSchedule([sundayNight], new Date("2026-08-09T23:00:00Z")).allowed).toBe(true);
|
||||
expect(evaluateGroupSchedule([sundayNight], new Date("2026-08-10T01:59:59Z")).allowed).toBe(true);
|
||||
expect(evaluateGroupSchedule([sundayNight], new Date("2026-08-10T02:00:00Z")).allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("selects the earliest upcoming window when several are configured", () => {
|
||||
const mondayMorning = { startMinuteOfWeek: 8 * 60, endMinuteOfWeek: 9 * 60 };
|
||||
const decision = evaluateGroupSchedule(
|
||||
[fridayEvening, mondayMorning],
|
||||
new Date("2026-08-08T01:00:00Z"),
|
||||
);
|
||||
expect(decision.nextWindow?.start).toEqual(new Date("2026-08-10T08:00:00.000Z"));
|
||||
expect(decision.nextWindow?.end).toEqual(new Date("2026-08-10T09:00:00.000Z"));
|
||||
});
|
||||
|
||||
it("fails closed for malformed persisted policy", () => {
|
||||
expect(evaluateGroupSchedule([
|
||||
{ startMinuteOfWeek: 100, endMinuteOfWeek: 200 },
|
||||
{ startMinuteOfWeek: 150, endMinuteOfWeek: 250 },
|
||||
], new Date("2026-08-03T02:30:00Z"))).toEqual({ allowed: false, nextWindow: null });
|
||||
});
|
||||
|
||||
it("rejects malformed and overlapping submitted windows", () => {
|
||||
const valid = new FormData();
|
||||
valid.append("startMinuteOfWeek", "6960");
|
||||
valid.append("endMinuteOfWeek", "7199");
|
||||
valid.append("startMinuteOfWeek", "480");
|
||||
valid.append("endMinuteOfWeek", "540");
|
||||
expect(parseScheduleWindows(valid)).toEqual([
|
||||
{ startMinuteOfWeek: 480, endMinuteOfWeek: 540 },
|
||||
fridayEvening,
|
||||
]);
|
||||
|
||||
const overlapping = new FormData();
|
||||
overlapping.append("startMinuteOfWeek", "100");
|
||||
overlapping.append("endMinuteOfWeek", "200");
|
||||
overlapping.append("startMinuteOfWeek", "150");
|
||||
overlapping.append("endMinuteOfWeek", "250");
|
||||
expect(parseScheduleWindows(overlapping)).toBeNull();
|
||||
|
||||
const wrappingOverlap = new FormData();
|
||||
wrappingOverlap.append("startMinuteOfWeek", String(6 * 1440 + 23 * 60));
|
||||
wrappingOverlap.append("endMinuteOfWeek", String(2 * 60));
|
||||
wrappingOverlap.append("startMinuteOfWeek", String(60));
|
||||
wrappingOverlap.append("endMinuteOfWeek", String(3 * 60));
|
||||
expect(parseScheduleWindows(wrappingOverlap)).toBeNull();
|
||||
|
||||
const mismatched = new FormData();
|
||||
mismatched.append("startMinuteOfWeek", "100");
|
||||
expect(parseScheduleWindows(mismatched)).toBeNull();
|
||||
|
||||
const invalid = new FormData();
|
||||
invalid.append("startMinuteOfWeek", "10080");
|
||||
invalid.append("endMinuteOfWeek", "0");
|
||||
expect(parseScheduleWindows(invalid)).toBeNull();
|
||||
|
||||
for (const malformedValue of ["", " ", "+1", "0x10", "1e2", "1.5"]) {
|
||||
const malformed = new FormData();
|
||||
malformed.append("startMinuteOfWeek", malformedValue);
|
||||
malformed.append("endMinuteOfWeek", "2");
|
||||
expect(parseScheduleWindows(malformed)).toBeNull();
|
||||
}
|
||||
|
||||
const tooMany = new FormData();
|
||||
for (let index = 0; index < 51; index += 1) {
|
||||
tooMany.append("startMinuteOfWeek", String(index * 2));
|
||||
tooMany.append("endMinuteOfWeek", String(index * 2 + 1));
|
||||
}
|
||||
expect(parseScheduleWindows(tooMany)).toBeNull();
|
||||
});
|
||||
|
||||
it("converts browser-local weekly values to authoritative UTC and back", () => {
|
||||
const local = { startMinuteOfWeek: 4 * 1440 + 20 * 60, endMinuteOfWeek: 4 * 1440 + 23 * 60 };
|
||||
const utc = localWindowToUtc(local, 420);
|
||||
expect(utc).toEqual({
|
||||
startMinuteOfWeek: 5 * 1440 + 3 * 60,
|
||||
endMinuteOfWeek: 5 * 1440 + 6 * 60,
|
||||
});
|
||||
expect(utcWindowToLocal(utc, 420)).toEqual(local);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
export const MINUTES_PER_WEEK = 7 * 24 * 60;
|
||||
const MAX_WINDOWS = 50;
|
||||
|
||||
export function groupScheduleStatus(windowCount: number) {
|
||||
if (windowCount <= 0) return "Unrestricted";
|
||||
return `${windowCount} ${windowCount === 1 ? "window" : "windows"}`;
|
||||
}
|
||||
|
||||
export interface WeeklyAccessWindow {
|
||||
startMinuteOfWeek: number;
|
||||
endMinuteOfWeek: number;
|
||||
}
|
||||
|
||||
interface ScheduleDecision {
|
||||
allowed: boolean;
|
||||
nextWindow: { start: Date; end: Date } | null;
|
||||
}
|
||||
|
||||
function normalizedMinute(value: number) {
|
||||
return ((value % MINUTES_PER_WEEK) + MINUTES_PER_WEEK) % MINUTES_PER_WEEK;
|
||||
}
|
||||
|
||||
function validWindow(window: WeeklyAccessWindow) {
|
||||
return Number.isInteger(window.startMinuteOfWeek)
|
||||
&& Number.isInteger(window.endMinuteOfWeek)
|
||||
&& window.startMinuteOfWeek >= 0
|
||||
&& window.startMinuteOfWeek < MINUTES_PER_WEEK
|
||||
&& window.endMinuteOfWeek >= 0
|
||||
&& window.endMinuteOfWeek < MINUTES_PER_WEEK
|
||||
&& window.startMinuteOfWeek !== window.endMinuteOfWeek;
|
||||
}
|
||||
|
||||
function segments(window: WeeklyAccessWindow) {
|
||||
return window.endMinuteOfWeek > window.startMinuteOfWeek
|
||||
? [[window.startMinuteOfWeek, window.endMinuteOfWeek] as const]
|
||||
: [
|
||||
[window.startMinuteOfWeek, MINUTES_PER_WEEK] as const,
|
||||
[0, window.endMinuteOfWeek] as const,
|
||||
];
|
||||
}
|
||||
|
||||
export function validateScheduleWindows(windows: WeeklyAccessWindow[]) {
|
||||
if (windows.length > MAX_WINDOWS || windows.some((window) => !validWindow(window))) return null;
|
||||
for (let left = 0; left < windows.length; left += 1) {
|
||||
for (let right = left + 1; right < windows.length; right += 1) {
|
||||
const overlaps = segments(windows[left]!).some(([leftStart, leftEnd]) =>
|
||||
segments(windows[right]!).some(([rightStart, rightEnd]) =>
|
||||
leftStart < rightEnd && rightStart < leftEnd));
|
||||
if (overlaps) return null;
|
||||
}
|
||||
}
|
||||
return [...windows].sort((left, right) => left.startMinuteOfWeek - right.startMinuteOfWeek);
|
||||
}
|
||||
|
||||
export function parseScheduleWindows(formData: FormData) {
|
||||
const starts = formData.getAll("startMinuteOfWeek").map(String);
|
||||
const ends = formData.getAll("endMinuteOfWeek").map(String);
|
||||
if (starts.length !== ends.length) return null;
|
||||
const decimalInteger = /^(0|[1-9]\d*)$/;
|
||||
if (starts.some((value) => !decimalInteger.test(value)) || ends.some((value) => !decimalInteger.test(value))) {
|
||||
return null;
|
||||
}
|
||||
return validateScheduleWindows(starts.map((start, index) => ({
|
||||
startMinuteOfWeek: Number(start),
|
||||
endMinuteOfWeek: Number(ends[index]),
|
||||
})));
|
||||
}
|
||||
|
||||
function utcWeekStart(now: Date) {
|
||||
const dayFromMonday = (now.getUTCDay() + 6) % 7;
|
||||
return Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - dayFromMonday);
|
||||
}
|
||||
|
||||
function windowDuration(window: WeeklyAccessWindow) {
|
||||
return normalizedMinute(window.endMinuteOfWeek - window.startMinuteOfWeek);
|
||||
}
|
||||
|
||||
export function evaluateGroupSchedule(windows: WeeklyAccessWindow[], now: Date): ScheduleDecision {
|
||||
if (!windows.length) return { allowed: true, nextWindow: null };
|
||||
const valid = validateScheduleWindows(windows);
|
||||
if (!valid || !Number.isFinite(now.getTime())) return { allowed: false, nextWindow: null };
|
||||
|
||||
const weekStart = utcWeekStart(now);
|
||||
const occurrences = valid.flatMap((window) => [-1, 0, 1].map((weekOffset) => {
|
||||
const start = new Date(weekStart + (weekOffset * MINUTES_PER_WEEK + window.startMinuteOfWeek) * 60_000);
|
||||
const end = new Date(start.getTime() + windowDuration(window) * 60_000);
|
||||
return { start, end };
|
||||
}));
|
||||
if (occurrences.some(({ start, end }) => now >= start && now < end)) {
|
||||
return { allowed: true, nextWindow: null };
|
||||
}
|
||||
const nextWindow = occurrences
|
||||
.filter(({ start }) => start > now)
|
||||
.sort((left, right) => left.start.getTime() - right.start.getTime())[0] ?? null;
|
||||
return { allowed: false, nextWindow };
|
||||
}
|
||||
|
||||
export function localWindowToUtc(window: WeeklyAccessWindow, browserOffsetMinutes: number) {
|
||||
return {
|
||||
startMinuteOfWeek: normalizedMinute(window.startMinuteOfWeek + browserOffsetMinutes),
|
||||
endMinuteOfWeek: normalizedMinute(window.endMinuteOfWeek + browserOffsetMinutes),
|
||||
};
|
||||
}
|
||||
|
||||
export function utcWindowToLocal(window: WeeklyAccessWindow, browserOffsetMinutes: number) {
|
||||
return {
|
||||
startMinuteOfWeek: normalizedMinute(window.startMinuteOfWeek - browserOffsetMinutes),
|
||||
endMinuteOfWeek: normalizedMinute(window.endMinuteOfWeek - browserOffsetMinutes),
|
||||
};
|
||||
}
|
||||
|
||||
const WEEKDAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"] as const;
|
||||
|
||||
export function formatWeeklyMinute(minuteOfWeek: number) {
|
||||
const minute = normalizedMinute(minuteOfWeek);
|
||||
const day = WEEKDAYS[Math.floor(minute / (24 * 60))];
|
||||
const hour = Math.floor((minute % (24 * 60)) / 60);
|
||||
const minuteOfHour = minute % 60;
|
||||
return `${day} ${String(hour).padStart(2, "0")}:${String(minuteOfHour).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
export function formatScheduleInstant(value: Date) {
|
||||
return `${value.toISOString().slice(0, 16).replace("T", " ")} UTC`;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { ipIntelligence } from "@minecraft-account-manager/database";
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
import { headers } from "next/headers";
|
||||
import { db } from "@/lib/database";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const classifications = new Set<IpClassification>([
|
||||
"unknown",
|
||||
@@ -77,6 +78,13 @@ export async function getIpIntelligence(
|
||||
options: { now?: Date; forceRefresh?: boolean } = {},
|
||||
): Promise<IpIntelligenceResult> {
|
||||
if (!isPublicIp(ipAddress)) {
|
||||
logger.warn(
|
||||
{
|
||||
event: "ip_intelligence.skipped",
|
||||
reason: "non_public_address",
|
||||
},
|
||||
"IP intelligence lookup skipped for a non-public client address",
|
||||
);
|
||||
return { classification: "unknown", provider: null };
|
||||
}
|
||||
|
||||
@@ -96,14 +104,23 @@ export async function getIpIntelligence(
|
||||
const result = await provider.classify(ipAddress);
|
||||
await cacheResult(ipAddress, result, now, cacheHours() * 60 * 60_000);
|
||||
return result;
|
||||
} catch {
|
||||
} catch (error) {
|
||||
const providerName = process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() || null;
|
||||
const result: IpIntelligenceResult = {
|
||||
classification: "unknown",
|
||||
provider: process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() || null,
|
||||
provider: providerName,
|
||||
lookupError: true,
|
||||
};
|
||||
await cacheResult(ipAddress, result, now, 5 * 60_000).catch(() => undefined);
|
||||
console.error("IP intelligence lookup failed");
|
||||
await cacheResult(ipAddress, result, now, 5 * 60_000).catch((cacheError) => {
|
||||
logger.error(
|
||||
{ err: cacheError, event: "ip_intelligence.cache_failed", provider: providerName },
|
||||
"Failed to cache an IP intelligence lookup error",
|
||||
);
|
||||
});
|
||||
logger.error(
|
||||
{ err: error, event: "ip_intelligence.lookup_failed", provider: providerName },
|
||||
"IP intelligence lookup failed",
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -134,6 +151,15 @@ export async function checkAccountAdditionNetwork() {
|
||||
const requestHeaders = await headers();
|
||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||
if (!ipAddress) {
|
||||
logger.warn(
|
||||
{
|
||||
event: "client_ip.unavailable",
|
||||
trustProxy: process.env.TRUST_PROXY === "true",
|
||||
forwardedForPresent: requestHeaders.has("x-forwarded-for"),
|
||||
realIpPresent: requestHeaders.has("x-real-ip"),
|
||||
},
|
||||
"Client IP address was unavailable for account addition",
|
||||
);
|
||||
return {
|
||||
allowed: false as const,
|
||||
reason: "unavailable" as const,
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { createLogger } from "@minecraft-account-manager/logging";
|
||||
|
||||
export const logger = createLogger("minecraft-account-manager-web");
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildRconCommandHistory, normalizeRconHistoryFilters } from "./rcon-command-history";
|
||||
|
||||
const correlationId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
function event(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: "22222222-2222-4222-8222-222222222222",
|
||||
time: new Date("2026-08-14T01:00:00Z"),
|
||||
correlationId,
|
||||
data: {
|
||||
command: "say hello operators",
|
||||
serverId: "33333333-3333-4333-8333-333333333333",
|
||||
name: "Season 4",
|
||||
adminEmail: "admin@example.test",
|
||||
adminName: "Admin",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RCON command history", () => {
|
||||
it("normalizes bounded search filters and accepts only known servers", () => {
|
||||
expect(normalizeRconHistoryFilters({
|
||||
command: [" say hello ", "ignored"],
|
||||
admin: " admin@example.test ",
|
||||
server: "33333333-3333-4333-8333-333333333333",
|
||||
}, ["33333333-3333-4333-8333-333333333333"])).toEqual({
|
||||
command: "say hello",
|
||||
admin: "admin@example.test",
|
||||
serverId: "33333333-3333-4333-8333-333333333333",
|
||||
});
|
||||
|
||||
expect(normalizeRconHistoryFilters({ server: "unknown" }, [])).toEqual({
|
||||
command: "",
|
||||
admin: "",
|
||||
serverId: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("pairs requested commands with their completion outcome without exposing responses", () => {
|
||||
const requested = event();
|
||||
const completed = event({
|
||||
id: "44444444-4444-4444-8444-444444444444",
|
||||
data: { success: false, reason: "timeout", durationMs: 5001 },
|
||||
});
|
||||
|
||||
expect(buildRconCommandHistory([requested], [completed])).toEqual([{
|
||||
eventId: requested.id,
|
||||
time: requested.time,
|
||||
command: "say hello operators",
|
||||
serverId: "33333333-3333-4333-8333-333333333333",
|
||||
serverName: "Season 4",
|
||||
adminEmail: "admin@example.test",
|
||||
adminName: "Admin",
|
||||
status: "failed",
|
||||
reason: "timeout",
|
||||
durationMs: 5001,
|
||||
}]);
|
||||
expect(JSON.stringify(buildRconCommandHistory([requested], [completed]))).not.toContain("response");
|
||||
});
|
||||
|
||||
it("marks a requested command pending when no completion event exists", () => {
|
||||
expect(buildRconCommandHistory([event()], [event({ correlationId: null })])[0]?.status).toBe("pending");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
export const RCON_COMMAND_REQUESTED = "games.minecraft.account-manager.rcon.command.requested";
|
||||
export const RCON_COMMAND_COMPLETED = "games.minecraft.account-manager.rcon.command.completed";
|
||||
|
||||
export type RconHistoryEvent = {
|
||||
id: string;
|
||||
time: Date;
|
||||
correlationId: string | null;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type RconCommandHistoryRow = {
|
||||
eventId: string;
|
||||
time: Date;
|
||||
command: string;
|
||||
serverId: string;
|
||||
serverName: string;
|
||||
adminEmail: string | null;
|
||||
adminName: string | null;
|
||||
status: "pending" | "succeeded" | "failed";
|
||||
reason: string | null;
|
||||
durationMs: number | null;
|
||||
};
|
||||
|
||||
type SearchParams = Record<string, string | string[] | undefined>;
|
||||
|
||||
function first(value: string | string[] | undefined) {
|
||||
return (Array.isArray(value) ? value[0] : value)?.trim() ?? "";
|
||||
}
|
||||
|
||||
function text(data: Record<string, unknown>, key: string) {
|
||||
const value = data[key];
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
export function normalizeRconHistoryFilters(query: SearchParams, availableServerIds: string[]) {
|
||||
const requestedServerId = first(query.server);
|
||||
return {
|
||||
command: first(query.command).slice(0, 1024),
|
||||
admin: first(query.admin).slice(0, 320),
|
||||
serverId: availableServerIds.includes(requestedServerId) ? requestedServerId : "",
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRconCommandHistory(
|
||||
requestedEvents: RconHistoryEvent[],
|
||||
completedEvents: RconHistoryEvent[],
|
||||
): RconCommandHistoryRow[] {
|
||||
const completions = new Map(completedEvents
|
||||
.filter((event) => event.correlationId)
|
||||
.map((event) => [event.correlationId, event]));
|
||||
|
||||
return requestedEvents.flatMap((event) => {
|
||||
const command = text(event.data, "command");
|
||||
const serverId = text(event.data, "serverId");
|
||||
const serverName = text(event.data, "name");
|
||||
if (!command || !serverId || !serverName) return [];
|
||||
|
||||
const completed = event.correlationId ? completions.get(event.correlationId) : undefined;
|
||||
const success = completed?.data.success;
|
||||
const duration = completed?.data.durationMs;
|
||||
return [{
|
||||
eventId: event.id,
|
||||
time: event.time,
|
||||
command,
|
||||
serverId,
|
||||
serverName,
|
||||
adminEmail: text(event.data, "adminEmail"),
|
||||
adminName: text(event.data, "adminName"),
|
||||
status: success === true ? "succeeded" as const : success === false ? "failed" as const : "pending" as const,
|
||||
reason: completed ? text(completed.data, "reason") : null,
|
||||
durationMs: typeof duration === "number" && Number.isFinite(duration) ? duration : null,
|
||||
}];
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "./rcon-credentials";
|
||||
|
||||
const key = randomBytes(32).toString("base64");
|
||||
const otherKey = randomBytes(32).toString("base64");
|
||||
const connectionId = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
describe("RCON credential encryption", () => {
|
||||
it("round trips with randomized authenticated encryption", () => {
|
||||
const first = encryptRconPassword("super-secret", connectionId, key);
|
||||
const second = encryptRconPassword("super-secret", connectionId, key);
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(first).not.toContain("super-secret");
|
||||
expect(decryptRconPassword(first, connectionId, key)).toBe("super-secret");
|
||||
expect(decryptRconPassword(second, connectionId, key)).toBe("super-secret");
|
||||
});
|
||||
|
||||
it("fails closed for tampering, another connection, or another key", () => {
|
||||
const encrypted = encryptRconPassword("super-secret", connectionId, key);
|
||||
expect(() => decryptRconPassword(`${encrypted}x`, connectionId, key)).toThrow("RCON credential unavailable");
|
||||
expect(() => decryptRconPassword(encrypted, "22222222-2222-4222-8222-222222222222", key)).toThrow("RCON credential unavailable");
|
||||
expect(() => decryptRconPassword(encrypted, connectionId, otherKey)).toThrow("RCON credential unavailable");
|
||||
});
|
||||
|
||||
it("requires an exact 32-byte deployment key", () => {
|
||||
expect(() => encryptRconPassword("secret", connectionId, "not-base64")).toThrow("RCON credential key is not configured");
|
||||
});
|
||||
|
||||
it("creates a keyed, versioned command digest", () => {
|
||||
const digest = rconCommandDigest("say secret message", key);
|
||||
expect(digest).toMatch(/^hmac-sha256:v1:[a-f0-9]{64}$/u);
|
||||
expect(digest).not.toContain("secret message");
|
||||
expect(rconCommandDigest("say secret message", key)).toBe(digest);
|
||||
expect(rconCommandDigest("say secret message", otherKey)).not.toBe(digest);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
|
||||
|
||||
const VERSION = "v1";
|
||||
const KEY_BYTES = 32;
|
||||
const IV_BYTES = 12;
|
||||
|
||||
function explicitKey(encoded: string) {
|
||||
const key = Buffer.from(encoded, "base64");
|
||||
if (key.length !== KEY_BYTES || key.toString("base64").replace(/=+$/u, "") !== encoded.trim().replace(/=+$/u, "")) {
|
||||
throw new Error("invalid key");
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function credentialKey(encoded: string | undefined, purpose: "credential" | "audit" = "credential") {
|
||||
if (encoded !== undefined) return explicitKey(encoded);
|
||||
const configured = purpose === "credential" ? process.env.RCON_CREDENTIAL_KEY : process.env.RCON_AUDIT_KEY;
|
||||
if (configured) return explicitKey(configured);
|
||||
const authSecret = process.env.AUTH_SECRET;
|
||||
if (!authSecret) throw new Error("missing key");
|
||||
return createHash("sha256").update(`minecraft-account-manager:rcon:${purpose}:v1\0${authSecret}`, "utf8").digest();
|
||||
}
|
||||
|
||||
function additionalData(connectionId: string) {
|
||||
return Buffer.from(`${VERSION}:${connectionId}`, "utf8");
|
||||
}
|
||||
|
||||
function decodeBase64url(value: string) {
|
||||
const decoded = Buffer.from(value, "base64url");
|
||||
if (decoded.toString("base64url") !== value) throw new Error("invalid envelope");
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export function encryptRconPassword(password: string, connectionId: string, encodedKey?: string) {
|
||||
let key: Buffer;
|
||||
try {
|
||||
key = credentialKey(encodedKey);
|
||||
} catch {
|
||||
throw new Error("RCON credential key is not configured");
|
||||
}
|
||||
const iv = randomBytes(IV_BYTES);
|
||||
const cipher = createCipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
|
||||
cipher.setAAD(additionalData(connectionId));
|
||||
const ciphertext = Buffer.concat([cipher.update(password, "utf8"), cipher.final()]);
|
||||
return [VERSION, iv.toString("base64url"), cipher.getAuthTag().toString("base64url"), ciphertext.toString("base64url")].join(":");
|
||||
}
|
||||
|
||||
export function rconCommandDigest(command: string, encodedKey?: string) {
|
||||
let key: Buffer;
|
||||
try {
|
||||
key = credentialKey(encodedKey, "audit");
|
||||
} catch {
|
||||
throw new Error("RCON audit key is not configured");
|
||||
}
|
||||
return `hmac-sha256:v1:${createHmac("sha256", key).update(command, "utf8").digest("hex")}`;
|
||||
}
|
||||
|
||||
export function decryptRconPassword(envelope: string, connectionId: string, encodedKey?: string) {
|
||||
try {
|
||||
const key = credentialKey(encodedKey);
|
||||
const [version, ivValue, tagValue, ciphertextValue, extra] = envelope.split(":");
|
||||
if (version !== VERSION || !ivValue || !tagValue || !ciphertextValue || extra) throw new Error("invalid envelope");
|
||||
const iv = decodeBase64url(ivValue);
|
||||
const tag = decodeBase64url(tagValue);
|
||||
const ciphertext = decodeBase64url(ciphertextValue);
|
||||
if (iv.length !== IV_BYTES || tag.length !== 16) throw new Error("invalid envelope");
|
||||
const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
|
||||
decipher.setAAD(additionalData(connectionId));
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||
} catch {
|
||||
throw new Error("RCON credential unavailable");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { executeRcon, testRconConnection, type RconTransport } from "./rcon-gateway";
|
||||
|
||||
function transport(overrides: Partial<RconTransport> = {}): RconTransport {
|
||||
return {
|
||||
connect: vi.fn().mockResolvedValue(undefined),
|
||||
send: vi.fn().mockResolvedValue("20 players online"),
|
||||
end: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("RCON gateway", () => {
|
||||
it("authenticates a connection without sending a command", async () => {
|
||||
const client = transport();
|
||||
await expect(testRconConnection({ host: "season4", port: 25575, password: "secret" }, () => client)).resolves.toEqual({ ok: true });
|
||||
expect(client.connect).toHaveBeenCalledOnce();
|
||||
expect(client.send).not.toHaveBeenCalled();
|
||||
expect(client.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("executes one command and always closes the connection", async () => {
|
||||
const client = transport();
|
||||
await expect(executeRcon({ host: "season4", port: 25575, password: "secret" }, "list", () => client)).resolves.toEqual({
|
||||
ok: true,
|
||||
response: "20 players online",
|
||||
});
|
||||
expect(client.send).toHaveBeenCalledWith("list");
|
||||
expect(client.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns safe categorized failures and closes failed clients", async () => {
|
||||
const client = transport({ connect: vi.fn().mockRejectedValue(new Error("password secret rejected")) });
|
||||
await expect(testRconConnection({ host: "season4", port: 25575, password: "secret" }, () => client)).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "unavailable",
|
||||
});
|
||||
expect(client.end).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects concurrent work for the same connection", async () => {
|
||||
let release!: () => void;
|
||||
const pending = new Promise<string>((resolve) => { release = () => resolve("done"); });
|
||||
const firstClient = transport({ send: vi.fn().mockReturnValue(pending) });
|
||||
const first = executeRcon({ id: "server-one", host: "season4", port: 25575, password: "secret" }, "list", () => firstClient);
|
||||
await vi.waitFor(() => expect(firstClient.send).toHaveBeenCalled());
|
||||
|
||||
await expect(executeRcon({ id: "server-one", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "busy",
|
||||
});
|
||||
release();
|
||||
await first;
|
||||
});
|
||||
|
||||
it("bounds total concurrent work", async () => {
|
||||
let release!: () => void;
|
||||
const pendingResponse = new Promise<string>((resolve) => { release = () => resolve("done"); });
|
||||
const clients = Array.from({ length: 8 }, () => transport({ send: vi.fn().mockReturnValue(pendingResponse) }));
|
||||
const active = clients.map((client, index) => executeRcon({
|
||||
id: `server-${index}`,
|
||||
host: `season-${index}`,
|
||||
port: 25575,
|
||||
password: "secret",
|
||||
}, "list", () => client));
|
||||
await vi.waitFor(() => expect(clients.every((client) => vi.mocked(client.send).mock.calls.length === 1)).toBe(true));
|
||||
|
||||
await expect(executeRcon({ id: "server-ninth", host: "season-9", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "busy",
|
||||
});
|
||||
release();
|
||||
await Promise.all(active);
|
||||
});
|
||||
|
||||
it("times out the complete operation, aborts the socket, and releases the connection", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = transport({
|
||||
send: vi.fn().mockReturnValue(new Promise(() => undefined)),
|
||||
destroy: vi.fn(),
|
||||
});
|
||||
const pending = executeRcon({ id: "server-timeout", host: "season4", port: 25575, password: "secret" }, "list", () => client);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await expect(pending).resolves.toEqual({ ok: false, reason: "timeout" });
|
||||
expect(client.destroy).toHaveBeenCalledOnce();
|
||||
|
||||
await expect(executeRcon({ id: "server-timeout", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||
ok: true,
|
||||
response: "20 players online",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not let stalled cleanup retain a connection lock", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const client = transport({ end: vi.fn().mockReturnValue(new Promise(() => undefined)) });
|
||||
const pending = executeRcon({ id: "server-cleanup", host: "season4", port: 25575, password: "secret" }, "list", () => client);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await expect(pending).resolves.toEqual({ ok: true, response: "20 players online" });
|
||||
|
||||
await expect(executeRcon({ id: "server-cleanup", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||
ok: true,
|
||||
response: "20 players online",
|
||||
});
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Rcon } from "rcon-client";
|
||||
import { sanitizeRconOutput } from "./rcon-validation";
|
||||
|
||||
const TIMEOUT_MS = 5_000;
|
||||
const CLEANUP_TIMEOUT_MS = 1_000;
|
||||
const MAX_ACTIVE_CONNECTIONS = 8;
|
||||
const activeConnections = new Set<string>();
|
||||
|
||||
type Connection = { id?: string; host: string; port: number; password: string };
|
||||
type FailureReason = "busy" | "timeout" | "unavailable";
|
||||
|
||||
export interface RconTransport {
|
||||
connect(): Promise<unknown>;
|
||||
send(command: string): Promise<string>;
|
||||
end(): Promise<unknown>;
|
||||
destroy?(): void;
|
||||
}
|
||||
|
||||
type TransportFactory = (connection: Connection) => RconTransport;
|
||||
|
||||
class RconDeadlineError extends Error {}
|
||||
|
||||
async function deadline<T>(operation: Promise<T>, timeout: () => void, timeoutMs = TIMEOUT_MS) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
timeout();
|
||||
reject(new RconDeadlineError("RCON operation timed out"));
|
||||
}, timeoutMs);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function defaultTransport(connection: Connection): RconTransport {
|
||||
const client = new Rcon({
|
||||
host: connection.host,
|
||||
port: connection.port,
|
||||
password: connection.password,
|
||||
timeout: TIMEOUT_MS,
|
||||
maxPending: 1,
|
||||
});
|
||||
return {
|
||||
connect: () => client.connect(),
|
||||
send: (command) => client.send(command),
|
||||
end: async () => {
|
||||
if (!client.socket) return;
|
||||
if (client.socket.connecting || !client.socket.writable) {
|
||||
client.socket.destroy();
|
||||
return;
|
||||
}
|
||||
await client.end();
|
||||
},
|
||||
destroy: () => client.socket?.destroy(),
|
||||
};
|
||||
}
|
||||
|
||||
function failure(error: unknown): { ok: false; reason: FailureReason } {
|
||||
return { ok: false, reason: error instanceof RconDeadlineError ? "timeout" : "unavailable" };
|
||||
}
|
||||
|
||||
async function withTransport<T>(
|
||||
connection: Connection,
|
||||
operation: (transport: RconTransport) => Promise<T>,
|
||||
factory: TransportFactory,
|
||||
): Promise<T | { ok: false; reason: FailureReason }> {
|
||||
const key = connection.id ?? `${connection.host}:${connection.port}`;
|
||||
if (activeConnections.has(key) || activeConnections.size >= MAX_ACTIVE_CONNECTIONS) {
|
||||
return { ok: false, reason: "busy" };
|
||||
}
|
||||
activeConnections.add(key);
|
||||
let transport: RconTransport | null = null;
|
||||
try {
|
||||
transport = factory(connection);
|
||||
return await deadline(operation(transport), () => transport?.destroy?.());
|
||||
} catch (error) {
|
||||
return failure(error);
|
||||
} finally {
|
||||
if (transport) {
|
||||
await deadline(transport.end(), () => transport?.destroy?.(), CLEANUP_TIMEOUT_MS).catch(() => undefined);
|
||||
}
|
||||
activeConnections.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
export async function testRconConnection(connection: Connection, factory: TransportFactory = defaultTransport) {
|
||||
return withTransport(connection, async (transport) => {
|
||||
await transport.connect();
|
||||
return { ok: true as const };
|
||||
}, factory);
|
||||
}
|
||||
|
||||
export async function executeRcon(connection: Connection, command: string, factory: TransportFactory = defaultTransport) {
|
||||
return withTransport(connection, async (transport) => {
|
||||
await transport.connect();
|
||||
const response = await transport.send(command);
|
||||
return { ok: true as const, response: sanitizeRconOutput(response) };
|
||||
}, factory);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { sanitizeRconOutput, validateRconCommand, validateRconConnection } from "./rcon-validation";
|
||||
|
||||
describe("RCON validation", () => {
|
||||
it("normalizes any valid DNS hostname and port without deployment configuration", () => {
|
||||
expect(validateRconConnection({
|
||||
name: " Season 4 ",
|
||||
host: "SEASON4.SOMC.SVC.CLUSTER.LOCAL",
|
||||
port: "25575",
|
||||
password: "correct horse battery staple",
|
||||
}, { passwordRequired: true })).toEqual({
|
||||
name: "Season 4",
|
||||
host: "season4.somc.svc.cluster.local",
|
||||
port: 25575,
|
||||
password: "correct horse battery staple",
|
||||
});
|
||||
|
||||
expect(validateRconConnection({
|
||||
name: "Creative",
|
||||
host: "creative.example.net",
|
||||
port: "43210",
|
||||
password: "secret",
|
||||
}, { passwordRequired: true })).toEqual({
|
||||
name: "Creative",
|
||||
host: "creative.example.net",
|
||||
port: 43210,
|
||||
password: "secret",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects IP literals and malformed DNS hostnames", () => {
|
||||
for (const host of ["10.0.0.1", "2001:db8::1", "season4.", "-season4.example", "season4..example"]) {
|
||||
expect(validateRconConnection({ name: "Server", host, port: "25575", password: "secret" }, {
|
||||
passwordRequired: true,
|
||||
})).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("allows a blank replacement password only while editing", () => {
|
||||
expect(validateRconConnection({ name: "Server", host: "season4.somc.svc.cluster.local", port: "25575", password: "" }, {
|
||||
passwordRequired: false,
|
||||
})?.password).toBeNull();
|
||||
expect(validateRconConnection({ name: "Server", host: "season4.somc.svc.cluster.local", port: "25575", password: "" }, {
|
||||
passwordRequired: true,
|
||||
})).toBeNull();
|
||||
});
|
||||
|
||||
it("bounds commands by UTF-8 bytes and rejects control characters", () => {
|
||||
expect(validateRconCommand(" list ")).toBe("list");
|
||||
expect(validateRconCommand("say first\nsay second")).toBeNull();
|
||||
expect(validateRconCommand("say \u001b[31mred")).toBeNull();
|
||||
expect(validateRconCommand(`say ${"😀".repeat(300)}`)).toBeNull();
|
||||
});
|
||||
|
||||
it("strips output controls and bounds output by UTF-8 bytes", () => {
|
||||
expect(sanitizeRconOutput("ok\u001b[31mred\u0000done")).toBe("ok[31mreddone");
|
||||
expect(Buffer.byteLength(sanitizeRconOutput("😀".repeat(20_000)), "utf8")).toBeLessThanOrEqual(65_536);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
const HOST_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u;
|
||||
const MAX_COMMAND_BYTES = 1_024;
|
||||
const MAX_OUTPUT_BYTES = 65_536;
|
||||
|
||||
export type ValidRconConnection = {
|
||||
name: string;
|
||||
host: string;
|
||||
port: number;
|
||||
password: string | null;
|
||||
};
|
||||
|
||||
export function validateRconConnection(
|
||||
input: { name: unknown; host: unknown; port: unknown; password: unknown },
|
||||
options: { passwordRequired: boolean },
|
||||
): ValidRconConnection | null {
|
||||
const name = typeof input.name === "string" ? input.name.trim() : "";
|
||||
const host = typeof input.host === "string" ? input.host.trim().toLowerCase() : "";
|
||||
const portText = typeof input.port === "string" || typeof input.port === "number" ? String(input.port).trim() : "";
|
||||
const passwordText = typeof input.password === "string" ? input.password : "";
|
||||
const port = Number(portText);
|
||||
|
||||
if (!name || name.length > 100 || CONTROL_PATTERN.test(name)) return null;
|
||||
if (!host || host.endsWith(".") || isIP(host) !== 0 || !HOST_PATTERN.test(host)) return null;
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65_535) return null;
|
||||
if (passwordText.length > 512 || CONTROL_PATTERN.test(passwordText)) return null;
|
||||
if (options.passwordRequired && !passwordText) return null;
|
||||
|
||||
return { name, host, port, password: passwordText || null };
|
||||
}
|
||||
|
||||
export function validateRconCommand(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const command = value.trim();
|
||||
if (!command || CONTROL_PATTERN.test(command) || Buffer.byteLength(command, "utf8") > MAX_COMMAND_BYTES) return null;
|
||||
return command;
|
||||
}
|
||||
|
||||
export function sanitizeRconOutput(value: string) {
|
||||
const safe = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, "");
|
||||
if (Buffer.byteLength(safe, "utf8") <= MAX_OUTPUT_BYTES) return safe;
|
||||
let result = "";
|
||||
let bytes = 0;
|
||||
for (const character of safe) {
|
||||
const size = Buffer.byteLength(character, "utf8");
|
||||
if (bytes + size > MAX_OUTPUT_BYTES) break;
|
||||
result += character;
|
||||
bytes += size;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
MAP_LOCATION_CLASSIFICATIONS,
|
||||
groupMapLocations,
|
||||
parseUserLocation,
|
||||
parseUserNetwork,
|
||||
projectWorldPoint,
|
||||
} from "./user-location-map";
|
||||
|
||||
describe("user location map", () => {
|
||||
it("allows only clear and hosting observations as map locations", () => {
|
||||
expect(MAP_LOCATION_CLASSIFICATIONS).toEqual(["clear", "hosting"]);
|
||||
expect(MAP_LOCATION_CLASSIFICATIONS).not.toContain("vpn");
|
||||
expect(MAP_LOCATION_CLASSIFICATIONS).not.toContain("proxy");
|
||||
expect(MAP_LOCATION_CLASSIFICATIONS).not.toContain("tor");
|
||||
});
|
||||
|
||||
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("extracts enriched network fields from existing ProxyCheck cache entries", () => {
|
||||
expect(parseUserNetwork({
|
||||
network: { asn: "AS7922", provider: "Comcast Cable Communications, LLC" },
|
||||
rawResponse: {
|
||||
status: "ok",
|
||||
"203.0.113.10": { type: "Residential", proxy: "no" },
|
||||
},
|
||||
})).toEqual({
|
||||
asn: "AS7922",
|
||||
provider: "Comcast Cable Communications, LLC",
|
||||
connectionType: "Residential",
|
||||
proxy: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers normalized network fields and preserves unavailable values", () => {
|
||||
expect(parseUserNetwork({
|
||||
network: { asn: "AS62371", provider: "Proton AG", connectionType: "VPN", proxy: true },
|
||||
rawResponse: { "198.51.100.5": { type: "Residential", proxy: "no" } },
|
||||
})).toEqual({ asn: "AS62371", provider: "Proton AG", connectionType: "VPN", proxy: true });
|
||||
expect(parseUserNetwork({ network: {} })).toEqual({ asn: null, provider: null, connectionType: null, proxy: null });
|
||||
});
|
||||
|
||||
it("rejects missing and out-of-range coordinates", () => {
|
||||
expect(parseUserLocation({ location: { latitude: 91, longitude: 0 } })).toBeNull();
|
||||
expect(parseUserLocation({ location: { city: "Unknown" } })).toBeNull();
|
||||
});
|
||||
|
||||
it("groups users sharing approximate coordinates without hiding their identities", () => {
|
||||
const groups = groupMapLocations([
|
||||
{ userId: "one", nickname: "Dani (Steve)", latitude: 37.4056, longitude: -122.0775 },
|
||||
{ userId: "two", nickname: "Alex (AlexMC)", latitude: 37.4057, longitude: -122.0774 },
|
||||
{ userId: "three", nickname: "Sam (Notch)", latitude: 51.5, longitude: -0.12 },
|
||||
]);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0]).toMatchObject({ count: 2, nicknames: ["Alex (AlexMC)", "Dani (Steve)"] });
|
||||
expect(groups[0]?.locations.map((location) => location.userId)).toEqual(["one", "two"]);
|
||||
expect(groups[1]).toMatchObject({ count: 1, nicknames: ["Sam (Notch)"] });
|
||||
});
|
||||
|
||||
it("normalizes signed zero and the antimeridian before grouping", () => {
|
||||
const groups = groupMapLocations([
|
||||
{ nickname: "West", latitude: -0.004, longitude: 180 },
|
||||
{ nickname: "East", latitude: 0.004, longitude: -180 },
|
||||
]);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0]).toMatchObject({ count: 2, key: "0:-180", latitude: 0, longitude: -180 });
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
type UnknownMap = Record<string, unknown>;
|
||||
|
||||
export const MAP_LOCATION_CLASSIFICATIONS = ["clear", "hosting"] as const;
|
||||
|
||||
function objectValue(value: unknown): UnknownMap | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as UnknownMap
|
||||
: null;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown) {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function proxyValue(value: unknown) {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string" && value.toLowerCase() === "yes") return true;
|
||||
if (typeof value === "string" && value.toLowerCase() === "no") return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function coordinate(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ParsedUserLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ParsedUserNetwork {
|
||||
asn: string | null;
|
||||
provider: string | null;
|
||||
connectionType: string | null;
|
||||
proxy: boolean | null;
|
||||
}
|
||||
|
||||
export function parseUserNetwork(value: unknown): ParsedUserNetwork {
|
||||
const intelligence = objectValue(value);
|
||||
const network = objectValue(intelligence?.network);
|
||||
const providerResponse = objectValue(intelligence?.rawResponse);
|
||||
const legacyDetails = Object.values(providerResponse ?? {})
|
||||
.map(objectValue)
|
||||
.find((details) => details && ("type" in details || "proxy" in details));
|
||||
return {
|
||||
asn: stringValue(network?.asn),
|
||||
provider: stringValue(network?.provider),
|
||||
connectionType: stringValue(network?.connectionType) ?? stringValue(legacyDetails?.type),
|
||||
proxy: proxyValue(network?.proxy) ?? proxyValue(legacyDetails?.proxy),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseUserLocation(value: unknown): ParsedUserLocation | null {
|
||||
const intelligence = objectValue(value);
|
||||
const location = objectValue(intelligence?.location);
|
||||
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 groupMapLocations<T extends {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
nickname: string;
|
||||
}>(locations: T[]) {
|
||||
const grouped = new Map<string, { latitude: number; longitude: number; locations: T[] }>();
|
||||
for (const location of locations) {
|
||||
const roundedLatitude = Number(location.latitude.toFixed(2));
|
||||
const latitude = roundedLatitude === 0 ? 0 : roundedLatitude;
|
||||
const roundedLongitude = Number(location.longitude.toFixed(2));
|
||||
const longitude = Math.abs(roundedLongitude) === 180 ? -180 : roundedLongitude;
|
||||
const key = `${latitude}:${longitude}`;
|
||||
const group = grouped.get(key);
|
||||
if (group) group.locations.push(location);
|
||||
else grouped.set(key, { latitude, longitude, locations: [location] });
|
||||
}
|
||||
return [...grouped.entries()].map(([key, group]) => ({
|
||||
key,
|
||||
latitude: group.latitude,
|
||||
longitude: group.longitude,
|
||||
count: group.locations.length,
|
||||
nicknames: [...group.locations.map((location) => location.nickname)].sort((left, right) => left.localeCompare(right)),
|
||||
locations: group.locations,
|
||||
}));
|
||||
}
|
||||
|
||||
export function projectWorldPoint(latitude: number, longitude: number, width: number, height: number) {
|
||||
return {
|
||||
x: ((longitude + 180) / 360) * width,
|
||||
y: ((90 - latitude) / 180) * height,
|
||||
};
|
||||
}
|
||||
@@ -30,6 +30,12 @@ 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) - Each user has one effective group that explicitly controls Minecraft access.
|
||||
* [US-018 — Monitor community account activity](us-018-admin-dashboard.md) - Administrators review daily users, confirmed connections, locations, denials, and risky networks.
|
||||
* [US-019 — Manage groups efficiently](us-019-admin-group-management.md) - Administrators manage group identity, policies, membership, and creation through focused confirmed workflows.
|
||||
* [US-020 — Schedule group access in UTC](us-020-scheduled-group-access.md) - Enabled groups may be restricted to recurring weekly UTC windows with static denial-message templates.
|
||||
* [US-021 — Manage RCON server connections](us-021-rcon-connections.md) - Administrators manage encrypted Minecraft RCON server addresses.
|
||||
* [US-022 — Operate servers through an RCON console](us-022-rcon-console.md) - Administrators execute bounded commands through the server-side portal proxy.
|
||||
|
||||
# Tracking
|
||||
|
||||
|
||||
@@ -1,7 +1,48 @@
|
||||
# Design Update Log
|
||||
|
||||
## 2026-08-14
|
||||
|
||||
* **Verify**: Persist complete administrator-attributed RCON commands in correlated requested/completed audit events and add protected history search by command text, server, and administrator without retaining responses or credentials.
|
||||
|
||||
## 2026-08-08
|
||||
|
||||
* **Verify**: Added encrypted, allowlisted administrator RCON connection management with credential-safe audits and a generated Drizzle migration.
|
||||
* **Implement**: Added a bounded server-side RCON command console with safe output and error handling; internal-only deployment verification remains pending.
|
||||
* **Refine**: Removed deployment-managed RCON endpoint allowlisting so administrators may configure any valid DNS hostname and port, while retaining IP-literal rejection and documenting the outbound-connectivity trust boundary.
|
||||
* **Verify**: Confirmed the RCON console uses an authenticated internal ClusterIP deployment with secret-backed credentials and no public RCON exposure.
|
||||
* **Refine**: Renamed RCON host configuration to server addresses, documented internal and external targets, and redesigned the console as a portal-colored terminal with a target bar, command prompt, and latest-response viewport.
|
||||
* **Refine**: Consolidated RCON connection management into a full-width terminal workspace with header controls, modal add/edit/delete flows, terminal-contained notices, and no duplicate configuration panels.
|
||||
* **Extend**: Added bounded page-memory RCON command recall with Arrow Up/Arrow Down navigation, unsent-draft restoration, and prompt focus retention after results and server changes.
|
||||
* **Extend**: Retained up to 50 chronological page-memory RCON command/response exchanges in the auto-scrolling terminal transcript without persisting them.
|
||||
* **Extend**: Added the Docker-build-supplied immutable application version to the dependency-free `/healthz` response, with a `development` fallback.
|
||||
|
||||
## 2026-08-07
|
||||
|
||||
* **Extend**: Show each grouped recent address's latest approximate location and network classification on administrator user records.
|
||||
* **Refine**: Select each admin map marker from the user's latest coordinate-bearing clear or hosting observation while keeping VPN, proxy, and Tor activity in the network-risk view.
|
||||
|
||||
## 2026-08-02
|
||||
|
||||
* **Extend**: Add recurring UTC group-access windows, browser-local schedule editing, and validated static denial-message variables.
|
||||
* **Refine**: Replace admin group cards with a policy table, confirmed modal workflows, editable group details, and reusable effective-member management.
|
||||
* **Add**: Provide Users-page group assignment, effective-group VPN/proxy/Tor exceptions for game admission, and independent configurable denial messages.
|
||||
* **Fix**: Treat malformed ProxyCheck proxy signals as unknown and classify every authenticated Velocity login before identity resolution.
|
||||
* **Fix**: Replace the dashboard's pre-enrichment network label with enriched company, ASN, connection type, Proxy/VPN status, and risk fields.
|
||||
* **Fix**: Group collocated map users into count-badged markers with complete nickname tooltips and per-user interactive-map links.
|
||||
* **Refine**: Replace registration counts with daily active users, collapse enriched VPN activity per user, add opt-in OpenStreetMap zoom, show managed nickname tooltips, and measure active Minecraft accounts from confirmed Velocity connections.
|
||||
* **Governance**: Require user review and explicit confirmation of relevant OKF story changes before future implementation work.
|
||||
|
||||
## 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.
|
||||
* **Fix**: Build magic-link redirects from the configured public portal URL instead of the reverse proxy's internal request origin.
|
||||
* **Verify**: Confirmed `v1.1.1` left all pre-existing `latest` digests unchanged while publishing versioned artifacts.
|
||||
* **Refine**: Removed mutable `latest` publication so all deployable artifacts use explicit semantic versions.
|
||||
* **Verify**: Confirmed the `v1.1.0` Discord bot image and matching web, migration, and Velocity artifacts.
|
||||
* **Extend**: Added a releasable Discord bot image and a dependency-free web health endpoint for Kubernetes deployment.
|
||||
* **Verify**: Confirmed the initial `v1.0.0` release, public Velocity JAR, and versioned and `latest` web and migration image manifests.
|
||||
* **Create**: Added Gitea CI and semantic-release pipelines for downloadable Velocity JARs and versioned web and migration images.
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Enter the account portal through Discord
|
||||
description: Direct visitors are guided to the configured Discord community and its account commands.
|
||||
tags: [player, portal, discord, onboarding]
|
||||
timestamp: 2026-08-01T18:43:58Z
|
||||
timestamp: 2026-08-01T22:34:31Z
|
||||
story_id: US-001
|
||||
status: verified
|
||||
---
|
||||
@@ -18,11 +18,15 @@ As a prospective player, I want the portal to direct me to the community Discord
|
||||
- [x] Given a configured invite URL, when the visitor selects the join action, then the Discord invite opens in a new browser context.
|
||||
- [x] Given a configured guild ID, when the visitor selects the app action, then a `discord://` guild link is opened.
|
||||
- [x] Given an unauthenticated protected-page request, when authorization fails, then the visitor returns to the portal with prominent Discord instructions.
|
||||
- [x] Every portal page credits Social Minecraft sponsorship by DMG Games and links to `https://dmg.games`.
|
||||
- [x] Portal branding uses the SoMC Portal name and a dedicated favicon.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`apps/web/src/app/page.tsx`](../apps/web/src/app/page.tsx)
|
||||
- [`apps/web/src/lib/auth/user-session.ts`](../apps/web/src/lib/auth/user-session.ts)
|
||||
- [`apps/web/src/components/site-footer.tsx`](../apps/web/src/components/site-footer.tsx)
|
||||
- [`apps/web/src/app/icon.svg`](../apps/web/src/app/icon.svg)
|
||||
- Configuration: `DISCORD_GUILD_ID`, `DISCORD_INVITE_URL`
|
||||
|
||||
# Validation
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Authenticate with a Discord magic link
|
||||
description: Discord users receive private single-use links that establish secure portal sessions.
|
||||
tags: [player, discord, authentication, security]
|
||||
timestamp: 2026-08-01T18:43:58Z
|
||||
timestamp: 2026-08-01T20:43:46Z
|
||||
story_id: US-002
|
||||
status: verified
|
||||
---
|
||||
@@ -19,6 +19,7 @@ As a Discord community member, I want `/register` and `/account` to issue a priv
|
||||
- [x] Given a login token, then it expires after ten minutes and can be consumed only once.
|
||||
- [x] Given repeated link requests, then requests are rate limited per Discord user and older active links are invalidated.
|
||||
- [x] Given a valid link, when it is consumed, then the Discord user is created or refreshed and a secure seven-day session is established.
|
||||
- [x] Given a magic-link result behind a reverse proxy, then the browser is redirected through the configured public application URL rather than an internal container address.
|
||||
- [x] Given an invalid, expired, or consumed link, then the user sees a safe recovery page instructing them to request another link.
|
||||
|
||||
# Implementation
|
||||
@@ -27,10 +28,12 @@ As a Discord community member, I want `/register` and `/account` to issue a priv
|
||||
- [`packages/auth/src/index.ts`](../packages/auth/src/index.ts)
|
||||
- [`packages/database/src/auth-repository.ts`](../packages/database/src/auth-repository.ts)
|
||||
- [`apps/web/src/app/auth/discord/route.ts`](../apps/web/src/app/auth/discord/route.ts)
|
||||
- [`apps/web/src/lib/application-url.ts`](../apps/web/src/lib/application-url.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
- [`packages/auth/test/magic-link.test.ts`](../packages/auth/test/magic-link.test.ts)
|
||||
- [`apps/web/src/lib/application-url.test.ts`](../apps/web/src/lib/application-url.test.ts)
|
||||
- Discord command and authentication workspaces pass TypeScript validation.
|
||||
|
||||
# Related Stories
|
||||
|
||||
@@ -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-01T18:43:58Z
|
||||
timestamp: 2026-08-01T23:10:59Z
|
||||
story_id: US-005
|
||||
status: verified
|
||||
---
|
||||
@@ -20,8 +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 show the expected Discord nickname and require confirmation.
|
||||
- [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 the single effective access group and whether it grants Minecraft access.
|
||||
- [x] The user can revoke the current session by signing out.
|
||||
|
||||
# Implementation
|
||||
@@ -32,7 +34,7 @@ As a registered player, I want to manage my profile and linked Minecraft account
|
||||
|
||||
# Validation
|
||||
|
||||
Server actions verify the current session and constrain every account lookup by the authenticated user ID.
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Enrich portal and game login IPs
|
||||
description: Login audit events include cached approximate location and network intelligence from ProxyCheck.io.
|
||||
tags: [security, network, audit, proxycheck]
|
||||
timestamp: 2026-08-01T18:43:58Z
|
||||
timestamp: 2026-08-02T14:12:43Z
|
||||
story_id: US-007
|
||||
status: verified
|
||||
---
|
||||
@@ -19,9 +19,10 @@ As an operator, I want portal and registered game logins enriched with network c
|
||||
- [x] Provider failures are cached briefly and do not deny portal or registered game login.
|
||||
- [x] Private, loopback, reserved, documentation, and mapped-private addresses are never sent to ProxyCheck.
|
||||
- [x] Forwarded web IP headers are ignored unless trusted-proxy handling is explicitly enabled.
|
||||
- [x] Unknown game accounts do not trigger paid ProxyCheck lookups.
|
||||
- [x] Every authenticated Velocity login request uses the cached ProxyCheck path before identity resolution, preventing account-creation races from bypassing network policy.
|
||||
- [x] Login events and IP observations retain the available classification and approximate location.
|
||||
- [x] Users and administrators can see available location and classification in audit views.
|
||||
- [x] Repeated access observations are summarized by IPv4 /24 or IPv6 /64 network with counts, sources, and latest activity.
|
||||
|
||||
# Implementation
|
||||
|
||||
@@ -34,6 +35,8 @@ As an operator, I want portal and registered game logins enriched with network c
|
||||
|
||||
- [`packages/network/test/proxycheck.test.ts`](../packages/network/test/proxycheck.test.ts)
|
||||
- [`packages/network/test/client-ip.test.ts`](../packages/network/test/client-ip.test.ts)
|
||||
- [`packages/network/test/address-groups.test.ts`](../packages/network/test/address-groups.test.ts)
|
||||
- [`apps/web/src/lib/access-address-groups.test.ts`](../apps/web/src/lib/access-address-groups.test.ts)
|
||||
|
||||
# Related Stories
|
||||
|
||||
|
||||
@@ -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-02T14:12:43Z
|
||||
story_id: US-008
|
||||
status: verified
|
||||
---
|
||||
@@ -21,6 +21,9 @@ 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.
|
||||
- [x] Game admission enforces confirmed VPN, proxy, and Tor classifications according to the user's effective-group exception policy.
|
||||
- [x] Account-addition blocking remains unchanged and independent from the game-admission exception.
|
||||
|
||||
# Implementation
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Enforce registration at the Velocity proxy
|
||||
description: Online-mode Java connections are admitted only after a fail-closed account-manager decision.
|
||||
tags: [minecraft, velocity, whitelist, security]
|
||||
timestamp: 2026-08-01T18:52:20Z
|
||||
timestamp: 2026-08-02T14:12:43Z
|
||||
story_id: US-009
|
||||
status: verified
|
||||
---
|
||||
@@ -22,13 +22,23 @@ 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] Unknown players, API failures, malformed responses, and unauthorized requests fail closed with registration guidance.
|
||||
- [x] Registered players are allowed only when their single effective group has access enabled; explicit assignments override the default group.
|
||||
- [x] Disabled group access overrides every schedule; enabled groups with weekly windows admit logins only during an active UTC window.
|
||||
- [x] Schedule policy is checked before VPN/proxy/Tor policy and is enforced only at login.
|
||||
- [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.
|
||||
- [x] Group-disabled and VPN/proxy/Tor-policy denials return distinct operator-configured messages.
|
||||
- [x] Schedule denials return the configured static template with the effective group, player, and next UTC window.
|
||||
- [x] The default anonymized-network message directs the player to contact a host for an exception.
|
||||
- [x] API failures, malformed responses, and unauthorized requests retain fail-closed plugin fallback behavior.
|
||||
|
||||
# Implementation
|
||||
|
||||
- [`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)
|
||||
|
||||
@@ -41,3 +51,4 @@ As a registered player, I want the Velocity proxy to recognize my approved Java
|
||||
|
||||
- [Validate Minecraft accounts](us-004-minecraft-validation.md)
|
||||
- [Standardize API errors](us-014-problem-details.md)
|
||||
- [Control Minecraft admission with groups](us-017-group-access.md)
|
||||
|
||||
@@ -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-14T01:23:35Z
|
||||
story_id: US-010
|
||||
status: verified
|
||||
---
|
||||
@@ -16,10 +16,14 @@ 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] Every sent RCON command is represented in the audit ledger with its complete command text and acting SSO identity.
|
||||
- [x] RCON responses and credentials are never persisted in audit events.
|
||||
- [x] RCON command events are searchable by command text, server, and administrator.
|
||||
- [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
|
||||
@@ -27,13 +31,18 @@ As an operator, I want security and identity activity recorded consistently, so
|
||||
- [`packages/database/src/events.ts`](../packages/database/src/events.ts)
|
||||
- [`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/lib/rcon-command-history.ts`](../apps/web/src/lib/rcon-command-history.ts)
|
||||
- [`apps/web/src/app/admin/(console)/rcon/actions.ts`](../apps/web/src/app/admin/%28console%29/rcon/actions.ts)
|
||||
- [`apps/web/src/app/admin/(console)/rcon/history/page.tsx`](../apps/web/src/app/admin/%28console%29/rcon/history/page.tsx)
|
||||
- [`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
|
||||
|
||||
The shared CloudEvent contract is covered by [`packages/contracts/test/contracts.test.ts`](../packages/contracts/test/contracts.test.ts), and event-producing routes pass full type and production-build validation.
|
||||
The shared CloudEvent contract is covered by [`packages/contracts/test/contracts.test.ts`](../packages/contracts/test/contracts.test.ts). RCON action and history tests verify complete command attribution, correlated outcomes, audit-before-send behavior, and response and credential exclusion. All workspace tests, type checks, web lint, OKF validation, Semgrep, dependency audit, and the production build passed on 2026-08-14.
|
||||
|
||||
# Related Stories
|
||||
|
||||
- [Enrich login IPs](us-007-ip-intelligence.md)
|
||||
- [Administer users](us-013-admin-user-management.md)
|
||||
- [Operate servers through an RCON console](us-022-rcon-console.md)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user