Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19486150c3 | ||
|
|
3564d24a45 | ||
|
|
7f6d69e0a7 | ||
|
|
f9ccfd821d | ||
|
|
e43db34402 | ||
|
|
20dfc58d63 | ||
|
|
6425a5056a | ||
|
|
6fa33c9f7b | ||
|
|
c131465ff5 | ||
|
|
eb1c5b6de4 | ||
|
|
9f0832a5b5 | ||
|
|
ae623f5316 | ||
|
|
d4afb71798 | ||
|
|
71856bb869 | ||
|
|
24808b0f8c | ||
|
|
aa0b757814 | ||
|
|
ebc7c7df17 |
@@ -25,5 +25,9 @@ PROXYCHECK_API_KEY=
|
|||||||
IP_INTELLIGENCE_CACHE_HOURS=48
|
IP_INTELLIGENCE_CACHE_HOURS=48
|
||||||
BLOCK_HOSTING_IPS=false
|
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
|
# Structured Pino logging
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -116,9 +115,9 @@ jobs:
|
|||||||
apt-get install -y docker-ce-cli
|
apt-get install -y docker-ce-cli
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Gitea container registry
|
- name: Log in to Docker Hub
|
||||||
if: steps.release.outputs.created == 'true'
|
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
|
- name: Build and push web image
|
||||||
if: steps.release.outputs.created == 'true'
|
if: steps.release.outputs.created == 'true'
|
||||||
@@ -129,9 +128,9 @@ jobs:
|
|||||||
--platform linux/amd64 \
|
--platform linux/amd64 \
|
||||||
--target runner \
|
--target runner \
|
||||||
--build-arg VERSION="$VERSION" \
|
--build-arg VERSION="$VERSION" \
|
||||||
-t "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}" \
|
-t "dmgarvis/minecraft-account-manager:${VERSION}" \
|
||||||
.
|
.
|
||||||
docker push "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}"
|
docker push "dmgarvis/minecraft-account-manager:${VERSION}"
|
||||||
|
|
||||||
- name: Build and push Discord bot image
|
- name: Build and push Discord bot image
|
||||||
if: steps.release.outputs.created == 'true'
|
if: steps.release.outputs.created == 'true'
|
||||||
@@ -142,9 +141,9 @@ jobs:
|
|||||||
--platform linux/amd64 \
|
--platform linux/amd64 \
|
||||||
--target bot \
|
--target bot \
|
||||||
--build-arg VERSION="$VERSION" \
|
--build-arg VERSION="$VERSION" \
|
||||||
-t "git.garvis.dev/dmg/minecraft-account-manager-bot:${VERSION}" \
|
-t "dmgarvis/minecraft-account-manager-bot:${VERSION}" \
|
||||||
.
|
.
|
||||||
docker push "git.garvis.dev/dmg/minecraft-account-manager-bot:${VERSION}"
|
docker push "dmgarvis/minecraft-account-manager-bot:${VERSION}"
|
||||||
|
|
||||||
- name: Build and push migration image
|
- name: Build and push migration image
|
||||||
if: steps.release.outputs.created == 'true'
|
if: steps.release.outputs.created == 'true'
|
||||||
@@ -155,9 +154,9 @@ jobs:
|
|||||||
--platform linux/amd64 \
|
--platform linux/amd64 \
|
||||||
--target migrate \
|
--target migrate \
|
||||||
--build-arg VERSION="$VERSION" \
|
--build-arg VERSION="$VERSION" \
|
||||||
-t "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}" \
|
-t "dmgarvis/minecraft-account-manager-migrate:${VERSION}" \
|
||||||
.
|
.
|
||||||
docker push "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}"
|
docker push "dmgarvis/minecraft-account-manager-migrate:${VERSION}"
|
||||||
|
|
||||||
- name: Create Gitea release and upload Velocity JAR
|
- name: Create Gitea release and upload Velocity JAR
|
||||||
if: steps.release.outputs.created == 'true'
|
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:
|
Before changing behavior:
|
||||||
|
|
||||||
1. Read `design/index.md` and every story related to the requested 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.
|
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:
|
While implementing:
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -1,6 +1,6 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM node:22-alpine AS dependencies
|
FROM node:22-alpine AS manifests
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apk add --no-cache libc6-compat
|
RUN apk add --no-cache libc6-compat
|
||||||
|
|
||||||
@@ -13,8 +13,16 @@ COPY packages/database/package.json ./packages/database/package.json
|
|||||||
COPY packages/logging/package.json ./packages/logging/package.json
|
COPY packages/logging/package.json ./packages/logging/package.json
|
||||||
COPY packages/minecraft/package.json ./packages/minecraft/package.json
|
COPY packages/minecraft/package.json ./packages/minecraft/package.json
|
||||||
COPY packages/network/package.json ./packages/network/package.json
|
COPY packages/network/package.json ./packages/network/package.json
|
||||||
|
|
||||||
|
FROM manifests AS dependencies
|
||||||
RUN npm ci
|
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
|
FROM dependencies AS builder
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build --workspace @minecraft-account-manager/web
|
RUN npm run build --workspace @minecraft-account-manager/web
|
||||||
@@ -36,7 +44,7 @@ USER app
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
CMD ["node", "apps/web/server.js"]
|
CMD ["node", "apps/web/server.js"]
|
||||||
|
|
||||||
FROM dependencies AS bot
|
FROM bot-dependencies AS bot
|
||||||
ARG VERSION=development
|
ARG VERSION=development
|
||||||
LABEL org.opencontainers.image.title="Minecraft Account Manager Discord Bot" \
|
LABEL org.opencontainers.image.title="Minecraft Account Manager Discord Bot" \
|
||||||
org.opencontainers.image.version="${VERSION}" \
|
org.opencontainers.image.version="${VERSION}" \
|
||||||
@@ -51,7 +59,7 @@ COPY --chown=app:app packages ./packages
|
|||||||
USER app
|
USER app
|
||||||
CMD ["npm", "run", "start", "--workspace", "@minecraft-account-manager/discord-bot"]
|
CMD ["npm", "run", "start", "--workspace", "@minecraft-account-manager/discord-bot"]
|
||||||
|
|
||||||
FROM dependencies AS migrate
|
FROM migration-dependencies AS migrate
|
||||||
ARG VERSION=development
|
ARG VERSION=development
|
||||||
LABEL org.opencontainers.image.title="Minecraft Account Manager Migrations" \
|
LABEL org.opencontainers.image.title="Minecraft Account Manager Migrations" \
|
||||||
org.opencontainers.image.version="${VERSION}" \
|
org.opencontainers.image.version="${VERSION}" \
|
||||||
|
|||||||
@@ -76,12 +76,13 @@ The token is displayed once and stored only as a SHA-256 hash.
|
|||||||
|
|
||||||
- PostgreSQL and Drizzle ORM
|
- PostgreSQL and Drizzle ORM
|
||||||
- Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role
|
- Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role
|
||||||
- Admin user search, account management, event exploration, operational metrics, an open-data user-location world map, and automatic 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 only the effective group's access setting applies
|
- 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
|
- Deployment-managed Discord guild ID and invite URL
|
||||||
- discord.js bot with `/register` and `/account`
|
- discord.js bot with `/register` and `/account`
|
||||||
- Java Edition online-mode accounts only
|
- Java Edition online-mode accounts only
|
||||||
- Velocity admission checks are fail closed
|
- Velocity admission checks are fail closed; disabled group access overrides recurring schedules, which are evaluated only at login
|
||||||
- ProxyCheck.io geolocation and VPN/proxy/Tor detection with a 48-hour PostgreSQL cache
|
- 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, and [`docs/accessibility.md`](docs/accessibility.md) for the WCAG-oriented interface review.
|
See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities, [`docs/api-errors.md`](docs/api-errors.md) for the RFC 9457 API error contract, and [`docs/security-review.md`](docs/security-review.md) for implemented controls and production requirements, and [`docs/accessibility.md`](docs/accessibility.md) for the WCAG-oriented interface review.
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
"@minecraft-account-manager/logging": "*",
|
"@minecraft-account-manager/logging": "*",
|
||||||
"discord.js": "^14.25.1",
|
"discord.js": "^14.25.1",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"drizzle-orm": "^0.45.1"
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"tsx": "^4.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"tsx": "^4.21.0",
|
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ const contentSecurityPolicy = [
|
|||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
`script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""}`,
|
`script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""}`,
|
||||||
"style-src 'self' 'unsafe-inline'",
|
"style-src 'self' 'unsafe-inline'",
|
||||||
"img-src 'self' data:",
|
"img-src 'self' data: https://tile.openstreetmap.org",
|
||||||
"font-src 'self'",
|
"font-src 'self'",
|
||||||
"connect-src 'self'",
|
"connect-src 'self'",
|
||||||
"object-src 'none'",
|
"object-src 'none'",
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
"@minecraft-account-manager/network": "*",
|
"@minecraft-account-manager/network": "*",
|
||||||
"d3-geo": "^3.1.1",
|
"d3-geo": "^3.1.1",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"next": "^16.2.1",
|
"next": "^16.2.1",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
|
"rcon-client": "^4.2.5",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
"topojson-client": "^3.1.0",
|
"topojson-client": "^3.1.0",
|
||||||
@@ -28,13 +30,16 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.2.1",
|
"@tailwindcss/postcss": "^4.2.1",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/d3-geo": "^3.1.1",
|
"@types/d3-geo": "^3.1.1",
|
||||||
|
"@types/leaflet": "^1.9.22",
|
||||||
"@types/node": "^25.0.3",
|
"@types/node": "^25.0.3",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/topojson-client": "^3.1.5",
|
"@types/topojson-client": "^3.1.5",
|
||||||
"eslint": "^9.39.4",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "^16.2.1",
|
"eslint-config-next": "^16.2.1",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"vitest": "^4.1.0"
|
"vitest": "^4.1.0"
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default async function AccountPage({
|
|||||||
.orderBy(desc(ipObservations.observedAt))
|
.orderBy(desc(ipObservations.observedAt))
|
||||||
.limit(100),
|
.limit(100),
|
||||||
discordIdentity(user),
|
discordIdentity(user),
|
||||||
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, isDefault: groups.isDefault })
|
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed, isDefault: groups.isDefault })
|
||||||
.from(groups)
|
.from(groups)
|
||||||
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
|
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
|
||||||
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
|
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
|
||||||
@@ -192,8 +192,8 @@ export default async function AccountPage({
|
|||||||
</form>
|
</form>
|
||||||
<section className="mt-8 border border-line bg-panel p-6">
|
<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>
|
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Access groups</p>
|
||||||
{effectiveGroup ? <div className="mt-4 flex items-center justify-between gap-3"><span className="font-mono text-xs font-bold">{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}</span><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${effectiveGroup.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{effectiveGroup.accessEnabled ? "Access on" : "Access off"}</span></div> : <p className="mt-4 text-sm text-accent">No default access group is configured.</p>}
|
{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 access.</p>
|
<p className="mt-4 text-xs leading-5 text-muted">Your effective group alone determines Minecraft and VPN/proxy/Tor access.</p>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,31 +1,43 @@
|
|||||||
"use server";
|
"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 { redirect } from "next/navigation";
|
||||||
|
import { parseAdmissionMessages } from "@/lib/admission-settings";
|
||||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
|
|
||||||
export async function saveDiscordSettings(formData: FormData) {
|
export async function saveAdmissionSettings(formData: FormData) {
|
||||||
await requireAdminSession();
|
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");
|
||||||
if (registrationMessage.length < 10 || registrationMessage.length > 500) {
|
await db.transaction(async (tx) => {
|
||||||
redirect("/admin/settings?error=invalid-message");
|
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 db
|
await tx.insert(appSettings)
|
||||||
.insert(appSettings)
|
.values({ id: "default", ...messages })
|
||||||
.values({
|
|
||||||
id: "default",
|
|
||||||
registrationMessage,
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: appSettings.id,
|
target: appSettings.id,
|
||||||
set: {
|
set: { ...messages, updatedAt: new Date() },
|
||||||
registrationMessage,
|
});
|
||||||
updatedAt: new Date(),
|
if (changedFields.length) {
|
||||||
},
|
await tx.insert(events).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
source: "/web/admin",
|
||||||
|
type: "games.minecraft.account-manager.settings.admission-messages-updated",
|
||||||
|
subject: "settings/default",
|
||||||
|
time: new Date(),
|
||||||
|
data: { changedFields, adminEmail: admin.email, adminName: admin.name },
|
||||||
|
ipAddress: ipAddress ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
redirect("/admin/settings?saved=1");
|
redirect("/admin/settings?saved=1");
|
||||||
|
|||||||
@@ -1,15 +1,31 @@
|
|||||||
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
import { groupAccessWindows, groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||||
import { asc, eq } from "drizzle-orm";
|
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
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 { db } from "@/lib/database";
|
||||||
import { addGroupMember, assignDefaultGroup, deleteGroup, removeGroupMember, setGroupAccess } from "../actions";
|
import { isEffectiveGroupMember } from "@/lib/group-management";
|
||||||
|
import { assignUserGroupFromRegistry } from "../../users/actions";
|
||||||
|
import { deleteGroup, replaceGroupSchedule, setGroupAccess, setGroupAnonymizedNetworkAccess, updateGroupDetails } from "../actions";
|
||||||
|
|
||||||
const savedMessages: Record<string, string> = {
|
const savedMessages: Record<string, string> = {
|
||||||
created: "Group created with access disabled.",
|
created: "Group created.",
|
||||||
access: "Group access policy updated.",
|
details: "Group details updated.",
|
||||||
"member-added": "User assigned to the group.",
|
access: "Minecraft access policy updated.",
|
||||||
"member-removed": "User returned to the default group.",
|
"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 const dynamic = "force-dynamic";
|
||||||
@@ -19,31 +35,48 @@ export default async function GroupPage({
|
|||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ groupId: string }>;
|
params: Promise<{ groupId: string }>;
|
||||||
searchParams: Promise<{ saved?: string }>;
|
searchParams: Promise<{ error?: string; saved?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { groupId } = await params;
|
const { groupId } = await params;
|
||||||
const query = await searchParams;
|
const query = await searchParams;
|
||||||
const [group] = await db.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
const [group] = await db.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||||
if (!group) notFound();
|
if (!group) notFound();
|
||||||
|
|
||||||
const [allUsers, memberships] = await Promise.all([
|
const [allUsers, allGroups, memberships, accessWindows] = await Promise.all([
|
||||||
db.select({
|
db.select({
|
||||||
id: users.id,
|
id: users.id,
|
||||||
firstName: users.firstName,
|
firstName: users.firstName,
|
||||||
discordUsername: users.discordUsername,
|
discordUsername: users.discordUsername,
|
||||||
discordGlobalName: users.discordGlobalName,
|
discordGlobalName: users.discordGlobalName,
|
||||||
discordUserId: users.discordUserId,
|
discordUserId: users.discordUserId,
|
||||||
}).from(users).orderBy(asc(users.discordUsername)),
|
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({
|
db.select({
|
||||||
userId: userGroupMemberships.userId,
|
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
|
||||||
groupId: userGroupMemberships.groupId,
|
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
|
||||||
groupName: groups.name,
|
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id))
|
||||||
}).from(userGroupMemberships).innerJoin(groups, eq(groups.id, userGroupMemberships.groupId)),
|
.orderBy(groupAccessWindows.startMinuteOfWeek),
|
||||||
]);
|
]);
|
||||||
const assignmentByUser = new Map(memberships.map((membership) => [membership.userId, membership]));
|
const assignmentByUser = Object.fromEntries(memberships.map((membership) => [membership.userId, membership.groupId]));
|
||||||
const memberCount = group.isDefault
|
const memberUsers = allUsers.filter((user) => isEffectiveGroupMember(user.id, assignmentByUser, group));
|
||||||
? allUsers.length - assignmentByUser.size
|
const returnTo = `/admin/groups/${group.id}`;
|
||||||
: memberships.filter((membership) => membership.groupId === group.id).length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-6 py-12">
|
<main className="mx-auto max-w-6xl px-6 py-12">
|
||||||
@@ -52,74 +85,66 @@ export default async function GroupPage({
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Access group</p>
|
<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>
|
<div className="mt-4 flex flex-wrap items-center gap-3"><h1 className="font-display text-5xl font-black uppercase sm:text-7xl">{group.name}</h1>{group.isDefault && <span className="bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase text-canvas">Default</span>}</div>
|
||||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
|
<p className="mt-3 max-w-2xl whitespace-pre-line text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
|
||||||
</div>
|
</div>
|
||||||
<form action={setGroupAccess} className="border-l-2 border-accent pl-5">
|
<AdminModalForm
|
||||||
|
action={updateGroupDetails}
|
||||||
|
description={group.isDefault ? "Update the protected default group's description. Its name remains everyone." : "Update the administrator-facing name and description. The internal slug remains stable."}
|
||||||
|
submitLabel="Save details"
|
||||||
|
title={`Edit ${group.name}`}
|
||||||
|
triggerClassName="border border-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider"
|
||||||
|
triggerLabel="Edit group"
|
||||||
|
>
|
||||||
<input name="groupId" type="hidden" value={group.id} />
|
<input name="groupId" type="hidden" value={group.id} />
|
||||||
<input name="accessEnabled" type="hidden" value={group.accessEnabled ? "no" : "yes"} />
|
<div className="space-y-5">
|
||||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Minecraft admission</p>
|
<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>
|
||||||
<p className="mt-2 font-display text-2xl font-black uppercase">{group.accessEnabled ? "Allowed" : "Denied"}</p>
|
<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>
|
||||||
<button className="mt-3 font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn access {group.accessEnabled ? "off" : "on"}</button>
|
</div>
|
||||||
</form>
|
</AdminModalForm>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider" role="status">{savedMessages[query.saved] ?? "Group updated."}</p>}
|
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">{savedMessages[query.saved] ?? "Group updated."}</p>}
|
||||||
|
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errorMessages[query.error] ?? "The group operation failed."}</p>}
|
||||||
|
|
||||||
<section className="mt-10">
|
<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="flex items-end justify-between border-b border-line pb-4">
|
<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><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Membership</p><h2 className="mt-2 font-display text-3xl font-black uppercase">Registered users</h2></div>
|
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
||||||
<span className="font-mono text-xs text-muted">{memberCount} members</span>
|
<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>
|
||||||
{group.isDefault && <p className="border-b border-line bg-panel px-5 py-4 text-sm text-muted">Users belong to <strong className="text-ink">everyone</strong> only while they have no explicit group assignment.</p>}
|
<div className="mt-7 scroll-mt-6 border-t border-line pt-6" id="group-schedule">
|
||||||
<div className="divide-y divide-line">
|
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
|
||||||
{allUsers.map((user) => {
|
<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>
|
||||||
const assignment = assignmentByUser.get(user.id);
|
<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">
|
||||||
const isMember = group.isDefault ? !assignment : assignment?.groupId === group.id;
|
<input name="groupId" type="hidden" value={group.id} />
|
||||||
return (
|
<GroupScheduleEditor windows={accessWindows} />
|
||||||
<article className="grid gap-4 py-5 sm:grid-cols-[1fr_auto] sm:items-center" key={user.id}>
|
</AdminModalForm>
|
||||||
<div>
|
|
||||||
<Link className="font-mono text-sm font-bold underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? user.discordGlobalName ?? user.discordUsername}</Link>
|
|
||||||
<p className="mt-1 font-mono text-[10px] text-muted">@{user.discordUsername} · {user.discordUserId}</p>
|
|
||||||
{!isMember && assignment && <p className="mt-1 text-xs text-muted">Currently assigned to {assignment.groupName}</p>}
|
|
||||||
</div>
|
</div>
|
||||||
{isMember ? (
|
|
||||||
group.isDefault ? <span className="font-mono text-[9px] font-bold uppercase text-muted">Default assignment</span> : (
|
|
||||||
<form action={removeGroupMember}>
|
|
||||||
<input name="groupId" type="hidden" value={group.id} />
|
|
||||||
<input name="userId" type="hidden" value={user.id} />
|
|
||||||
<button className="font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Return to everyone</button>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<form action={group.isDefault ? assignDefaultGroup : addGroupMember}>
|
|
||||||
<input name="groupId" type="hidden" value={group.id} />
|
|
||||||
<input name="userId" type="hidden" value={user.id} />
|
|
||||||
<button className="font-mono text-[9px] font-bold uppercase text-ink underline underline-offset-4" type="submit">Move to {group.name}</button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!allUsers.length && <p className="py-8 text-sm text-muted">No registered users yet.</p>}
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 && (
|
{!group.isDefault && (
|
||||||
<section className="mt-12 border border-accent bg-panel p-6">
|
<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">
|
||||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Danger zone</p>
|
<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>
|
||||||
<h2 className="mt-3 font-display text-2xl font-black uppercase">Delete {group.name}</h2>
|
<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">
|
||||||
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted">Deleting this group returns its {memberCount} {memberCount === 1 ? "member" : "members"} to the protected default group. This cannot be undone.</p>
|
|
||||||
<details className="mt-5">
|
|
||||||
<summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4">Review deletion</summary>
|
|
||||||
<form action={deleteGroup} className="mt-4 flex flex-wrap items-center gap-4">
|
|
||||||
<input name="groupId" type="hidden" value={group.id} />
|
<input name="groupId" type="hidden" value={group.id} />
|
||||||
<input name="confirmDelete" type="hidden" value="yes" />
|
<input name="confirmDelete" type="hidden" value="yes" />
|
||||||
<button className="bg-accent px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Delete group permanently</button>
|
</AdminModalForm>
|
||||||
<span className="text-xs text-muted">Members will use everyone immediately.</span>
|
|
||||||
</form>
|
|
||||||
</details>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</main>
|
</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,
|
||||||
|
}]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,150 +1,214 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { events, groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
import { events, groupAccessWindows, groups, userGroupMemberships } from "@minecraft-account-manager/database";
|
||||||
import { getClientIp } from "@minecraft-account-manager/network";
|
import { getClientIp } from "@minecraft-account-manager/network";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, ne, sql } from "drizzle-orm";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { recordAdminSubjectEvent } from "@/lib/audit";
|
|
||||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||||
import { db } from "@/lib/database";
|
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;
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
||||||
|
type Admin = Awaited<ReturnType<typeof requireAdminSession>>;
|
||||||
|
type ReturnLocation = "list" | "detail";
|
||||||
|
|
||||||
function groupPath(groupId: string, query?: string) {
|
function groupPath(groupId: string, query?: string) {
|
||||||
return `/admin/groups/${encodeURIComponent(groupId)}${query ? `?${query}` : ""}`;
|
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) {
|
export async function createGroup(formData: FormData) {
|
||||||
const admin = await requireAdminSession();
|
const admin = await requireAdminSession();
|
||||||
const name = String(formData.get("name") ?? "").trim();
|
const details = validateGroupDetails(formData.get("name"), formData.get("description"));
|
||||||
const slug = String(formData.get("slug") ?? "").trim().toLowerCase();
|
if (!details) redirect("/admin/groups?error=invalid-group");
|
||||||
const description = String(formData.get("description") ?? "").trim();
|
const accessEnabled = formData.get("accessEnabled") === "yes";
|
||||||
if (name.length < 1 || name.length > 50 || !SLUG_PATTERN.test(slug) || slug.length > 50 || description.length > 500) {
|
const anonymizedNetworksAllowed = formData.get("anonymizedNetworksAllowed") === "yes";
|
||||||
redirect("/admin/groups?error=invalid-group");
|
const ipAddress = await auditContext();
|
||||||
}
|
|
||||||
|
|
||||||
let group: { id: string } | undefined;
|
let created: { id: string } | null = null;
|
||||||
try {
|
try {
|
||||||
[group] = await db.insert(groups).values({
|
created = await db.transaction(async (tx) => {
|
||||||
name,
|
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,
|
slug,
|
||||||
description: description || null,
|
description: details.description || null,
|
||||||
accessEnabled: false,
|
accessEnabled,
|
||||||
|
anonymizedNetworksAllowed,
|
||||||
isDefault: false,
|
isDefault: false,
|
||||||
}).returning({ id: groups.id });
|
}).returning({ id: groups.id });
|
||||||
} catch {
|
if (!group) throw new Error("Group insert returned no row");
|
||||||
redirect("/admin/groups?error=duplicate-group");
|
await tx.insert(events).values({
|
||||||
}
|
id: randomUUID(),
|
||||||
if (!group) redirect("/admin/groups?error=create-failed");
|
source: "/web/admin",
|
||||||
|
type: "games.minecraft.account-manager.group.created",
|
||||||
await recordAdminSubjectEvent(admin, `group/${group.id}`, "games.minecraft.account-manager.group.created", {
|
subject: `group/${group.id}`,
|
||||||
name,
|
time: new Date(),
|
||||||
|
data: auditData(admin, {
|
||||||
|
name: details.name,
|
||||||
slug,
|
slug,
|
||||||
accessEnabled: false,
|
accessEnabled,
|
||||||
|
anonymizedNetworksAllowed,
|
||||||
|
}),
|
||||||
|
ipAddress: ipAddress ?? null,
|
||||||
});
|
});
|
||||||
redirect(groupPath(group.id, "saved=created"));
|
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) {
|
export async function setGroupAccess(formData: FormData) {
|
||||||
const admin = await requireAdminSession();
|
return updateGroupPolicy(formData, "access");
|
||||||
const groupId = String(formData.get("groupId") ?? "");
|
|
||||||
const accessEnabled = formData.get("accessEnabled") === "yes";
|
|
||||||
if (!UUID_PATTERN.test(groupId)) redirect("/admin/groups?error=unknown-group");
|
|
||||||
|
|
||||||
const [group] = await db.update(groups).set({ accessEnabled, updatedAt: new Date() })
|
|
||||||
.where(eq(groups.id, groupId)).returning({ id: groups.id, name: groups.name });
|
|
||||||
if (!group) redirect("/admin/groups?error=unknown-group");
|
|
||||||
|
|
||||||
await recordAdminSubjectEvent(admin, `group/${group.id}`, "games.minecraft.account-manager.group.access-updated", {
|
|
||||||
name: group.name,
|
|
||||||
accessEnabled,
|
|
||||||
});
|
|
||||||
redirect(groupPath(group.id, "saved=access"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addGroupMember(formData: FormData) {
|
export async function setGroupAnonymizedNetworkAccess(formData: FormData) {
|
||||||
const admin = await requireAdminSession();
|
return updateGroupPolicy(formData, "anonymized-networks");
|
||||||
const groupId = String(formData.get("groupId") ?? "");
|
|
||||||
const userId = String(formData.get("userId") ?? "");
|
|
||||||
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
|
|
||||||
|
|
||||||
const [[group], [user]] = await Promise.all([
|
|
||||||
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
|
|
||||||
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
|
|
||||||
]);
|
|
||||||
if (!group || !user || group.isDefault) redirect("/admin/groups?error=invalid-membership");
|
|
||||||
|
|
||||||
const previousGroup = await db.transaction(async (tx) => {
|
|
||||||
const [previous] = await tx
|
|
||||||
.select({ id: groups.id, name: groups.name })
|
|
||||||
.from(userGroupMemberships)
|
|
||||||
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
|
||||||
.where(eq(userGroupMemberships.userId, user.id))
|
|
||||||
.limit(1);
|
|
||||||
await tx.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
|
|
||||||
await tx.insert(userGroupMemberships).values({ groupId: group.id, userId: user.id });
|
|
||||||
return previous ?? null;
|
|
||||||
});
|
|
||||||
await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.assignment-updated", {
|
|
||||||
groupId: group.id,
|
|
||||||
groupName: group.name,
|
|
||||||
previousGroupId: previousGroup?.id ?? null,
|
|
||||||
previousGroupName: previousGroup?.name ?? "everyone",
|
|
||||||
});
|
|
||||||
redirect(groupPath(group.id, "saved=member-added"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeGroupMember(formData: FormData) {
|
export async function replaceGroupSchedule(formData: FormData) {
|
||||||
const admin = await requireAdminSession();
|
const admin = await requireAdminSession();
|
||||||
const groupId = String(formData.get("groupId") ?? "");
|
const groupId = String(formData.get("groupId") ?? "");
|
||||||
const userId = String(formData.get("userId") ?? "");
|
const windows = parseScheduleWindows(formData);
|
||||||
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
|
if (!UUID_PATTERN.test(groupId) || !windows) redirect(groupPath(groupId, "error=invalid-schedule"));
|
||||||
|
const ipAddress = await auditContext();
|
||||||
|
|
||||||
const [group] = await db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault })
|
const updated = await db.transaction(async (tx) => {
|
||||||
.from(groups).where(eq(groups.id, groupId)).limit(1);
|
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
|
||||||
if (!group || group.isDefault) redirect("/admin/groups?error=invalid-membership");
|
const [group] = await tx.select({ id: groups.id, name: groups.name }).from(groups)
|
||||||
|
.where(eq(groups.id, groupId)).limit(1);
|
||||||
await db.delete(userGroupMemberships).where(and(
|
if (!group) return null;
|
||||||
eq(userGroupMemberships.groupId, group.id),
|
const previous = await tx.select({
|
||||||
eq(userGroupMemberships.userId, userId),
|
startMinuteOfWeek: groupAccessWindows.startMinuteOfWeek,
|
||||||
));
|
endMinuteOfWeek: groupAccessWindows.endMinuteOfWeek,
|
||||||
await recordAdminSubjectEvent(admin, `user/${userId}`, "games.minecraft.account-manager.group.assignment-removed", {
|
}).from(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id));
|
||||||
groupId: group.id,
|
await tx.delete(groupAccessWindows).where(eq(groupAccessWindows.groupId, group.id));
|
||||||
groupName: group.name,
|
if (windows.length) {
|
||||||
fallbackGroup: "everyone",
|
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,
|
||||||
});
|
});
|
||||||
redirect(groupPath(group.id, "saved=member-removed"));
|
return group;
|
||||||
}
|
|
||||||
|
|
||||||
export async function assignDefaultGroup(formData: FormData) {
|
|
||||||
const admin = await requireAdminSession();
|
|
||||||
const groupId = String(formData.get("groupId") ?? "");
|
|
||||||
const userId = String(formData.get("userId") ?? "");
|
|
||||||
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
|
|
||||||
|
|
||||||
const [[defaultGroup], [user]] = await Promise.all([
|
|
||||||
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
|
|
||||||
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
|
|
||||||
]);
|
|
||||||
if (!defaultGroup?.isDefault || !user) redirect("/admin/groups?error=invalid-membership");
|
|
||||||
|
|
||||||
const [previous] = await db
|
|
||||||
.select({ id: groups.id, name: groups.name })
|
|
||||||
.from(userGroupMemberships)
|
|
||||||
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
|
||||||
.where(eq(userGroupMemberships.userId, user.id))
|
|
||||||
.limit(1);
|
|
||||||
await db.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
|
|
||||||
await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.assignment-updated", {
|
|
||||||
groupId: defaultGroup.id,
|
|
||||||
groupName: defaultGroup.name,
|
|
||||||
previousGroupId: previous?.id ?? null,
|
|
||||||
previousGroupName: previous?.name ?? null,
|
|
||||||
});
|
});
|
||||||
redirect(groupPath(defaultGroup.id, "saved=member-added"));
|
if (!updated) redirect("/admin/groups?error=unknown-group");
|
||||||
|
redirect(groupPath(updated.id, "saved=schedule"));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteGroup(formData: FormData) {
|
export async function deleteGroup(formData: FormData) {
|
||||||
@@ -152,40 +216,32 @@ export async function deleteGroup(formData: FormData) {
|
|||||||
const groupId = String(formData.get("groupId") ?? "");
|
const groupId = String(formData.get("groupId") ?? "");
|
||||||
const confirmed = formData.get("confirmDelete") === "yes";
|
const confirmed = formData.get("confirmDelete") === "yes";
|
||||||
if (!UUID_PATTERN.test(groupId) || !confirmed) redirect("/admin/groups?error=invalid-delete");
|
if (!UUID_PATTERN.test(groupId) || !confirmed) redirect("/admin/groups?error=invalid-delete");
|
||||||
const requestHeaders = await headers();
|
const ipAddress = await auditContext();
|
||||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
|
||||||
|
|
||||||
const deleted = await db.transaction(async (tx) => {
|
const deleted = await db.transaction(async (tx) => {
|
||||||
const [group] = await tx
|
await tx.execute(sql`select pg_advisory_xact_lock(hashtext('minecraft-account-manager-group-membership'))`);
|
||||||
.select({ id: groups.id, name: groups.name, slug: groups.slug, isDefault: groups.isDefault })
|
await tx.execute(sql`select ${groups.id} from ${groups} where ${groups.id} = ${groupId} for update`);
|
||||||
.from(groups)
|
const [group] = await tx.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||||
.where(eq(groups.id, groupId))
|
|
||||||
.limit(1);
|
|
||||||
if (!group || group.isDefault) return null;
|
if (!group || group.isDefault) return null;
|
||||||
const members = await tx
|
const members = await tx.select({ userId: userGroupMemberships.userId })
|
||||||
.select({ userId: userGroupMemberships.userId })
|
.from(userGroupMemberships).where(eq(userGroupMemberships.groupId, group.id));
|
||||||
.from(userGroupMemberships)
|
|
||||||
.where(eq(userGroupMemberships.groupId, group.id));
|
|
||||||
await tx.insert(events).values({
|
await tx.insert(events).values({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
source: "/web/admin",
|
source: "/web/admin",
|
||||||
type: "games.minecraft.account-manager.group.deleted",
|
type: "games.minecraft.account-manager.group.deleted",
|
||||||
subject: `group/${group.id}`,
|
subject: `group/${group.id}`,
|
||||||
time: new Date(),
|
time: new Date(),
|
||||||
data: {
|
data: auditData(admin, {
|
||||||
name: group.name,
|
name: group.name,
|
||||||
slug: group.slug,
|
slug: group.slug,
|
||||||
affectedUsers: members.length,
|
affectedUsers: members.length,
|
||||||
fallbackGroup: "everyone",
|
fallbackGroup: "everyone",
|
||||||
adminEmail: admin.email,
|
}),
|
||||||
adminName: admin.name,
|
|
||||||
},
|
|
||||||
ipAddress: ipAddress ?? null,
|
ipAddress: ipAddress ?? null,
|
||||||
});
|
});
|
||||||
await tx.delete(groups).where(eq(groups.id, group.id));
|
await tx.delete(groups).where(eq(groups.id, group.id));
|
||||||
return group;
|
return group;
|
||||||
});
|
});
|
||||||
if (!deleted) redirect("/admin/groups?error=protected-group");
|
if (!deleted) redirect("/admin/groups?error=protected-group");
|
||||||
|
|
||||||
redirect("/admin/groups?saved=deleted");
|
redirect("/admin/groups?saved=deleted");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,84 +1,109 @@
|
|||||||
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
import { groupAccessWindows, groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||||
import { asc, desc } from "drizzle-orm";
|
import { asc, count, desc } from "drizzle-orm";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { AdminModalForm } from "@/components/admin-modal-form";
|
||||||
|
import { GroupPolicyControl } from "@/components/group-policy-control";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
import { createGroup, setGroupAccess } from "./actions";
|
import { effectiveGroupMemberCount } from "@/lib/group-management";
|
||||||
|
import { groupScheduleStatus } from "@/lib/group-schedule";
|
||||||
|
import { createGroup, setGroupAccess, setGroupAnonymizedNetworkAccess } from "./actions";
|
||||||
|
|
||||||
const errors: Record<string, string> = {
|
const errors: Record<string, string> = {
|
||||||
"invalid-group": "Enter a name and a lowercase slug containing letters, numbers, or hyphens.",
|
"invalid-group": "Enter a group name and an optional description of no more than 500 characters.",
|
||||||
"duplicate-group": "That group slug already exists.",
|
"duplicate-group": "A group with that name already exists.",
|
||||||
"create-failed": "The group could not be created.",
|
"create-failed": "The group could not be created.",
|
||||||
"unknown-group": "That group no longer exists.",
|
"unknown-group": "That group no longer exists.",
|
||||||
"invalid-membership": "That membership change was invalid.",
|
|
||||||
"invalid-delete": "Confirm the group deletion before continuing.",
|
"invalid-delete": "Confirm the group deletion before continuing.",
|
||||||
"protected-group": "The protected default group cannot be deleted.",
|
"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 const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function GroupsPage({ searchParams }: { searchParams: Promise<{ error?: string; saved?: string }> }) {
|
export default async function GroupsPage({ searchParams }: { searchParams: Promise<{ error?: string; saved?: string }> }) {
|
||||||
const query = await searchParams;
|
const query = await searchParams;
|
||||||
const [allGroups, memberships, registeredUsers] = await Promise.all([
|
const [allGroups, memberships, [registeredUsers], scheduleCounts] = await Promise.all([
|
||||||
db.select().from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
|
db.select().from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
|
||||||
db.select({ groupId: userGroupMemberships.groupId }).from(userGroupMemberships),
|
db.select({ groupId: userGroupMemberships.groupId }).from(userGroupMemberships),
|
||||||
db.select({ id: users.id }).from(users),
|
db.select({ count: count() }).from(users),
|
||||||
|
db.select({ groupId: groupAccessWindows.groupId, count: count() })
|
||||||
|
.from(groupAccessWindows)
|
||||||
|
.groupBy(groupAccessWindows.groupId),
|
||||||
]);
|
]);
|
||||||
const membershipCounts = new Map<string, number>();
|
const scheduleCountByGroup = new Map(scheduleCounts.map((schedule) => [schedule.groupId, Number(schedule.count)]));
|
||||||
for (const membership of memberships) {
|
|
||||||
membershipCounts.set(membership.groupId, (membershipCounts.get(membership.groupId) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
const explicitlyAssignedUsers = memberships.length;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||||
<header className="border-b border-line pb-8">
|
<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>
|
<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>
|
<h1 className="mt-4 font-display text-5xl font-black uppercase">Access groups</h1>
|
||||||
<p className="mt-5 max-w-2xl leading-7 text-muted">Each user has one effective group. Users without an explicit assignment fall back to <strong className="text-ink">everyone</strong>; Minecraft admission follows only that group’s access setting.</p>
|
<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>
|
</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.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errors[query.error] ?? "The group operation failed."}</p>}
|
||||||
{query.saved === "deleted" && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">Group deleted. Its former members now use the default group.</p>}
|
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">{savedMessages[query.saved] ?? "Group updated."}</p>}
|
||||||
|
|
||||||
<section className="mt-10 grid gap-5 md:grid-cols-2">
|
<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) => {
|
{allGroups.map((group) => {
|
||||||
const memberCount = group.isDefault ? registeredUsers.length - explicitlyAssignedUsers : membershipCounts.get(group.id) ?? 0;
|
const memberCount = effectiveGroupMemberCount(
|
||||||
|
Number(registeredUsers?.count ?? 0),
|
||||||
|
memberships.map((membership) => membership.groupId),
|
||||||
|
group,
|
||||||
|
);
|
||||||
|
const scheduleStatus = groupScheduleStatus(scheduleCountByGroup.get(group.id) ?? 0);
|
||||||
return (
|
return (
|
||||||
<article className="border border-line bg-panel p-6 shadow-[5px_5px_0_var(--color-shadow)]" key={group.id}>
|
<tr className="transition-colors hover:bg-canvas/60" key={group.id}>
|
||||||
<div className="flex items-start justify-between gap-4">
|
<th className="p-4 text-left" scope="row">
|
||||||
<div>
|
<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>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
{group.isDefault && <span className="ml-3 bg-ink px-2 py-1 font-mono text-[8px] font-bold uppercase text-canvas">Default</span>}
|
||||||
<h2 className="font-display text-2xl font-black uppercase">{group.name}</h2>
|
</th>
|
||||||
{group.isDefault && <span className="bg-ink px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">Default</span>}
|
<td className="p-4"><GroupPolicyControl action={setGroupAccess} enabled={group.accessEnabled} groupId={group.id} groupName={group.name} memberCount={memberCount} policy="Minecraft access" returnLocation="list" /></td>
|
||||||
</div>
|
<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>
|
||||||
<p className="mt-1 font-mono text-[10px] text-muted">{group.slug} · {memberCount} members</p>
|
<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>
|
||||||
</div>
|
<td className="p-4 text-right font-mono text-sm font-bold">{memberCount}</td>
|
||||||
<span className={`px-3 py-2 font-mono text-[9px] font-bold uppercase ${group.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{group.accessEnabled ? "Access on" : "Access off"}</span>
|
</tr>
|
||||||
</div>
|
|
||||||
<p className="mt-4 min-h-12 text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
|
|
||||||
<div className="mt-5 flex items-center justify-between gap-4 border-t border-line pt-4">
|
|
||||||
<Link className="font-mono text-[10px] font-bold uppercase underline underline-offset-4" href={`/admin/groups/${group.id}`}>Manage members</Link>
|
|
||||||
<form action={setGroupAccess}>
|
|
||||||
<input name="groupId" type="hidden" value={group.id} />
|
|
||||||
<input name="accessEnabled" type="hidden" value={group.accessEnabled ? "no" : "yes"} />
|
|
||||||
<button className="font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn access {group.accessEnabled ? "off" : "on"}</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</section>
|
</tbody>
|
||||||
|
</table>
|
||||||
<form action={createGroup} className="mt-12 border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)]">
|
|
||||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Create a group</p>
|
|
||||||
<div className="mt-5 grid gap-5 sm:grid-cols-2">
|
|
||||||
<label className="text-sm font-bold">Name<input className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={50} name="name" required /></label>
|
|
||||||
<label className="text-sm font-bold">Slug<input className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-mono font-normal outline-none focus:border-accent" maxLength={50} name="slug" pattern="[a-z0-9]+(?:-[a-z0-9]+)*" placeholder="ops" required /></label>
|
|
||||||
</div>
|
</div>
|
||||||
<label className="mt-5 block text-sm font-bold">Description<textarea className="mt-2 min-h-24 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={500} name="description" /></label>
|
<p className="mt-4 text-xs leading-5 text-muted">Users without an explicit assignment count toward <strong className="text-ink">everyone</strong>.</p>
|
||||||
<p className="mt-4 text-xs text-muted">New groups start with access disabled.</p>
|
|
||||||
<button className="mt-6 border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Create group</button>
|
|
||||||
</form>
|
|
||||||
</main>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export default async function AdminConsoleLayout({ children }: { children: React
|
|||||||
<Link className="hover:text-accent" href="/admin/settings">Settings</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/users">Users</Link>
|
||||||
<Link className="hover:text-accent" href="/admin/groups">Groups</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>
|
<Link className="hover:text-accent" href="/admin/events">Events</Link>
|
||||||
</nav>
|
</nav>
|
||||||
<AdminSignOutButton />
|
<AdminSignOutButton />
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { events, ipIntelligence, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
|
import { events, ipIntelligence, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
|
||||||
import { and, count, countDistinct, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm";
|
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 Link from "next/link";
|
||||||
import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map";
|
import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
import { fillDailySeries, type DailyCount } from "@/lib/admin-metrics";
|
import { fillDailySeries, mergeRiskActivity, type DailyCount } from "@/lib/admin-metrics";
|
||||||
import { parseUserLocation } from "@/lib/user-location-map";
|
import { MAP_LOCATION_CLASSIFICATIONS, parseUserLocation, parseUserNetwork } from "@/lib/user-location-map";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -15,30 +16,34 @@ export default async function AdminDashboardPage() {
|
|||||||
fourteenDaysAgo.setUTCHours(0, 0, 0, 0);
|
fourteenDaysAgo.setUTCHours(0, 0, 0, 0);
|
||||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1_000);
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1_000);
|
||||||
|
|
||||||
const [registrationRows, [totals], [monthlyActive], locationRows, riskyActivity, [recentDenials]] = await Promise.all([
|
const [dailyActiveRows, [totals], [monthlyActive], [monthlyAccounts], locationRows, riskyLatestRows, riskySummaryRows, [recentDenials]] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
day: sql<string>`to_char(date_trunc('day', ${users.createdAt} at time zone 'UTC'), 'YYYY-MM-DD')`,
|
day: sql<string>`to_char(date_trunc('day', ${ipObservations.observedAt} at time zone 'UTC'), 'YYYY-MM-DD')`,
|
||||||
count: count(),
|
count: countDistinct(ipObservations.userId),
|
||||||
})
|
})
|
||||||
.from(users)
|
.from(ipObservations)
|
||||||
.where(gte(users.createdAt, fourteenDaysAgo))
|
.where(and(gte(ipObservations.observedAt, fourteenDaysAgo), isNotNull(ipObservations.userId)))
|
||||||
.groupBy(sql`date_trunc('day', ${users.createdAt} at time zone 'UTC')`)
|
.groupBy(sql`date_trunc('day', ${ipObservations.observedAt} at time zone 'UTC')`)
|
||||||
.orderBy(sql`date_trunc('day', ${users.createdAt} 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: count(users.id) }).from(users),
|
||||||
db.select({
|
db.select({
|
||||||
users: countDistinct(ipObservations.userId),
|
users: countDistinct(ipObservations.userId),
|
||||||
accounts: countDistinct(ipObservations.minecraftAccountId),
|
|
||||||
}).from(ipObservations).where(and(
|
}).from(ipObservations).where(and(
|
||||||
gte(ipObservations.observedAt, thirtyDaysAgo),
|
gte(ipObservations.observedAt, thirtyDaysAgo),
|
||||||
isNotNull(ipObservations.userId),
|
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
|
db
|
||||||
.selectDistinctOn([ipObservations.userId], {
|
.selectDistinctOn([ipObservations.userId], {
|
||||||
userId: ipObservations.userId,
|
userId: ipObservations.userId,
|
||||||
name: users.firstName,
|
name: users.firstName,
|
||||||
discordUsername: users.discordUsername,
|
discordUsername: users.discordUsername,
|
||||||
classification: ipObservations.classification,
|
primaryUsername: minecraftAccounts.username,
|
||||||
|
classification: ipIntelligence.classification,
|
||||||
source: ipObservations.source,
|
source: ipObservations.source,
|
||||||
observedAt: ipObservations.observedAt,
|
observedAt: ipObservations.observedAt,
|
||||||
intelligence: ipIntelligence.rawResponse,
|
intelligence: ipIntelligence.rawResponse,
|
||||||
@@ -46,16 +51,22 @@ export default async function AdminDashboardPage() {
|
|||||||
.from(ipObservations)
|
.from(ipObservations)
|
||||||
.innerJoin(users, eq(users.id, ipObservations.userId))
|
.innerJoin(users, eq(users.id, ipObservations.userId))
|
||||||
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||||
|
.leftJoin(minecraftAccounts, and(
|
||||||
|
eq(minecraftAccounts.userId, users.id),
|
||||||
|
eq(minecraftAccounts.isPrimary, true),
|
||||||
|
isNull(minecraftAccounts.deletedAt),
|
||||||
|
))
|
||||||
.where(and(
|
.where(and(
|
||||||
isNotNull(ipObservations.userId),
|
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'->'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`,
|
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)),
|
.orderBy(ipObservations.userId, desc(ipObservations.observedAt), desc(ipObservations.id)),
|
||||||
db
|
db
|
||||||
.select({
|
.selectDistinctOn([ipObservations.userId], {
|
||||||
id: ipObservations.id,
|
id: ipObservations.id,
|
||||||
classification: ipObservations.classification,
|
classification: ipIntelligence.classification,
|
||||||
observedAt: ipObservations.observedAt,
|
observedAt: ipObservations.observedAt,
|
||||||
source: ipObservations.source,
|
source: ipObservations.source,
|
||||||
userId: users.id,
|
userId: users.id,
|
||||||
@@ -64,28 +75,54 @@ export default async function AdminDashboardPage() {
|
|||||||
accountUsername: minecraftAccounts.username,
|
accountUsername: minecraftAccounts.username,
|
||||||
})
|
})
|
||||||
.from(ipObservations)
|
.from(ipObservations)
|
||||||
.leftJoin(users, eq(users.id, ipObservations.userId))
|
.innerJoin(users, eq(users.id, ipObservations.userId))
|
||||||
.leftJoin(minecraftAccounts, eq(minecraftAccounts.id, ipObservations.minecraftAccountId))
|
.leftJoin(minecraftAccounts, eq(minecraftAccounts.id, ipObservations.minecraftAccountId))
|
||||||
.where(inArray(ipObservations.classification, ["vpn", "proxy", "tor"]))
|
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||||
.orderBy(desc(ipObservations.observedAt))
|
.where(and(
|
||||||
.limit(10),
|
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(
|
db.select({ count: count() }).from(events).where(and(
|
||||||
eq(events.type, "games.minecraft.account-manager.game.login.denied"),
|
eq(events.type, "games.minecraft.account-manager.game.login.denied"),
|
||||||
gte(events.time, oneDayAgo),
|
gte(events.time, oneDayAgo),
|
||||||
)),
|
)),
|
||||||
]);
|
]);
|
||||||
const registrations = fillDailySeries(registrationRows as DailyCount[], now, 14);
|
const dailyActive = fillDailySeries(dailyActiveRows as DailyCount[], now, 14);
|
||||||
|
const riskyActivity = mergeRiskActivity(riskyLatestRows, riskySummaryRows).slice(0, 10);
|
||||||
const locations = locationRows.flatMap((row): UserMapLocation[] => {
|
const locations = locationRows.flatMap((row): UserMapLocation[] => {
|
||||||
const parsed = parseUserLocation(row.intelligence);
|
const parsed = parseUserLocation(row.intelligence);
|
||||||
if (!parsed || !row.userId) return [];
|
if (!parsed || !row.userId) return [];
|
||||||
|
const network = parseUserNetwork(row.intelligence);
|
||||||
return [{
|
return [{
|
||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
name: row.name ?? row.discordUsername,
|
name: row.name ?? row.discordUsername,
|
||||||
discordUsername: row.discordUsername,
|
discordUsername: row.discordUsername,
|
||||||
|
nickname: formatManagedDiscordNickname(row.name ?? row.discordUsername, row.primaryUsername ?? null),
|
||||||
latitude: parsed.latitude,
|
latitude: parsed.latitude,
|
||||||
longitude: parsed.longitude,
|
longitude: parsed.longitude,
|
||||||
location: parsed.label,
|
location: parsed.label,
|
||||||
classification: row.classification,
|
classification: row.classification,
|
||||||
|
networkProvider: network.provider,
|
||||||
|
networkAsn: network.asn,
|
||||||
|
connectionType: network.connectionType,
|
||||||
|
proxy: network.proxy,
|
||||||
source: row.source,
|
source: row.source,
|
||||||
observedAt: row.observedAt,
|
observedAt: row.observedAt,
|
||||||
}];
|
}];
|
||||||
@@ -104,15 +141,15 @@ export default async function AdminDashboardPage() {
|
|||||||
<section aria-label="Key metrics" className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<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="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="Monthly active users" value={monthlyActive?.users ?? 0} detail="Distinct users · 30 days" />
|
||||||
<Metric label="Active Minecraft accounts" value={monthlyActive?.accounts ?? 0} detail="Distinct accounts · 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 />
|
<Metric label="Login denials" value={recentDenials?.count ?? 0} detail="Past 24 hours" accent />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="mt-10 grid gap-8 lg:grid-cols-[1.3fr_0.7fr]">
|
<div className="mt-10 grid gap-8 lg:grid-cols-[1.3fr_0.7fr]">
|
||||||
<RegistrationChart data={registrations} />
|
<DailyActiveChart data={dailyActive} />
|
||||||
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
<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 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 VPN activity</h2></div>
|
<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>
|
<Link className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" href="/admin/events?category=security">All security events</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 divide-y divide-line">
|
<div className="mt-5 divide-y divide-line">
|
||||||
@@ -121,9 +158,9 @@ export default async function AdminDashboardPage() {
|
|||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div>
|
<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>}
|
{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.source}</p>
|
<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>
|
</div>
|
||||||
<span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">{activity.classification}</span>
|
<span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">{activity.classifications.join(" + ")}</span>
|
||||||
</div>
|
</div>
|
||||||
<time className="mt-2 block font-mono text-[9px] text-muted" dateTime={activity.observedAt.toISOString()}>{activity.observedAt.toISOString()}</time>
|
<time className="mt-2 block font-mono text-[9px] text-muted" dateTime={activity.observedAt.toISOString()}>{activity.observedAt.toISOString()}</time>
|
||||||
</article>
|
</article>
|
||||||
@@ -146,7 +183,7 @@ function Metric({ label, value, detail, accent = false }: { label: string; value
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RegistrationChart({ data }: { data: DailyCount[] }) {
|
function DailyActiveChart({ data }: { data: DailyCount[] }) {
|
||||||
const width = 720;
|
const width = 720;
|
||||||
const height = 260;
|
const height = 260;
|
||||||
const padding = 32;
|
const padding = 32;
|
||||||
@@ -159,22 +196,21 @@ function RegistrationChart({ data }: { data: DailyCount[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
<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">Growth signal</p>
|
<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">New users by day</h2>
|
<h2 className="mt-2 font-display text-2xl font-black uppercase">Daily active users</h2>
|
||||||
<svg aria-labelledby="registration-chart-title registration-chart-description" className="mt-6 h-auto w-full" role="img" viewBox={`0 0 ${width} ${height}`}>
|
<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="registration-chart-title">New user registrations over the last 14 days</title>
|
<title id="daily-active-chart-title">Daily active users over the last 14 days</title>
|
||||||
<desc id="registration-chart-description">Daily registrations range from zero to {maximum}. A text summary follows the chart.</desc>
|
<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} />
|
<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" />
|
<polyline fill="none" points={points} stroke="var(--accent)" strokeLinecap="square" strokeLinejoin="miter" strokeWidth="4" />
|
||||||
{data.map((entry, index) => {
|
{data.map((entry, index) => {
|
||||||
const [x, y] = points.split(" ")[index]!.split(",");
|
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} new users</title></circle>;
|
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>
|
</svg>
|
||||||
<dl className="mt-4 grid grid-cols-7 gap-2 border-t border-line pt-4 text-center">
|
<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="sr-only">{entry.day}</dt><dd className="font-mono text-xs font-bold">{entry.count}</dd></div>)}
|
{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>
|
</dl>
|
||||||
<div aria-hidden="true" className="mt-2 flex justify-between font-mono text-[9px] text-muted"><span>{data[0]?.day}</span><span>{data.at(-1)?.day}</span></div>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
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> }>,
|
||||||
|
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>,
|
||||||
|
) => {
|
||||||
|
actionState.audits.push({ admin, subject, type, data });
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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.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 credential-safe 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({ verb: "say", commandDigest: "hmac-sha256:v1:digest" }),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
subject: `rcon-server/${serverId}`,
|
||||||
|
type: "games.minecraft.account-manager.rcon.command.completed",
|
||||||
|
data: expect.objectContaining({ success: true, durationMs: expect.any(Number) }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const serializedAudits = JSON.stringify(actionState.audits);
|
||||||
|
expect(serializedAudits).not.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 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,256 @@
|
|||||||
|
"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();
|
||||||
|
|
||||||
|
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.requested", {
|
||||||
|
serverId: server.id,
|
||||||
|
name: server.name,
|
||||||
|
verb,
|
||||||
|
commandDigest,
|
||||||
|
});
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { appSettings } from "@minecraft-account-manager/database";
|
import { appSettings } from "@minecraft-account-manager/database";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { DEFAULT_ADMISSION_MESSAGES } from "@/lib/admission-settings";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
import { saveDiscordSettings } from "../actions";
|
import { saveAdmissionSettings } from "../actions";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -12,7 +13,12 @@ export default async function SettingsPage({
|
|||||||
}) {
|
}) {
|
||||||
const query = await searchParams;
|
const query = await searchParams;
|
||||||
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
||||||
const message = settings?.registrationMessage ?? "Please register your Minecraft account before joining.";
|
const messages = {
|
||||||
|
registrationMessage: settings?.registrationMessage ?? DEFAULT_ADMISSION_MESSAGES.registrationMessage,
|
||||||
|
groupAccessDeniedMessage: settings?.groupAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.groupAccessDeniedMessage,
|
||||||
|
vpnDeniedMessage: settings?.vpnDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.vpnDeniedMessage,
|
||||||
|
scheduledAccessDeniedMessage: settings?.scheduledAccessDeniedMessage ?? DEFAULT_ADMISSION_MESSAGES.scheduledAccessDeniedMessage,
|
||||||
|
};
|
||||||
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
||||||
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
|
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
|
||||||
|
|
||||||
@@ -29,23 +35,51 @@ export default async function SettingsPage({
|
|||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form action={saveDiscordSettings} className="border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)] sm:p-9">
|
<form action={saveAdmissionSettings} className="border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)] sm:p-9">
|
||||||
{query.saved && <p className="mb-6 border-l-2 border-signal pl-4 font-mono text-xs font-bold uppercase tracking-wider" role="status">Settings saved</p>}
|
{query.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>}
|
{query.error && <p className="mb-6 border-l-2 border-accent pl-4 text-sm" role="alert">Check the configuration value and try again.</p>}
|
||||||
|
|
||||||
<label className="block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="registrationMessage">Denied-player message</label>
|
<fieldset className="space-y-7">
|
||||||
<textarea
|
<legend className="font-display text-2xl font-black uppercase">Minecraft denial messages</legend>
|
||||||
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"
|
<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>
|
||||||
defaultValue={message}
|
<AdmissionMessageField description="Shown when the Minecraft identity is not registered. The unresolved group is everyone." label="Registration required" name="registrationMessage" value={messages.registrationMessage} />
|
||||||
id="registrationMessage"
|
<AdmissionMessageField description="Shown when the effective group has Minecraft access disabled." label="Group access disabled" name="groupAccessDeniedMessage" value={messages.groupAccessDeniedMessage} />
|
||||||
maxLength={500}
|
<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} />
|
||||||
minLength={10}
|
<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} />
|
||||||
name="registrationMessage"
|
</fieldset>
|
||||||
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>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</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,10 +1,10 @@
|
|||||||
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
|
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
|
||||||
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
|
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||||
import { events, groups, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
import { events, groups, ipIntelligence, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||||
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { groupAccessAddresses } from "@/lib/access-address-groups";
|
import { accessAddressDetails, groupAccessAddresses } from "@/lib/access-address-groups";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
import { discordIdentity } from "@/lib/discord-identity";
|
import { discordIdentity } from "@/lib/discord-identity";
|
||||||
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
|
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
|
||||||
@@ -80,13 +80,21 @@ export default async function AdminUserPage({
|
|||||||
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
|
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
|
||||||
recentEventsQuery,
|
recentEventsQuery,
|
||||||
db
|
db
|
||||||
.select()
|
.select({
|
||||||
|
id: ipObservations.id,
|
||||||
|
ipAddress: ipObservations.ipAddress,
|
||||||
|
source: ipObservations.source,
|
||||||
|
classification: ipObservations.classification,
|
||||||
|
observedAt: ipObservations.observedAt,
|
||||||
|
intelligence: ipIntelligence.rawResponse,
|
||||||
|
})
|
||||||
.from(ipObservations)
|
.from(ipObservations)
|
||||||
|
.leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||||
.where(eq(ipObservations.userId, user.id))
|
.where(eq(ipObservations.userId, user.id))
|
||||||
.orderBy(desc(ipObservations.observedAt))
|
.orderBy(desc(ipObservations.observedAt))
|
||||||
.limit(100),
|
.limit(100),
|
||||||
discordIdentity(user),
|
discordIdentity(user),
|
||||||
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, isDefault: groups.isDefault })
|
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed, isDefault: groups.isDefault })
|
||||||
.from(groups)
|
.from(groups)
|
||||||
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
|
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
|
||||||
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
|
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
|
||||||
@@ -95,9 +103,7 @@ export default async function AdminUserPage({
|
|||||||
const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null;
|
const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null;
|
||||||
const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null;
|
const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null;
|
||||||
const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup);
|
const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup);
|
||||||
const addressGroups = groupAccessAddresses(
|
const addressGroups = groupAccessAddresses(observations);
|
||||||
observations.map((observation) => ({ ...observation, intelligence: null })),
|
|
||||||
);
|
|
||||||
const primary = accounts.find((account) => account.isPrimary);
|
const primary = accounts.find((account) => account.isPrimary);
|
||||||
const nickname = user.firstName
|
const nickname = user.firstName
|
||||||
? formatManagedDiscordNickname(user.firstName, primary?.username ?? null)
|
? formatManagedDiscordNickname(user.firstName, primary?.username ?? null)
|
||||||
@@ -208,14 +214,16 @@ export default async function AdminUserPage({
|
|||||||
|
|
||||||
<section className="border border-line bg-panel p-6">
|
<section className="border border-line bg-panel p-6">
|
||||||
<div className="flex items-center justify-between gap-3"><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Access groups</p><Link className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" href="/admin/groups">Manage</Link></div>
|
<div className="flex items-center justify-between gap-3"><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Access groups</p><Link className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" href="/admin/groups">Manage</Link></div>
|
||||||
{effectiveGroup ? <div className="mt-4 flex items-center justify-between gap-3"><Link className="font-mono text-xs font-bold underline decoration-line underline-offset-4" href={`/admin/groups/${effectiveGroup.id}`}>{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}</Link><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${effectiveGroup.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{effectiveGroup.accessEnabled ? "On" : "Off"}</span></div> : <p className="mt-4 text-sm text-accent">No effective group configured.</p>}
|
{effectiveGroup ? <div className="mt-4 flex flex-wrap items-center justify-between gap-3"><Link className="font-mono text-xs font-bold underline decoration-line underline-offset-4" href={`/admin/groups/${effectiveGroup.id}`}>{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}</Link><div className="flex gap-2"><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${effectiveGroup.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{effectiveGroup.accessEnabled ? "Access on" : "Access off"}</span><span className="border border-line px-2 py-1 font-mono text-[9px] font-bold uppercase">VPN {effectiveGroup.anonymizedNetworksAllowed ? "allowed" : "denied"}</span></div></div> : <p className="mt-4 text-sm text-accent">No effective group configured.</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="border border-line bg-panel p-6">
|
<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="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>
|
<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">
|
<div className="mt-4 divide-y divide-line">
|
||||||
{addressGroups.map((group) => (
|
{addressGroups.map((group) => {
|
||||||
|
const details = accessAddressDetails(group);
|
||||||
|
return (
|
||||||
<div className="py-3" key={group.network}>
|
<div className="py-3" key={group.network}>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="flex items-center justify-between gap-3">
|
||||||
<p className="font-mono text-xs font-bold">{group.network}</p>
|
<p className="font-mono text-xs font-bold">{group.network}</p>
|
||||||
@@ -223,8 +231,10 @@ export default async function AdminUserPage({
|
|||||||
</div>
|
</div>
|
||||||
<p className="mt-1 font-mono text-[9px] text-muted">{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}</p>
|
<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 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>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{!addressGroups.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
|
{!addressGroups.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
formatManagedDiscordNickname,
|
formatManagedDiscordNickname,
|
||||||
lookupJavaProfile,
|
lookupJavaProfile,
|
||||||
updateGuildNickname,
|
updateGuildNickname,
|
||||||
} from "@minecraft-account-manager/minecraft";
|
} from "@minecraft-account-manager/minecraft";
|
||||||
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
|
import { events, groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||||
import { and, eq, isNull, ne } from "drizzle-orm";
|
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 { redirect } from "next/navigation";
|
||||||
import { recordAdminEvent } from "@/lib/audit";
|
import { recordAdminEvent } from "@/lib/audit";
|
||||||
import { requireAdminSession } from "@/lib/auth/require-admin";
|
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
|
import { adminGroupReturnPath } from "@/lib/group-management";
|
||||||
|
|
||||||
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
|
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;
|
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) {
|
export async function updateUserName(formData: FormData) {
|
||||||
const admin = await requireAdminSession();
|
const admin = await requireAdminSession();
|
||||||
const userId = String(formData.get("userId") ?? "");
|
const userId = String(formData.get("userId") ?? "");
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
|
import { groups, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||||
import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
||||||
import Link from "next/link";
|
import { AdminUserTable } from "@/components/admin-user-table";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
|
import { assignUserGroupFromRegistry } from "./actions";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function AdminUsersPage({
|
export default async function AdminUsersPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ q?: string; error?: string }>;
|
searchParams: Promise<{ q?: string; error?: string; saved?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const query = await searchParams;
|
const query = await searchParams;
|
||||||
const search = query.q?.trim().slice(0, 100) ?? "";
|
const search = query.q?.trim().slice(0, 100) ?? "";
|
||||||
@@ -31,7 +32,8 @@ export default async function AdminUsersPage({
|
|||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const results = await db
|
const [results, allGroups, memberships] = await Promise.all([
|
||||||
|
db
|
||||||
.select({
|
.select({
|
||||||
id: users.id,
|
id: users.id,
|
||||||
firstName: users.firstName,
|
firstName: users.firstName,
|
||||||
@@ -57,7 +59,14 @@ export default async function AdminUsersPage({
|
|||||||
)
|
)
|
||||||
.where(where)
|
.where(where)
|
||||||
.orderBy(users.firstName, users.discordUsername)
|
.orderBy(users.firstName, users.discordUsername)
|
||||||
.limit(100);
|
.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 (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||||
@@ -79,27 +88,11 @@ export default async function AdminUsersPage({
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">The requested user could not be found.</p>}
|
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{query.error === "invalid-group-assignment" ? "The user or group no longer exists. No group change was applied." : "The requested user could not be found."}</p>}
|
||||||
|
{query.saved === "group" && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">User group updated.</p>}
|
||||||
|
|
||||||
<div className="mt-8 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
<div className="mt-8">
|
||||||
<table className="w-full min-w-[760px] border-collapse text-left">
|
<AdminUserTable action={assignUserGroupFromRegistry} assignmentByUser={assignmentByUser} emptyMessage="No users match that search." groups={allGroups} returnTo={returnTo} users={results} />
|
||||||
<caption className="sr-only">Registered portal users</caption>
|
|
||||||
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
|
|
||||||
<tr><th className="p-4" scope="col">User</th><th className="p-4" scope="col">Discord</th><th className="p-4" scope="col">Primary</th><th className="p-4" scope="col">Accounts</th><th className="p-4" scope="col">Status</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-line">
|
|
||||||
{results.map((user) => (
|
|
||||||
<tr className="transition-colors hover:bg-canvas/60" key={user.id}>
|
|
||||||
<th className="p-4 text-left" scope="row"><Link className="font-display text-lg font-black underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? "Name needed"}</Link></th>
|
|
||||||
<td className="p-4"><div className="font-mono text-xs font-bold">{user.discordGlobalName ?? user.discordUsername}</div><div className="mt-1 font-mono text-[10px] text-muted">@{user.discordUsername}</div><div className="mt-1 font-mono text-[9px] text-muted">{user.discordUserId}</div></td>
|
|
||||||
<td className="p-4 font-mono text-xs">{user.primaryUsername ?? "—"}</td>
|
|
||||||
<td className="p-4 font-mono text-xs">{user.accountCount}</td>
|
|
||||||
<td className="p-4"><span className={`border px-2 py-1 font-mono text-[9px] uppercase tracking-wider ${user.onboardingCompletedAt ? "border-line text-muted" : "border-accent text-accent"}`}>{user.onboardingCompletedAt ? "Ready" : "Onboarding"}</span></td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{!results.length && <tr><td className="p-8 text-muted" colSpan={5}>No users match that search.</td></tr>}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 font-mono text-[9px] uppercase tracking-widest text-muted">Showing up to 100 users</p>
|
<p className="mt-4 font-mono text-[9px] uppercase tracking-widest text-muted">Showing up to 100 users</p>
|
||||||
</main>
|
</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" }),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { problemDetails, velocityAccessRequestSchema } from "@minecraft-account-
|
|||||||
import {
|
import {
|
||||||
appSettings,
|
appSettings,
|
||||||
events,
|
events,
|
||||||
|
groupAccessWindows,
|
||||||
groups,
|
groups,
|
||||||
ipObservations,
|
ipObservations,
|
||||||
minecraftAccounts,
|
minecraftAccounts,
|
||||||
@@ -13,14 +14,15 @@ import {
|
|||||||
} from "@minecraft-account-manager/database";
|
} from "@minecraft-account-manager/database";
|
||||||
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
import { and, eq, isNull, lt, sql } from "drizzle-orm";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { admissionDenialMessage, DEFAULT_ADMISSION_MESSAGES, renderAdmissionMessage } from "@/lib/admission-settings";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
import { isUniqueConstraintViolation } from "@/lib/database-errors";
|
import { isUniqueConstraintViolation } from "@/lib/database-errors";
|
||||||
|
import { evaluateRegisteredPlayerAdmission } from "@/lib/game-admission-policy";
|
||||||
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
||||||
|
|
||||||
const MAX_CLOCK_SKEW_MS = 45_000;
|
const MAX_CLOCK_SKEW_MS = 45_000;
|
||||||
const DEFAULT_DENIAL_MESSAGE = "Please register your Minecraft account before joining.";
|
|
||||||
|
|
||||||
function methodNotAllowed(request: Request) {
|
function methodNotAllowed(request: Request) {
|
||||||
const response = problemResponse(problemDetails(
|
const response = problemResponse(problemDetails(
|
||||||
@@ -111,36 +113,16 @@ async function handleVelocityAccess(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
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
|
const intelligence = await getIpIntelligence(input.ipAddress);
|
||||||
.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 auditIpData = toAuditIpData(intelligence);
|
const auditIpData = toAuditIpData(intelligence);
|
||||||
|
const decisionAt = new Date();
|
||||||
|
|
||||||
const decision = await db.transaction(async (tx) => {
|
const decision = await db.transaction(async (tx) => {
|
||||||
await tx.delete(pluginRequests).where(lt(pluginRequests.expiresAt, new Date()));
|
await tx.delete(pluginRequests).where(lt(pluginRequests.expiresAt, new Date()));
|
||||||
@@ -209,23 +191,43 @@ async function handleVelocityAccess(request: Request) {
|
|||||||
classification: intelligence.classification,
|
classification: intelligence.classification,
|
||||||
observedAt: occurredAt,
|
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
|
const [explicitGroup] = await tx
|
||||||
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled })
|
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed })
|
||||||
.from(userGroupMemberships)
|
.from(userGroupMemberships)
|
||||||
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
||||||
.where(eq(userGroupMemberships.userId, account.userId))
|
.where(eq(userGroupMemberships.userId, account.userId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const [defaultGroup] = await tx
|
const [defaultGroup] = await tx
|
||||||
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled })
|
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, anonymizedNetworksAllowed: groups.anonymizedNetworksAllowed })
|
||||||
.from(groups)
|
.from(groups)
|
||||||
.where(eq(groups.isDefault, true))
|
.where(eq(groups.isDefault, true))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
const effectiveGroup = resolveEffectiveGroup(explicitGroup ?? null, defaultGroup ?? null);
|
const effectiveGroup = resolveEffectiveGroup(explicitGroup ?? null, defaultGroup ?? null);
|
||||||
|
const accessWindows = effectiveGroup
|
||||||
if (!effectiveGroup?.accessEnabled) {
|
? 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({
|
await tx.insert(events).values({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
source: `/velocity/${input.serverId}`,
|
source: `/velocity/${input.serverId}`,
|
||||||
@@ -235,7 +237,13 @@ async function handleVelocityAccess(request: Request) {
|
|||||||
actorUserId: account.userId,
|
actorUserId: account.userId,
|
||||||
data: {
|
data: {
|
||||||
username: input.username,
|
username: input.username,
|
||||||
reason: "group_access_disabled",
|
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,
|
ipIntelligence: auditIpData,
|
||||||
},
|
},
|
||||||
ipAddress: input.ipAddress,
|
ipAddress: input.ipAddress,
|
||||||
@@ -251,9 +259,14 @@ async function handleVelocityAccess(request: Request) {
|
|||||||
classification: intelligence.classification,
|
classification: intelligence.classification,
|
||||||
observedAt: occurredAt,
|
observedAt: occurredAt,
|
||||||
});
|
});
|
||||||
return { allowed: false as const, message: "Your account group does not currently have server access." };
|
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) {
|
if (account.minecraftUuid !== input.minecraftUuid || account.username !== input.username) {
|
||||||
await tx
|
await tx
|
||||||
.update(minecraftAccounts)
|
.update(minecraftAccounts)
|
||||||
@@ -304,13 +317,14 @@ async function handleVelocityAccess(request: Request) {
|
|||||||
uuidBackfilled: account.minecraftUuid === null,
|
uuidBackfilled: account.minecraftUuid === null,
|
||||||
ipIntelligence: auditIpData,
|
ipIntelligence: auditIpData,
|
||||||
accessGroup: effectiveGroup.name,
|
accessGroup: effectiveGroup.name,
|
||||||
|
accessGroupId: effectiveGroup.id,
|
||||||
},
|
},
|
||||||
ipAddress: input.ipAddress,
|
ipAddress: input.ipAddress,
|
||||||
correlationId: input.requestId,
|
correlationId: input.requestId,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { allowed: true as const, message: "Account approved." };
|
return { allowed: true as const, message: "Account approved." };
|
||||||
});
|
}, { isolationLevel: "repeatable read" });
|
||||||
|
|
||||||
return NextResponse.json(decision);
|
return NextResponse.json(decision);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
@import "leaflet/dist/leaflet.css";
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
@@ -64,6 +65,28 @@ svg a:focus .map-marker {
|
|||||||
stroke-width: 6px;
|
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 {
|
::selection {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--panel);
|
color: var(--panel);
|
||||||
|
|||||||
@@ -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,59 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@/app/admin/(console)/rcon/actions", () => ({
|
||||||
|
createRconServer: vi.fn(),
|
||||||
|
deleteRconServer: vi.fn(),
|
||||||
|
executeRconCommand: vi.fn(),
|
||||||
|
setRconServerEnabled: vi.fn(),
|
||||||
|
testSavedRconServer: vi.fn(),
|
||||||
|
updateRconServer: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { RconConsole } from "./rcon-console";
|
||||||
|
|
||||||
|
const server = {
|
||||||
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
name: "Season 4",
|
||||||
|
host: "season4.somc.svc.cluster.local",
|
||||||
|
port: 25575,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
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("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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, 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: "" };
|
||||||
|
|
||||||
|
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 selected = servers.find((server) => server.id === selectedId) ?? servers[0];
|
||||||
|
const responseServer = servers.find((server) => server.id === state.serverId);
|
||||||
|
const output = terminalOutput({ notice, pending, responseServer, selected, state });
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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-live="polite" aria-relevant="additions text" className="min-h-72 max-h-[32rem] overflow-auto p-5 font-mono text-xs leading-5" role={output.status === "error" ? "alert" : "status"}>
|
||||||
|
<p className={`text-[9px] font-bold uppercase tracking-wider ${output.status === "error" ? "text-accent" : "text-muted"}`}>{output.label}</p>
|
||||||
|
<pre className="mt-3 whitespace-pre-wrap break-words font-mono text-xs leading-5">{output.message}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3">
|
||||||
|
<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"
|
||||||
|
placeholder={selected ? (selected.enabled ? "list" : "Enable this connection to run commands") : "Add a connection to begin"}
|
||||||
|
required
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
<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 terminalOutput({
|
||||||
|
notice,
|
||||||
|
pending,
|
||||||
|
responseServer,
|
||||||
|
selected,
|
||||||
|
state,
|
||||||
|
}: {
|
||||||
|
notice?: RconTerminalNotice;
|
||||||
|
pending: boolean;
|
||||||
|
responseServer?: RconServerOption;
|
||||||
|
selected?: RconServerOption;
|
||||||
|
state: RconCommandState;
|
||||||
|
}) {
|
||||||
|
if (pending) return { status: "success" as const, label: "Executing", message: "Command in progress…" };
|
||||||
|
if (state.status !== "idle") return {
|
||||||
|
status: state.status,
|
||||||
|
label: `${state.status === "error" ? "Error" : "Response"}${responseServer ? ` — ${responseServer.name}` : ""}`,
|
||||||
|
message: state.message,
|
||||||
|
};
|
||||||
|
if (notice) return { ...notice, label: notice.status === "error" ? "Connection error" : "Connection update" };
|
||||||
|
if (!selected) return { status: "success" as const, label: "Ready", message: "No connections configured. Use Add to create a server connection." };
|
||||||
|
if (!selected.enabled) return { status: "error" as const, label: `Disabled — ${selected.name}`, message: "Enable this connection before testing commands." };
|
||||||
|
return { status: "success" as const, label: `Ready — ${selected.name}`, message: "Awaiting command" };
|
||||||
|
}
|
||||||
|
|
||||||
|
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,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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,19 +8,58 @@ describe("UserWorldMap", () => {
|
|||||||
userId: "11111111-1111-4111-8111-111111111111",
|
userId: "11111111-1111-4111-8111-111111111111",
|
||||||
name: "Dani",
|
name: "Dani",
|
||||||
discordUsername: "dani",
|
discordUsername: "dani",
|
||||||
|
nickname: "Dani (Steve)",
|
||||||
latitude: 37.4056,
|
latitude: 37.4056,
|
||||||
longitude: -122.0775,
|
longitude: -122.0775,
|
||||||
location: "Mountain View, California, US",
|
location: "Mountain View, California, US",
|
||||||
classification: "clear",
|
classification: "clear",
|
||||||
|
networkProvider: "Comcast Cable Communications, LLC",
|
||||||
|
networkAsn: "AS7922",
|
||||||
|
connectionType: "Residential",
|
||||||
|
proxy: false,
|
||||||
source: "game",
|
source: "game",
|
||||||
observedAt: new Date("2026-08-01T12:00:00Z"),
|
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} />);
|
}]} unavailableCount={2} />);
|
||||||
|
|
||||||
expect(markup).toContain('role="group"');
|
expect(markup).toContain('role="group"');
|
||||||
expect(markup).toContain('class="map-marker-target"');
|
expect(markup).toContain('class="map-marker-target"');
|
||||||
expect(markup).toContain("Latest approximate location for registered users");
|
expect(markup).toContain("Latest approximate location for registered users");
|
||||||
expect(markup).toContain('href="/admin/users/11111111-1111-4111-8111-111111111111"');
|
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("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("Natural Earth, public domain");
|
||||||
expect(markup).toContain("2 without coordinates");
|
expect(markup).toContain("2 without coordinates");
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { geoEquirectangular, geoPath } from "d3-geo";
|
|||||||
import { feature } from "topojson-client";
|
import { feature } from "topojson-client";
|
||||||
import countriesTopologyJson from "world-atlas/countries-110m.json";
|
import countriesTopologyJson from "world-atlas/countries-110m.json";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { groupMapLocations } from "@/lib/user-location-map";
|
||||||
|
import { MapViewToggle } from "./map-view-toggle";
|
||||||
|
|
||||||
const WIDTH = 1_000;
|
const WIDTH = 1_000;
|
||||||
const HEIGHT = 500;
|
const HEIGHT = 500;
|
||||||
@@ -16,15 +18,21 @@ export interface UserMapLocation {
|
|||||||
userId: string;
|
userId: string;
|
||||||
name: string;
|
name: string;
|
||||||
discordUsername: string;
|
discordUsername: string;
|
||||||
|
nickname: string;
|
||||||
latitude: number;
|
latitude: number;
|
||||||
longitude: number;
|
longitude: number;
|
||||||
location: string;
|
location: string;
|
||||||
classification: string;
|
classification: string;
|
||||||
|
networkProvider: string | null;
|
||||||
|
networkAsn: string | null;
|
||||||
|
connectionType: string | null;
|
||||||
|
proxy: boolean | null;
|
||||||
source: string;
|
source: string;
|
||||||
observedAt: Date;
|
observedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserWorldMap({ locations, unavailableCount }: { locations: UserMapLocation[]; unavailableCount: number }) {
|
export function UserWorldMap({ locations, unavailableCount }: { locations: UserMapLocation[]; unavailableCount: number }) {
|
||||||
|
const locationGroups = groupMapLocations(locations);
|
||||||
return (
|
return (
|
||||||
<section className="mt-8 border border-line bg-panel p-5 shadow-[8px_8px_0_var(--color-shadow)] sm:p-7">
|
<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 className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
@@ -35,7 +43,8 @@ export function UserWorldMap({ locations, unavailableCount }: { locations: UserM
|
|||||||
<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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 overflow-hidden border border-line bg-[#b9d4d1]">
|
<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}`}>
|
<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>
|
<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>
|
<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>
|
||||||
@@ -47,34 +56,58 @@ export function UserWorldMap({ locations, unavailableCount }: { locations: UserM
|
|||||||
})}
|
})}
|
||||||
</g>
|
</g>
|
||||||
<g>
|
<g>
|
||||||
{locations.map((user) => {
|
{locationGroups.map((group) => {
|
||||||
const projected = projection([user.longitude, user.latitude]);
|
const projected = projection([group.longitude, group.latitude]);
|
||||||
if (!projected) return null;
|
if (!projected) return null;
|
||||||
const x = Math.min(WIDTH - 14, Math.max(14, projected[0]));
|
const x = Math.min(WIDTH - 16, Math.max(16, projected[0]));
|
||||||
const y = Math.min(HEIGHT - 14, Math.max(14, projected[1]));
|
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 (
|
return (
|
||||||
<a aria-label={`${user.name}, ${user.location}, last seen ${user.observedAt.toISOString()}`} href={`/admin/users/${user.userId}`} key={user.userId}>
|
<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="7" stroke="transparent" strokeWidth="24" vectorEffect="non-scaling-stroke" />
|
<circle className="map-marker-target" cx={x} cy={y} fill="none" pointerEvents="stroke" r={markerRadius} stroke="transparent" strokeWidth="24" vectorEffect="non-scaling-stroke">
|
||||||
<circle className="map-marker" cx={x} cy={y} fill="var(--accent)" pointerEvents="none" r="7" stroke="var(--panel)" strokeWidth="3">
|
<title>{label}</title>
|
||||||
<title>{user.name} · {user.location} · {user.classification}</title>
|
|
||||||
</circle>
|
</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>
|
</a>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
</MapViewToggle>
|
||||||
<p className="mt-2 text-right font-mono text-[9px] text-muted">Map boundaries: Natural Earth, public domain</p>
|
<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">
|
<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>
|
<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">
|
<div className="mt-4 overflow-x-auto">
|
||||||
<table className="w-full min-w-[680px] border-collapse text-left text-xs">
|
<table className="w-full min-w-[980px] border-collapse text-left text-xs">
|
||||||
<caption className="sr-only">Latest approximate registered-user locations</caption>
|
<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">Source</th><th className="py-3 pl-4" scope="col">Last observed</th></tr></thead>
|
<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">
|
<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.name}</Link><span className="mt-1 block font-mono text-[9px] font-normal text-muted">@{user.discordUsername}</span></th><td className="p-3">{user.location}</td><td className="p-3 font-mono uppercase">{user.classification}</td><td className="p-3">{user.source}</td><td className="py-3 pl-4 font-mono text-[9px]"><time dateTime={user.observedAt.toISOString()}>{user.observedAt.toISOString()}</time></td></tr>)}
|
{locations.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={5}>No user observations currently include valid coordinates.</td></tr>}
|
{!locations.length && <tr><td className="py-6 text-muted" colSpan={8}>No user observations currently include valid coordinates.</td></tr>}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { groupAccessAddresses } from "./access-address-groups";
|
import { accessAddressDetails, groupAccessAddresses } from "./access-address-groups";
|
||||||
|
|
||||||
describe("groupAccessAddresses", () => {
|
describe("groupAccessAddresses", () => {
|
||||||
it("collapses repeated observations from the same network into one recent summary", () => {
|
it("collapses repeated observations from the same network into one recent summary", () => {
|
||||||
@@ -20,4 +20,19 @@ describe("groupAccessAddresses", () => {
|
|||||||
});
|
});
|
||||||
expect(groups[0]?.latestObservedAt.toISOString()).toBe("2026-08-01T12:00:00.000Z");
|
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",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { addressGroup } from "@minecraft-account-manager/network";
|
import { addressGroup } from "@minecraft-account-manager/network";
|
||||||
|
import { intelligenceSummary } from "./event-ip-summary";
|
||||||
|
|
||||||
type AccessObservation = {
|
type AccessObservation = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -9,6 +10,14 @@ type AccessObservation = {
|
|||||||
intelligence: Record<string, unknown> | null;
|
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 = {
|
export type AccessAddressGroup = {
|
||||||
network: string;
|
network: string;
|
||||||
latestAddress: string;
|
latestAddress: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { fillDailySeries } from "./admin-metrics";
|
import { fillDailySeries, mergeRiskActivity } from "./admin-metrics";
|
||||||
|
|
||||||
describe("admin dashboard metrics", () => {
|
describe("admin dashboard metrics", () => {
|
||||||
it("fills missing UTC registration days with zero", () => {
|
it("fills missing UTC registration days with zero", () => {
|
||||||
@@ -13,4 +13,20 @@ describe("admin dashboard metrics", () => {
|
|||||||
{ day: "2026-08-01", count: 1 },
|
{ 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" }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,19 @@ export interface DailyCount {
|
|||||||
count: number;
|
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) {
|
export function fillDailySeries(rows: DailyCount[], end: Date, days: number) {
|
||||||
const counts = new Map(rows.map((row) => [row.day, Number(row.count)]));
|
const counts = new Map(rows.map((row) => [row.day, Number(row.count)]));
|
||||||
const endDay = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate()));
|
const endDay = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate()));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ describe("event filters", () => {
|
|||||||
it("classifies events into operator-friendly views", () => {
|
it("classifies events into operator-friendly views", () => {
|
||||||
expect(eventCategory("games.minecraft.account-manager.group.deleted")).toBe("groups");
|
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.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.network.vpn-blocked")).toBe("security");
|
||||||
expect(eventCategory("games.minecraft.account-manager.auth.magic-link.consumed")).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");
|
expect(eventCategory("games.minecraft.account-manager.discord.nickname.updated")).toBe("identity");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ export type EventCategory = (typeof eventCategoryValues)[number];
|
|||||||
export function eventCategory(type: string): Exclude<EventCategory, "all"> {
|
export function eventCategory(type: string): Exclude<EventCategory, "all"> {
|
||||||
if (type.includes(".group.")) return "groups";
|
if (type.includes(".group.")) return "groups";
|
||||||
if (type.includes(".network.") || type.includes(".auth.") || type.includes("authentication") || type.includes("replay")) return "security";
|
if (type.includes(".network.") || type.includes(".auth.") || type.includes("authentication") || type.includes("replay")) return "security";
|
||||||
if (type.includes(".game.login.")) return "admission";
|
if (type.includes(".game.")) return "admission";
|
||||||
if (type.includes(".discord.") || type.includes(".user.") || type.includes("minecraft-account")) return "identity";
|
if (type.includes(".discord.") || type.includes(".user.") || type.includes("minecraft-account")) return "identity";
|
||||||
return "operations";
|
return "operations";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,7 +1,20 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { parseUserLocation, projectWorldPoint } from "./user-location-map";
|
import {
|
||||||
|
MAP_LOCATION_CLASSIFICATIONS,
|
||||||
|
groupMapLocations,
|
||||||
|
parseUserLocation,
|
||||||
|
parseUserNetwork,
|
||||||
|
projectWorldPoint,
|
||||||
|
} from "./user-location-map";
|
||||||
|
|
||||||
describe("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", () => {
|
it("extracts a valid approximate location from cached IP intelligence", () => {
|
||||||
expect(parseUserLocation({
|
expect(parseUserLocation({
|
||||||
classification: "clear",
|
classification: "clear",
|
||||||
@@ -19,11 +32,56 @@ describe("user location map", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("extracts enriched network fields from existing ProxyCheck cache entries", () => {
|
||||||
|
expect(parseUserNetwork({
|
||||||
|
network: { asn: "AS7922", provider: "Comcast Cable Communications, LLC" },
|
||||||
|
rawResponse: {
|
||||||
|
status: "ok",
|
||||||
|
"203.0.113.10": { type: "Residential", proxy: "no" },
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
asn: "AS7922",
|
||||||
|
provider: "Comcast Cable Communications, LLC",
|
||||||
|
connectionType: "Residential",
|
||||||
|
proxy: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers normalized network fields and preserves unavailable values", () => {
|
||||||
|
expect(parseUserNetwork({
|
||||||
|
network: { asn: "AS62371", provider: "Proton AG", connectionType: "VPN", proxy: true },
|
||||||
|
rawResponse: { "198.51.100.5": { type: "Residential", proxy: "no" } },
|
||||||
|
})).toEqual({ asn: "AS62371", provider: "Proton AG", connectionType: "VPN", proxy: true });
|
||||||
|
expect(parseUserNetwork({ network: {} })).toEqual({ asn: null, provider: null, connectionType: null, proxy: null });
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects missing and out-of-range coordinates", () => {
|
it("rejects missing and out-of-range coordinates", () => {
|
||||||
expect(parseUserLocation({ location: { latitude: 91, longitude: 0 } })).toBeNull();
|
expect(parseUserLocation({ location: { latitude: 91, longitude: 0 } })).toBeNull();
|
||||||
expect(parseUserLocation({ location: { city: "Unknown" } })).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", () => {
|
it("projects longitude and latitude into an equirectangular SVG", () => {
|
||||||
expect(projectWorldPoint(0, 0, 800, 400)).toEqual({ x: 400, y: 200 });
|
expect(projectWorldPoint(0, 0, 800, 400)).toEqual({ x: 400, y: 200 });
|
||||||
expect(projectWorldPoint(90, 180, 800, 400)).toEqual({ x: 800, y: 0 });
|
expect(projectWorldPoint(90, 180, 800, 400)).toEqual({ x: 800, y: 0 });
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
type UnknownMap = Record<string, unknown>;
|
type UnknownMap = Record<string, unknown>;
|
||||||
|
|
||||||
|
export const MAP_LOCATION_CLASSIFICATIONS = ["clear", "hosting"] as const;
|
||||||
|
|
||||||
function objectValue(value: unknown): UnknownMap | null {
|
function objectValue(value: unknown): UnknownMap | null {
|
||||||
return value && typeof value === "object" && !Array.isArray(value)
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
? value as UnknownMap
|
? value as UnknownMap
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyValue(value: unknown) {
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (typeof value === "string" && value.toLowerCase() === "yes") return true;
|
||||||
|
if (typeof value === "string" && value.toLowerCase() === "no") return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function coordinate(value: unknown) {
|
function coordinate(value: unknown) {
|
||||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
||||||
@@ -18,6 +31,28 @@ export interface ParsedUserLocation {
|
|||||||
label: string;
|
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 {
|
export function parseUserLocation(value: unknown): ParsedUserLocation | null {
|
||||||
const intelligence = objectValue(value);
|
const intelligence = objectValue(value);
|
||||||
const location = objectValue(intelligence?.location);
|
const location = objectValue(intelligence?.location);
|
||||||
@@ -33,6 +68,32 @@ export function parseUserLocation(value: unknown): ParsedUserLocation | null {
|
|||||||
return { latitude, longitude, label: label || "Approximate location unavailable" };
|
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) {
|
export function projectWorldPoint(latitude: number, longitude: number, width: number, height: number) {
|
||||||
return {
|
return {
|
||||||
x: ((longitude + 180) / 360) * width,
|
x: ((longitude + 180) / 360) * width,
|
||||||
|
|||||||
+5
-1
@@ -31,7 +31,11 @@ This OKF bundle is the product record for implemented and proposed behavior. Sto
|
|||||||
* [US-015 — Deploy and operate securely](us-015-platform-operations.md) - Operators have reproducible builds, migrations, credentials, and security controls.
|
* [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-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-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 registrations, monthly activity, denials, and risky networks.
|
* [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
|
# Tracking
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,30 @@
|
|||||||
# Design Update Log
|
# Design Update Log
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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
|
## 2026-08-01
|
||||||
|
|
||||||
* **Extend**: Plot each user's latest approximate location on an accessible, server-rendered Natural Earth world map in the operations dashboard.
|
* **Extend**: Plot each user's latest approximate location on an accessible, server-rendered Natural Earth world map in the operations dashboard.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Enrich portal and game login IPs
|
title: Enrich portal and game login IPs
|
||||||
description: Login audit events include cached approximate location and network intelligence from ProxyCheck.io.
|
description: Login audit events include cached approximate location and network intelligence from ProxyCheck.io.
|
||||||
tags: [security, network, audit, proxycheck]
|
tags: [security, network, audit, proxycheck]
|
||||||
timestamp: 2026-08-01T22:04:17Z
|
timestamp: 2026-08-02T14:12:43Z
|
||||||
story_id: US-007
|
story_id: US-007
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -19,7 +19,7 @@ As an operator, I want portal and registered game logins enriched with network c
|
|||||||
- [x] Provider failures are cached briefly and do not deny portal or registered game login.
|
- [x] 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] 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] 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] 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] 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.
|
- [x] Repeated access observations are summarized by IPv4 /24 or IPv6 /64 network with counts, sources, and latest activity.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Block account additions from anonymized networks
|
title: Block account additions from anonymized networks
|
||||||
description: User Minecraft-account additions fail closed for VPN, proxy, Tor, or unknown IP classifications.
|
description: User Minecraft-account additions fail closed for VPN, proxy, Tor, or unknown IP classifications.
|
||||||
tags: [security, vpn, proxy, minecraft]
|
tags: [security, vpn, proxy, minecraft]
|
||||||
timestamp: 2026-08-01T18:43:58Z
|
timestamp: 2026-08-02T14:12:43Z
|
||||||
story_id: US-008
|
story_id: US-008
|
||||||
status: verified
|
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 users receive a clear recovery message without provider internals.
|
||||||
- [x] Blocked and classification-unavailable attempts create distinct audit events with safe intelligence details.
|
- [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] 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
|
# Implementation
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Enforce registration at the Velocity proxy
|
title: Enforce registration at the Velocity proxy
|
||||||
description: Online-mode Java connections are admitted only after a fail-closed account-manager decision.
|
description: Online-mode Java connections are admitted only after a fail-closed account-manager decision.
|
||||||
tags: [minecraft, velocity, whitelist, security]
|
tags: [minecraft, velocity, whitelist, security]
|
||||||
timestamp: 2026-08-01T23:10:59Z
|
timestamp: 2026-08-02T14:12:43Z
|
||||||
story_id: US-009
|
story_id: US-009
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -23,13 +23,22 @@ As a registered player, I want the Velocity proxy to recognize my approved Java
|
|||||||
- [x] Successful fallback backfills UUID and canonical username.
|
- [x] Successful fallback backfills UUID and canonical username.
|
||||||
- [x] Changed usernames are persisted and audited.
|
- [x] Changed usernames are persisted and audited.
|
||||||
- [x] Registered players are allowed only when their single effective group has access enabled; explicit assignments override the default group.
|
- [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] 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] 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
|
# Implementation
|
||||||
|
|
||||||
- [`plugins/velocity`](../plugins/velocity)
|
- [`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/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/contracts/src/index.ts`](../packages/contracts/src/index.ts)
|
||||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Preserve a CloudEvents-style audit trail
|
title: Preserve a CloudEvents-style audit trail
|
||||||
description: Authentication, UI, account, Discord, network, and game actions create searchable immutable-style events.
|
description: Authentication, UI, account, Discord, network, and game actions create searchable immutable-style events.
|
||||||
tags: [audit, cloudevents, security, events]
|
tags: [audit, cloudevents, security, events]
|
||||||
timestamp: 2026-08-01T23:10:59Z
|
timestamp: 2026-08-02T00:12:32Z
|
||||||
story_id: US-010
|
story_id: US-010
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -16,7 +16,7 @@ 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 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] 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] Username changes learned from Velocity create their own event.
|
||||||
- [x] Administrative actions include the acting SSO identity in event data.
|
- [x] Administrative actions include the acting SSO identity in event data.
|
||||||
- [x] Events can be filtered by operator-friendly view and selected event types globally and from an individual user view.
|
- [x] Events can be filtered by operator-friendly view and selected event types globally and from an individual user view.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Operate settings and audit views
|
title: Operate settings and audit views
|
||||||
description: Authorized administrators control server messaging and investigate recent platform events.
|
description: Authorized administrators control server messaging and investigate recent platform events.
|
||||||
tags: [admin, settings, audit, operations]
|
tags: [admin, settings, audit, operations]
|
||||||
timestamp: 2026-08-01T22:34:31Z
|
timestamp: 2026-08-02T14:12:43Z
|
||||||
story_id: US-012
|
story_id: US-012
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -21,11 +21,18 @@ As an administrator, I want operational settings and audit visibility, so that I
|
|||||||
- [x] Event views show type, subject, IP, classification, and approximate location when available.
|
- [x] Event views show type, subject, IP, classification, and approximate location when available.
|
||||||
- [x] Admin console access itself creates an audit event with the SSO identity.
|
- [x] Admin console access itself creates an audit event with the SSO identity.
|
||||||
- [x] Settings, users, and events are linked from the shared admin navigation.
|
- [x] Settings, users, and events are linked from the shared admin navigation.
|
||||||
|
- [x] Administrators can independently configure registration-required, group-access-disabled, schedule-denied, and VPN/proxy/Tor-denied game-message templates.
|
||||||
|
- [x] Registration, group, and network templates accept only `{player}` and `{group}`; schedule templates also accept `{next_start}` and `{next_end}`.
|
||||||
|
- [x] Every template is validated server-side and has a safe default.
|
||||||
|
- [x] Admission-message changes are audited with the administrator identity without logging credentials.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
|
|
||||||
- [`apps/web/src/app/admin/(console)/page.tsx`](../apps/web/src/app/admin/%28console%29/page.tsx)
|
- [`apps/web/src/app/admin/(console)/page.tsx`](../apps/web/src/app/admin/%28console%29/page.tsx)
|
||||||
- [`apps/web/src/app/admin/(console)/actions.ts`](../apps/web/src/app/admin/%28console%29/actions.ts)
|
- [`apps/web/src/app/admin/(console)/actions.ts`](../apps/web/src/app/admin/%28console%29/actions.ts)
|
||||||
|
- [`apps/web/src/lib/admission-settings.ts`](../apps/web/src/lib/admission-settings.ts)
|
||||||
|
- [`packages/database/drizzle/0004_zippy_silver_centurion.sql`](../packages/database/drizzle/0004_zippy_silver_centurion.sql)
|
||||||
|
- [`packages/database/drizzle/0005_young_vertigo.sql`](../packages/database/drizzle/0005_young_vertigo.sql)
|
||||||
- [`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/page.tsx`](../apps/web/src/app/admin/%28console%29/events/page.tsx)
|
||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Manage users as an administrator
|
title: Manage users as an administrator
|
||||||
description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames.
|
description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames.
|
||||||
tags: [admin, users, minecraft, discord]
|
tags: [admin, users, minecraft, discord]
|
||||||
timestamp: 2026-08-01T22:34:31Z
|
timestamp: 2026-08-07T23:02:04Z
|
||||||
story_id: US-013
|
story_id: US-013
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -17,6 +17,9 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
|
|||||||
- [x] Administrators can search by preferred name, Discord username or ID, Minecraft username, or UUID.
|
- [x] Administrators can search by preferred name, Discord username or ID, Minecraft username, or UUID.
|
||||||
- [x] Search results show onboarding state, primary username, and active account count.
|
- [x] Search results show onboarding state, primary username, and active account count.
|
||||||
- [x] A user detail view shows Discord display name, username, guild nickname, immutable ID, active accounts, groups, recent events, and recent IP observations.
|
- [x] A user detail view shows Discord display name, username, guild nickname, immutable ID, active accounts, groups, recent events, and recent IP observations.
|
||||||
|
- [x] Each grouped recent address shows the latest observation's approximate location and classification, including clear, VPN, proxy, Tor, hosting, and unknown classifications.
|
||||||
|
- [x] Missing IP enrichment is labelled as location unavailable and falls back to the stored observation classification.
|
||||||
|
- [x] Address groups use the enrichment associated with their latest observation.
|
||||||
- [x] Administrators can update the preferred name and synchronize Discord.
|
- [x] Administrators can update the preferred name and synchronize Discord.
|
||||||
- [x] Administrators can add Mojang-verified accounts or explicitly override an unverified username.
|
- [x] Administrators can add Mojang-verified accounts or explicitly override an unverified username.
|
||||||
- [x] Administrators can remove an account only after a visible confirmation step.
|
- [x] Administrators can remove an account only after a visible confirmation step.
|
||||||
@@ -24,16 +27,26 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
|
|||||||
- [x] Administrators can set a new primary account and automatically update Discord.
|
- [x] Administrators can set a new primary account and automatically update Discord.
|
||||||
- [x] Every action rechecks role and account ownership and records the acting administrator.
|
- [x] Every action rechecks role and account ownership and records the acting administrator.
|
||||||
- [x] Discord failures do not falsely persist the requested name, primary, or removal change.
|
- [x] Discord failures do not falsely persist the requested name, primary, or removal change.
|
||||||
|
- [x] Each row in the administrator user registry shows the user's effective group in an accessible dropdown.
|
||||||
|
- [x] Selecting a group immediately applies the assignment; selecting `everyone` removes the explicit assignment.
|
||||||
|
- [x] Group changes preserve the active user search and show accessible success or error feedback.
|
||||||
|
- [x] Registry assignment changes revalidate administrator authorization, user existence, and group existence, and audit the previous and new effective groups.
|
||||||
|
- [x] User rows and group-assignment controls are reusable between the Users registry and group-member details.
|
||||||
|
- [x] Group details show only the group's effective members with identity, Discord, primary-account, account-count, status, and group columns.
|
||||||
|
- [x] Changing a user's group requires modal confirmation and choosing `everyone` removes the explicit assignment.
|
||||||
|
- [x] Moving a member to another group removes that user from the current effective-member list after confirmation.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
|
|
||||||
- [`apps/web/src/app/admin/(console)/users/page.tsx`](../apps/web/src/app/admin/%28console%29/users/page.tsx)
|
- [`apps/web/src/app/admin/(console)/users/page.tsx`](../apps/web/src/app/admin/%28console%29/users/page.tsx)
|
||||||
- [`apps/web/src/app/admin/(console)/users/[userId]/page.tsx`](../apps/web/src/app/admin/%28console%29/users/%5BuserId%5D/page.tsx)
|
- [`apps/web/src/app/admin/(console)/users/[userId]/page.tsx`](../apps/web/src/app/admin/%28console%29/users/%5BuserId%5D/page.tsx)
|
||||||
- [`apps/web/src/app/admin/(console)/users/actions.ts`](../apps/web/src/app/admin/%28console%29/users/actions.ts)
|
- [`apps/web/src/app/admin/(console)/users/actions.ts`](../apps/web/src/app/admin/%28console%29/users/actions.ts)
|
||||||
|
- [`apps/web/src/components/user-group-select.tsx`](../apps/web/src/components/user-group-select.tsx)
|
||||||
|
- [`apps/web/src/components/admin-user-table.tsx`](../apps/web/src/components/admin-user-table.tsx)
|
||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
|
|
||||||
Nickname fallback behavior is tested in [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts). Privileged routes pass TypeScript, lint, Semgrep, and production build checks.
|
Nickname fallback behavior is tested in [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts). Latest-observation enrichment and classification fallback are covered by [`apps/web/src/lib/access-address-groups.test.ts`](../apps/web/src/lib/access-address-groups.test.ts). The full 107-test suite, TypeScript, lint, OKF validation, and the production build pass.
|
||||||
|
|
||||||
# Related Stories
|
# Related Stories
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Control Minecraft admission with groups
|
title: Control Minecraft admission with groups
|
||||||
description: Administrators assign users to groups and enable Minecraft access through explicit group policy.
|
description: Administrators assign users to groups and enable Minecraft access through explicit group policy.
|
||||||
tags: [admin, groups, authorization, velocity, security]
|
tags: [admin, groups, authorization, velocity, security]
|
||||||
timestamp: 2026-08-01T23:10:59Z
|
timestamp: 2026-08-02T15:03:59Z
|
||||||
story_id: US-017
|
story_id: US-017
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -19,17 +19,30 @@ As an administrator, I want to organize registered users into access groups, so
|
|||||||
- [x] The `everyone` group remains created with Minecraft access disabled.
|
- [x] The `everyone` group remains created with Minecraft access disabled.
|
||||||
- [x] Administrators can create groups with access disabled by default and move users between groups.
|
- [x] Administrators can create groups with access disabled by default and move users between groups.
|
||||||
- [x] Administrators can enable or disable Minecraft admission for each group.
|
- [x] Administrators can enable or disable Minecraft admission for each group.
|
||||||
|
- [x] Disabled Minecraft admission always denies group members; enabled admission may additionally be restricted by recurring UTC windows.
|
||||||
|
- [x] Groups without windows retain unrestricted scheduling, and groups with windows admit logins only during an active window.
|
||||||
- [x] Admission follows only the user's effective group; default and explicit-group access are never combined.
|
- [x] Admission follows only the user's effective group; default and explicit-group access are never combined.
|
||||||
- [x] Administrators can delete non-default groups, returning affected users to `everyone`.
|
- [x] Administrators can delete non-default groups, returning affected users to `everyone`.
|
||||||
- [x] The protected default group cannot be deleted.
|
- [x] The protected default group cannot be deleted.
|
||||||
- [x] Group creation, membership, and access-policy changes are audited.
|
- [x] Group creation, membership, and access-policy changes are audited.
|
||||||
- [x] Users and administrators can inspect the user's single effective group assignment.
|
- [x] Users and administrators can inspect the user's single effective group assignment.
|
||||||
|
- [x] Every group has an independently configurable VPN/proxy/Tor exception policy.
|
||||||
|
- [x] The protected `everyone` group and newly created groups disallow VPN, proxy, and Tor connections by default.
|
||||||
|
- [x] Confirmed VPN, proxy, or Tor game connections are denied unless the user's single effective group allows anonymized networks.
|
||||||
|
- [x] Clear and hosting classifications are not denied by this group policy, and unavailable intelligence does not independently deny a registered player.
|
||||||
|
- [x] VPN policy changes are authorized server-side and audited.
|
||||||
|
- [x] Group creation can explicitly initialize Minecraft and VPN/proxy/Tor policies while retaining deny-by-default controls.
|
||||||
|
- [x] List and detail policy changes use the same confirmation workflow.
|
||||||
|
- [x] Effective member counts include unassigned users who fall back to `everyone`.
|
||||||
|
- [x] Group names and descriptions are validated and editable server-side.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
|
|
||||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
||||||
- [`packages/database/drizzle/0002_simple_queen_noir.sql`](../packages/database/drizzle/0002_simple_queen_noir.sql)
|
- [`packages/database/drizzle/0002_simple_queen_noir.sql`](../packages/database/drizzle/0002_simple_queen_noir.sql)
|
||||||
- [`packages/database/drizzle/0003_smiling_silver_samurai.sql`](../packages/database/drizzle/0003_smiling_silver_samurai.sql)
|
- [`packages/database/drizzle/0003_smiling_silver_samurai.sql`](../packages/database/drizzle/0003_smiling_silver_samurai.sql)
|
||||||
|
- [`packages/database/drizzle/0004_zippy_silver_centurion.sql`](../packages/database/drizzle/0004_zippy_silver_centurion.sql)
|
||||||
|
- [`packages/database/drizzle/0005_young_vertigo.sql`](../packages/database/drizzle/0005_young_vertigo.sql)
|
||||||
- [`apps/web/src/app/admin/(console)/groups/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/page.tsx)
|
- [`apps/web/src/app/admin/(console)/groups/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/page.tsx)
|
||||||
- [`apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/%5BgroupId%5D/page.tsx)
|
- [`apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/%5BgroupId%5D/page.tsx)
|
||||||
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
---
|
---
|
||||||
type: User Story
|
type: User Story
|
||||||
title: Monitor community account activity
|
title: Monitor community account activity
|
||||||
description: Administrators use a server-rendered dashboard to review registrations, monthly activity, denials, and risky networks.
|
description: Administrators use a server-rendered dashboard to review daily activity, confirmed connections, locations, denials, and risky networks.
|
||||||
tags: [admin, dashboard, metrics, security, ssr]
|
tags: [admin, dashboard, metrics, security, maps, ssr]
|
||||||
timestamp: 2026-08-01T23:32:54Z
|
timestamp: 2026-08-07T22:31:05Z
|
||||||
story_id: US-018
|
story_id: US-018
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -15,16 +15,27 @@ As an administrator, I want an operational dashboard of account and game activit
|
|||||||
# Acceptance Criteria
|
# Acceptance Criteria
|
||||||
|
|
||||||
- [x] The administrator landing page is a dashboard rather than a settings form.
|
- [x] The administrator landing page is a dashboard rather than a settings form.
|
||||||
- [x] An open-data world map plots each user's latest observation with valid approximate coordinates.
|
- [x] A server-rendered Natural Earth overview plots each user's latest non-anonymized observation with valid approximate coordinates, allowing clear and hosting classifications while excluding VPN, proxy, and Tor observations.
|
||||||
- [x] Map markers link to user records and have an accessible text-table equivalent.
|
- [x] When a user's newest coordinate-bearing observation is VPN, proxy, or Tor, the map uses that user's older clear or hosting observation when one exists.
|
||||||
- [x] Natural Earth boundaries are bundled and server-rendered without disclosing map or location requests to a third party.
|
- [x] A user without a coordinate-bearing clear or hosting observation is counted as unavailable on the map.
|
||||||
- [x] The dashboard graphs new registered users by UTC day for the previous 14 days.
|
- [x] Administrators can opt into a zoomable OpenStreetMap view without removing the default overview.
|
||||||
|
- [x] OpenStreetMap tiles load only after the administrator selects the interactive view and retain required attribution.
|
||||||
|
- [x] Map markers show the managed Discord nickname on hover or keyboard focus, link to user records, and have an accessible text-table equivalent.
|
||||||
|
- [x] Users sharing approximate coordinates render as one grouped marker with a visible count in both map views.
|
||||||
|
- [x] Grouped-marker hover and keyboard focus list every managed Discord nickname at that location.
|
||||||
|
- [x] Interactive grouped markers open a popup with links to every corresponding user record.
|
||||||
|
- [x] Single-user markers retain their direct nickname tooltip and user-record link.
|
||||||
|
- [x] The location list identifies the enriched network company and ASN when available.
|
||||||
|
- [x] The location list shows ProxyCheck's connection type separately from its risk classification.
|
||||||
|
- [x] The location list shows the provider's proxy/VPN signal as an explicit Yes or No value.
|
||||||
|
- [x] Unknown is shown only for individual enriched fields that are unavailable, including existing cached responses.
|
||||||
|
- [x] The dashboard graphs distinct daily active users by UTC day for the previous 14 days with understandable date labels.
|
||||||
- [x] Monthly active users count distinct users observed through portal or game activity in the previous 30 days.
|
- [x] Monthly active users count distinct users observed through portal or game activity in the previous 30 days.
|
||||||
- [x] Monthly active Minecraft accounts count distinct linked accounts observed in the previous 30 days.
|
- [x] Monthly active Minecraft accounts count distinct accounts with a confirmed Velocity post-login connection in the previous 30 days.
|
||||||
- [x] The dashboard shows login denials from the previous 24 hours.
|
- [x] The dashboard shows login denials from the previous 24 hours.
|
||||||
- [x] Recent VPN, proxy, and Tor observations link to affected user records.
|
- [x] Recent VPN, proxy, and Tor observations remain available in the separate network-risk section when excluded from map-location selection.
|
||||||
- [x] The graph includes an accessible title, description, point labels, and textual values.
|
- [x] The graph includes an accessible title, description, point labels, and textual values.
|
||||||
- [x] Dashboard queries and rendering execute server-side without client-side data fetching.
|
- [x] Dashboard queries and initial rendering execute server-side; only the opt-in pan-and-zoom map hydrates client-side.
|
||||||
- [x] Deployment-managed guild settings and denial messaging remain available on a dedicated settings page.
|
- [x] Deployment-managed guild settings and denial messaging remain available on a dedicated settings page.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
@@ -33,13 +44,15 @@ As an administrator, I want an operational dashboard of account and game activit
|
|||||||
- [`apps/web/src/app/admin/(console)/settings/page.tsx`](../apps/web/src/app/admin/%28console%29/settings/page.tsx)
|
- [`apps/web/src/app/admin/(console)/settings/page.tsx`](../apps/web/src/app/admin/%28console%29/settings/page.tsx)
|
||||||
- [`apps/web/src/lib/admin-metrics.ts`](../apps/web/src/lib/admin-metrics.ts)
|
- [`apps/web/src/lib/admin-metrics.ts`](../apps/web/src/lib/admin-metrics.ts)
|
||||||
- [`apps/web/src/components/user-world-map.tsx`](../apps/web/src/components/user-world-map.tsx)
|
- [`apps/web/src/components/user-world-map.tsx`](../apps/web/src/components/user-world-map.tsx)
|
||||||
|
- [`apps/web/src/components/map-view-toggle.tsx`](../apps/web/src/components/map-view-toggle.tsx)
|
||||||
- [`apps/web/src/lib/user-location-map.ts`](../apps/web/src/lib/user-location-map.ts)
|
- [`apps/web/src/lib/user-location-map.ts`](../apps/web/src/lib/user-location-map.ts)
|
||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
|
|
||||||
- Missing-day chart behavior is covered by [`apps/web/src/lib/admin-metrics.test.ts`](../apps/web/src/lib/admin-metrics.test.ts).
|
- Missing-day chart behavior and per-user VPN collapsing are covered by [`apps/web/src/lib/admin-metrics.test.ts`](../apps/web/src/lib/admin-metrics.test.ts).
|
||||||
- Coordinate parsing, projection, linked markers, text fallback, and attribution are covered by the user-world-map tests.
|
- Coordinate parsing, the clear/hosting map policy, backward-compatible ProxyCheck network parsing, normalized location grouping, projection, count badges, complete grouped tooltips, linked markers, semantic network columns, text fallback, and attribution are covered by the user-location and user-world-map tests.
|
||||||
- The Next.js production build reports the dashboard and database-backed console pages as dynamic server-rendered routes.
|
- The full test suite passes with 106 tests across 36 files; web type checking and lint pass.
|
||||||
|
- The Next.js production build succeeds and reports the dashboard and database-backed console pages as dynamic server-rendered routes.
|
||||||
|
|
||||||
# Related Stories
|
# Related Stories
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: Manage groups efficiently
|
||||||
|
description: Administrators use concise policy tables, focused group details, and confirmed modal workflows to manage access groups.
|
||||||
|
tags: [admin, groups, usability, authorization]
|
||||||
|
timestamp: 2026-08-02T15:03:59Z
|
||||||
|
story_id: US-019
|
||||||
|
status: verified
|
||||||
|
---
|
||||||
|
|
||||||
|
# User Story
|
||||||
|
|
||||||
|
As an administrator, I want a concise group policy table and focused group details, so that I can manage access without navigating cumbersome controls.
|
||||||
|
|
||||||
|
# Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] The main Groups page lists name, Minecraft access, schedule status, VPN/proxy/Tor access, and effective member count with the default group first and remaining names ordered alphabetically.
|
||||||
|
- [x] Policy controls show their current state and require confirmation in an accessible modal before mutation.
|
||||||
|
- [x] Selecting a group name opens a detail page with its description, policies, and effective members.
|
||||||
|
- [x] Add group opens an accessible modal asking for name, description, Minecraft access, and VPN/proxy/Tor access.
|
||||||
|
- [x] New-group policies default to denied and can be enabled before creation.
|
||||||
|
- [x] Administrators manage only the display name; an internal collision-safe slug is generated automatically.
|
||||||
|
- [x] Administrators can edit group name and description; the protected `everyone` name remains fixed while its description remains editable.
|
||||||
|
- [x] Non-default groups can be deleted only after modal confirmation, returning all affected users to `everyone`.
|
||||||
|
- [x] Group identity, policy, creation, and deletion mutations commit atomically with their audit events.
|
||||||
|
- [x] Modal controls support keyboard operation, focus management, cancellation, and clear pending state.
|
||||||
|
- [x] Group details summarize recurring UTC access windows in the browser's local timezone.
|
||||||
|
- [x] Administrators use a confirmed modal to add, remove, and replace multiple non-overlapping windows.
|
||||||
|
|
||||||
|
# Implementation
|
||||||
|
|
||||||
|
- [`apps/web/src/app/admin/(console)/groups/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/page.tsx)
|
||||||
|
- [`apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/%5BgroupId%5D/page.tsx)
|
||||||
|
- [`apps/web/src/app/admin/(console)/groups/actions.ts`](../apps/web/src/app/admin/%28console%29/groups/actions.ts)
|
||||||
|
- [`apps/web/src/components/admin-modal-form.tsx`](../apps/web/src/components/admin-modal-form.tsx)
|
||||||
|
- [`apps/web/src/components/group-policy-control.tsx`](../apps/web/src/components/group-policy-control.tsx)
|
||||||
|
- [`apps/web/src/components/group-schedule-editor.tsx`](../apps/web/src/components/group-schedule-editor.tsx)
|
||||||
|
- [`apps/web/src/lib/group-management.ts`](../apps/web/src/lib/group-management.ts)
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
|
||||||
|
Native-dialog interaction and pending-state behavior are covered by [`apps/web/src/components/admin-modal-form.test.tsx`](../apps/web/src/components/admin-modal-form.test.tsx). Slug, return-path, protected-name, and effective-membership behavior are covered by [`apps/web/src/lib/group-management.test.ts`](../apps/web/src/lib/group-management.test.ts). TypeScript, lint, accessibility review, Semgrep, production build, and OKF validation pass.
|
||||||
|
|
||||||
|
# Related Stories
|
||||||
|
|
||||||
|
- [Manage users as an administrator](us-013-admin-user-management.md)
|
||||||
|
- [Control Minecraft admission with groups](us-017-group-access.md)
|
||||||
|
- [Preserve an audit trail](us-010-audit-events.md)
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: Schedule group access in UTC
|
||||||
|
description: Administrators restrict enabled groups to recurring weekly UTC windows and provide static denial-message templates.
|
||||||
|
tags: [admin, groups, scheduling, velocity, templates, security]
|
||||||
|
timestamp: 2026-08-02T17:42:26Z
|
||||||
|
story_id: US-020
|
||||||
|
status: verified
|
||||||
|
---
|
||||||
|
|
||||||
|
# User Story
|
||||||
|
|
||||||
|
As an administrator, I want an enabled group to have recurring access windows, so that its members can join only during approved weekly periods and receive useful denial guidance.
|
||||||
|
|
||||||
|
# Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] A group can have zero or more recurring weekly access windows stored and evaluated in UTC.
|
||||||
|
- [x] The browser shows each UTC window's current equivalent in the administrator's local timezone while clearly identifying UTC as authoritative.
|
||||||
|
- [x] The Groups table identifies unrestricted groups and the configured window count, linking each status to schedule management.
|
||||||
|
- [x] Administrators can add and remove multiple windows, including windows that cross the end of the UTC week.
|
||||||
|
- [x] Window starts are inclusive and window ends are exclusive.
|
||||||
|
- [x] No configured windows preserve unrestricted scheduling behavior while Minecraft access is enabled.
|
||||||
|
- [x] Disabled Minecraft access always denies admission, regardless of schedule.
|
||||||
|
- [x] Enabled Minecraft access with configured windows allows login only inside an active window.
|
||||||
|
- [x] VPN/proxy/Tor policy is evaluated only after group access and schedule policy pass.
|
||||||
|
- [x] Schedule enforcement occurs at login and does not disconnect an existing session when a window ends.
|
||||||
|
- [x] Schedule changes require confirmation, reauthorize the administrator, and commit atomically with an audit event.
|
||||||
|
- [x] Malformed or overlapping schedule data is rejected; malformed persisted policy fails closed.
|
||||||
|
- [x] Registration, group-disabled, and VPN/proxy/Tor templates support `{player}` and `{group}`.
|
||||||
|
- [x] Schedule-denied templates additionally support `{next_start}` and `{next_end}` for the earliest upcoming UTC window.
|
||||||
|
- [x] Unknown template variables, control characters, and invalid lengths are rejected server-side.
|
||||||
|
- [x] Registration denials use `everyone` when no effective group can be resolved.
|
||||||
|
|
||||||
|
# Implementation
|
||||||
|
|
||||||
|
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
||||||
|
- [`packages/database/drizzle/0005_young_vertigo.sql`](../packages/database/drizzle/0005_young_vertigo.sql)
|
||||||
|
- [`apps/web/src/lib/group-schedule.ts`](../apps/web/src/lib/group-schedule.ts)
|
||||||
|
- [`apps/web/src/lib/admission-settings.ts`](../apps/web/src/lib/admission-settings.ts)
|
||||||
|
- [`apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/%5BgroupId%5D/page.tsx)
|
||||||
|
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
|
||||||
|
UTC recurrence, multiple-window selection, local conversion, malformed schedules, template validation, policy precedence, and schedule-editor interactions are covered by automated tests. Drizzle generation, migration preflight, TypeScript, lint, build, security checks, and OKF validation must pass.
|
||||||
|
|
||||||
|
# Related Stories
|
||||||
|
|
||||||
|
- [Enforce registration at Velocity](us-009-velocity-admission.md)
|
||||||
|
- [Operate settings and audit views](us-012-admin-operations.md)
|
||||||
|
- [Control Minecraft admission with groups](us-017-group-access.md)
|
||||||
|
- [Manage groups efficiently](us-019-admin-group-management.md)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: Manage RCON server connections
|
||||||
|
description: Administrators manage encrypted connection settings for Minecraft RCON server addresses.
|
||||||
|
tags: [admin, rcon, minecraft, security, operations]
|
||||||
|
timestamp: 2026-08-08T13:40:43Z
|
||||||
|
story_id: US-021
|
||||||
|
status: verified
|
||||||
|
---
|
||||||
|
|
||||||
|
# User Story
|
||||||
|
|
||||||
|
As an administrator, I want to manage one or more Minecraft RCON connections, so that server operations can be reached from the existing protected console.
|
||||||
|
|
||||||
|
# Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] Existing account-manager administrators use the terminal header to select, add, edit, test, enable or disable, and delete RCON server connections.
|
||||||
|
- [x] Each connection has a unique display name, server address, port, enabled state, and write-only password.
|
||||||
|
- [x] RCON passwords are encrypted with an authenticated cipher using a deployment-managed master key and are never returned to the browser, audit events, or application logs.
|
||||||
|
- [x] Updating a connection preserves its password unless an administrator explicitly supplies a replacement.
|
||||||
|
- [x] Administrators can save any syntactically valid DNS hostname and port without deployment-managed endpoint configuration; IP literals remain rejected.
|
||||||
|
- [x] Testing a connection authenticates through the server-side RCON proxy and reports a safe success or failure result.
|
||||||
|
- [x] Add and edit use accessible modal forms, and deleting a connection requires an explicit danger-confirmation modal.
|
||||||
|
- [x] Connection mutations independently recheck administrator authorization and create credential-safe audit events.
|
||||||
|
- [x] Database changes use a generated versioned Drizzle migration rather than schema push.
|
||||||
|
|
||||||
|
# Implementation
|
||||||
|
|
||||||
|
The unified terminal header selects connections and exposes add, test, enable or disable, edit, and delete controls. Add and edit use reusable accessible modal forms, while delete uses a danger-confirmation modal. Server actions manage endpoints without deployment-managed endpoint configuration, preserve write-only passwords, encrypt credentials with connection-bound AES-256-GCM, and emit credential-safe audit events. The `rcon_servers` table is delivered through generated migration `0006_curious_lester.sql`.
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
|
||||||
|
Verified with RCON validation, encryption, gateway, component, and server-action tests; full workspace tests and type checks; web lint; OKF validation; Semgrep; dependency audit; and a production Next.js build on 2026-08-08. Validation confirms arbitrary valid internal or external DNS server addresses and ports no longer require deployment configuration while IP literals and malformed hostnames remain rejected. Action tests confirm independent authorization, password preservation, enabled-state rechecks, safe failures, and command audit redaction.
|
||||||
|
|
||||||
|
# Related Stories
|
||||||
|
|
||||||
|
- [Operate servers through an RCON console](us-022-rcon-console.md)
|
||||||
|
- [Authenticate administrators with SSO](us-011-admin-sso.md)
|
||||||
|
- [Deploy and operate securely](us-015-platform-operations.md)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
---
|
||||||
|
type: User Story
|
||||||
|
title: Operate servers through an RCON console
|
||||||
|
description: Administrators execute bounded RCON commands through the server-side portal proxy.
|
||||||
|
tags: [admin, rcon, minecraft, console, security]
|
||||||
|
timestamp: 2026-08-08T13:40:43Z
|
||||||
|
story_id: US-022
|
||||||
|
status: verified
|
||||||
|
---
|
||||||
|
|
||||||
|
# User Story
|
||||||
|
|
||||||
|
As an administrator, I want an RCON console in the portal, so that I can operate configured Minecraft servers without exposing credentials to the browser.
|
||||||
|
|
||||||
|
# Acceptance Criteria
|
||||||
|
|
||||||
|
- [x] Existing account-manager administrators can select an enabled connection and execute an RCON command from the admin UI.
|
||||||
|
- [x] Browsers never connect to RCON directly; commands pass through the authenticated Next.js server runtime to the configured endpoint.
|
||||||
|
- [x] Every command independently rechecks administrator authorization and the selected connection's enabled state.
|
||||||
|
- [x] Commands are length-limited, reject control characters, execute with bounded concurrency and a timeout, and return bounded output.
|
||||||
|
- [x] Command responses are displayed safely and are not persisted in console history, audit data, or application logs.
|
||||||
|
- [x] Audit events record the administrator, connection, command verb and digest, success, and duration without recording complete commands or responses.
|
||||||
|
- [x] Authentication, timeout, and connection failures return safe operator-facing messages without credentials or stack traces.
|
||||||
|
- [x] The console spans the available content width and uses the portal color palette to present a terminal-style server header with connection controls, a single keyboard-accessible prompt, pending state, and scrollable latest-response viewport.
|
||||||
|
- [x] Configured server addresses may be internal or external, and operators receive guidance that RCON network exposure and transport security remain their responsibility.
|
||||||
|
- [x] Command responses, connection errors, and connection-operation results appear in the terminal viewport, including an actionable empty state when no connection exists.
|
||||||
|
- [x] The page has no duplicate connection form or connection-list panel outside the terminal workspace.
|
||||||
|
|
||||||
|
# Implementation
|
||||||
|
|
||||||
|
The full-width portal-colored terminal workspace identifies and manages the selected server in its header, accepts one command through a keyboard-focused prompt, and displays command responses plus connection-operation notices in one scrollable viewport. It retains an actionable terminal and Add control when no connections exist, with no duplicate configuration panels. The client invokes an authenticated server action that revalidates the enabled connection, decrypts its credential only in the server runtime, and executes one bounded command. The gateway limits each process to one operation per server and eight total operations, applies a five-second end-to-end deadline plus bounded cleanup, sanitizes and truncates output, and records keyed command lifecycle audits without command or response content.
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
|
||||||
|
Application behavior is verified with gateway, validation, component, credential, and server-action tests; full workspace tests and type checks; web lint; OKF validation; Semgrep; dependency audit; and a production Next.js build on 2026-08-08. Component validation confirms the full-width workspace, labelled server and command controls, header actions, accessible modal forms, terminal-contained notices, and the actionable no-server state. The SoMC GitOps deployment verifies Season 4 RCON through an authenticated internal ClusterIP Service backed by a Kubernetes Secret while product guidance also covers external server addresses.
|
||||||
|
|
||||||
|
# Related Stories
|
||||||
|
|
||||||
|
- [Manage RCON server connections](us-021-rcon-connections.md)
|
||||||
|
- [Operate settings and audit views](us-012-admin-operations.md)
|
||||||
|
- [Preserve an audit trail](us-010-audit-events.md)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Accessibility review
|
# Accessibility review
|
||||||
|
|
||||||
Review date: 2026-08-01
|
Review date: 2026-08-02
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -12,13 +12,19 @@ Player account management, administrator navigation, dashboard metrics and chart
|
|||||||
- Darkened the accent color so accent text reaches at least 4.5:1 contrast on both canvas and panel backgrounds.
|
- Darkened the accent color so accent text reaches at least 4.5:1 contrast on both canvas and panel backgrounds.
|
||||||
- Preserved reduced-motion behavior and disabled decorative cursor animation when requested.
|
- Preserved reduced-motion behavior and disabled decorative cursor animation when requested.
|
||||||
- Added labels or accessible names to search, Minecraft username, settings, group, and event-filter controls.
|
- Added labels or accessible names to search, Minecraft username, settings, group, and event-filter controls.
|
||||||
|
- Added reusable per-user group dropdowns that open labelled confirmation dialogs, restore the prior selection on cancellation, prevent dismissal while pending, and provide live progress and result feedback.
|
||||||
|
- Group creation, policy, schedule editing, metadata editing, and deletion use scrollable native modal dialogs with keyboard cancellation, focus management, descriptive confirmation text, and disabled pending controls.
|
||||||
|
- Weekly schedule rows use labelled fieldsets, weekday and time controls, explicit exclusive-end wording, authoritative UTC values, and browser-local equivalents with the detected timezone.
|
||||||
- Added `fieldset` and `legend` semantics to multi-select event-type filters.
|
- Added `fieldset` and `legend` semantics to multi-select event-type filters.
|
||||||
- Added table captions, column scopes, and row scopes to administrator data tables.
|
- Added table captions, column scopes, and row scopes to administrator data tables.
|
||||||
- Added `role=status` with polite announcements for successful nickname changes and `role=alert` with assertive announcements for errors.
|
- Added `role=status` with polite announcements for successful nickname changes and `role=alert` with assertive announcements for errors.
|
||||||
- Added semantic `time` elements for audit and security activity timestamps.
|
- Added semantic `time` elements for audit and security activity timestamps.
|
||||||
- Made event JSON keyboard-focusable so horizontally overflowing content can be reviewed without a pointer.
|
- Made event JSON keyboard-focusable so horizontally overflowing content can be reviewed without a pointer.
|
||||||
- Added an accessible title, description, per-point labels, and textual values to the registration chart.
|
- Added an accessible title, description, date labels, per-point labels, and textual values to the daily-active-user chart.
|
||||||
- Added labelled, keyboard-linked world-map markers plus a complete semantic table equivalent for approximate user locations.
|
- Added labelled, keyboard-linked world-map markers plus a complete semantic table equivalent for approximate user locations.
|
||||||
|
- Collocated users share a visible count badge; hover and focus tooltips announce every nickname, while interactive grouped markers expose per-user popup links.
|
||||||
|
- The semantic location table separates network company, connection type, Proxy/VPN status, and risk classification under explicit column headers.
|
||||||
|
- Added keyboard-operable tabs for the server-rendered overview and opt-in interactive OpenStreetMap view.
|
||||||
- Added explicit new-tab context to the external Discord invite link.
|
- Added explicit new-tab context to the external Discord invite link.
|
||||||
- Kept destructive account and group actions behind native keyboard-operable `details` confirmation disclosures.
|
- Kept destructive account and group actions behind native keyboard-operable `details` confirmation disclosures.
|
||||||
- Allowed administrator navigation to wrap at narrow viewport widths instead of overflowing.
|
- Allowed administrator navigation to wrap at narrow viewport widths instead of overflowing.
|
||||||
|
|||||||
+7
-3
@@ -29,12 +29,16 @@ Every error response has media type `application/problem+json` and the shape:
|
|||||||
|
|
||||||
| Type | Status | Meaning |
|
| Type | Status | Meaning |
|
||||||
| --- | ---: | --- |
|
| --- | ---: | --- |
|
||||||
| `urn:error:invalid-velocity-access-request` | 400 | Request JSON does not satisfy the shared Velocity contract |
|
| `urn:error:invalid-velocity-access-request` | 400 | Access request JSON does not satisfy the shared Velocity contract |
|
||||||
|
| `urn:error:invalid-velocity-connection-request` | 400 | Confirmed-connection JSON does not satisfy the shared Velocity contract |
|
||||||
| `urn:error:unauthorized` | 401 | Velocity bearer credential is missing, invalid, or revoked |
|
| `urn:error:unauthorized` | 401 | Velocity bearer credential is missing, invalid, or revoked |
|
||||||
| `urn:error:expired-velocity-access-request` | 401 | Request timestamp is outside the accepted clock-skew window |
|
| `urn:error:expired-velocity-access-request` | 401 | Access timestamp is outside the accepted clock-skew window |
|
||||||
|
| `urn:error:expired-velocity-connection-request` | 401 | Connection timestamp is outside the accepted clock-skew window |
|
||||||
| `urn:error:not-found` | 404 | Unknown application-owned API route |
|
| `urn:error:not-found` | 404 | Unknown application-owned API route |
|
||||||
|
| `urn:error:unknown-minecraft-account` | 404 | Connection telemetry references an inactive or unknown account |
|
||||||
| `urn:error:method-not-allowed` | 405 | The endpoint does not support the requested HTTP method |
|
| `urn:error:method-not-allowed` | 405 | The endpoint does not support the requested HTTP method |
|
||||||
| `urn:error:replayed-velocity-access-request` | 409 | Request ID was already processed |
|
| `urn:error:replayed-velocity-access-request` | 409 | Admission request ID was already processed |
|
||||||
|
| `urn:error:replayed-velocity-connection-request` | 409 | Confirmed-connection request ID was already processed |
|
||||||
| `urn:error:unsupported-media-type` | 415 | The request does not use `application/json` |
|
| `urn:error:unsupported-media-type` | 415 | The request does not use `application/json` |
|
||||||
| `urn:error:service-unavailable` | 503 | A safe access decision could not be completed |
|
| `urn:error:service-unavailable` | 503 | A safe access decision could not be completed |
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -4,10 +4,14 @@
|
|||||||
|
|
||||||
### Web application
|
### Web application
|
||||||
|
|
||||||
The Next.js application owns user onboarding, account management, admin configuration and metrics, server-side Minecraft profile validation, sessions, and the HTTP API used by Discord and Velocity integrations. Database-backed portal and console pages are dynamic React Server Components: authentication, queries, filtering, dashboard aggregation, and the Natural Earth user-location map execute on the server and return rendered HTML. Map boundaries are bundled open data, so rendering does not disclose administrator or user location requests to a map provider.
|
The Next.js application owns user onboarding, account management, admin configuration and metrics, server-side Minecraft profile validation, sessions, and the HTTP API used by Discord and Velocity integrations. Database-backed portal and console pages are dynamic React Server Components: authentication, queries, filtering, dashboard aggregation, and the initial Natural Earth user-location map execute on the server and return rendered HTML. Administrators can opt into a hydrated Leaflet/OpenStreetMap view; OSM receives requests only for viewed map tiles, while user marker coordinates remain local to the browser.
|
||||||
|
|
||||||
User authentication begins with an opaque, short-lived, single-use token created for a Discord user. Only a cryptographic hash of the token is persisted. Admin authentication is a separate Keycloak OIDC flow and requires the `minecraft-account-manager-admin` role.
|
User authentication begins with an opaque, short-lived, single-use token created for a Discord user. Only a cryptographic hash of the token is persisted. Admin authentication is a separate Keycloak OIDC flow and requires the `minecraft-account-manager-admin` role.
|
||||||
|
|
||||||
|
### RCON administration
|
||||||
|
|
||||||
|
The administrator console stores one or more RCON server addresses with write-only AES-GCM-encrypted passwords. Browser requests invoke authenticated server actions; only the Next.js runtime opens RCON TCP connections. Administrators may configure any syntactically valid internal or external DNS hostname and port without deployment-managed endpoint configuration; IP literals remain rejected. Commands and responses are bounded and ephemeral, while credential-safe audit events retain the operator, server, command verb, keyed digest, outcome, and duration. Operators remain responsible for endpoint exposure and transport security.
|
||||||
|
|
||||||
### Discord bot
|
### Discord bot
|
||||||
|
|
||||||
The bot creates private login links in response to `/register` and `/account`. Discord user IDs are the canonical Discord identity; mutable usernames are snapshots only. Nickname updates target the deployment guild configured by `DISCORD_GUILD_ID`; the public join button uses `DISCORD_INVITE_URL`.
|
The bot creates private login links in response to `/register` and `/account`. Discord user IDs are the canonical Discord identity; mutable usernames are snapshots only. Nickname updates target the deployment guild configured by `DISCORD_GUILD_ID`; the public join button uses `DISCORD_INVITE_URL`.
|
||||||
@@ -16,7 +20,7 @@ The bot creates private login links in response to `/register` and `/account`. D
|
|||||||
|
|
||||||
Velocity sends the authenticated Java UUID, current username, source IP, server ID, request ID, and occurrence time. The API matches UUID first. Username fallback is allowed only when the stored account has no UUID, after which UUID and canonical username are updated.
|
Velocity sends the authenticated Java UUID, current username, source IP, server ID, request ID, and occurrence time. The API matches UUID first. Username fallback is allowed only when the stored account has no UUID, after which UUID and canonical username are updated.
|
||||||
|
|
||||||
The decision is fail closed. Unknown players, invalid responses, expired requests, authentication failures, and unavailable API responses are denied with the configured registration message.
|
The admission decision is fail closed. Unknown players, disabled effective groups, out-of-window scheduled groups, disallowed confirmed VPN/proxy/Tor connections, invalid responses, expired requests, authentication failures, and unavailable API responses are denied. Policy order is fixed: registration, enabled effective group, recurring UTC schedule, then anonymized-network exception. Zero schedule windows mean no time restriction; configured starts are inclusive and ends are exclusive. Scheduling is checked only at login and never disconnects an admitted player. Registration, group-access, schedule, and anonymized-network denials use independent operator-configured static templates; validated variables provide player, group, and next-window UTC guidance without executable expressions. Transport and service failures retain the plugin's local fallback. After admission succeeds, `PostLoginEvent` reports a confirmed proxy connection through a fresh, authenticated, replay-protected request. Connection telemetry is best effort and never disconnects an already admitted player.
|
||||||
|
|
||||||
## Trust boundaries
|
## Trust boundaries
|
||||||
|
|
||||||
@@ -26,19 +30,22 @@ The decision is fail closed. Unknown players, invalid responses, expired request
|
|||||||
- Velocity requests use hashed per-server bearer credentials, timestamps, and database-unique request IDs for authentication and replay prevention.
|
- Velocity requests use hashed per-server bearer credentials, timestamps, and database-unique request IDs for authentication and replay prevention.
|
||||||
- Session and one-time-code values are random and stored only as hashes.
|
- Session and one-time-code values are random and stored only as hashes.
|
||||||
- Exact IP addresses are sensitive data and require an explicit retention policy before production deployment.
|
- Exact IP addresses are sensitive data and require an explicit retention policy before production deployment.
|
||||||
|
- RCON endpoints require syntactically valid DNS hostnames and ports; passwords never cross the browser trust boundary. Cluster egress policy and administrator authorization constrain the resulting outbound-connectivity trust boundary.
|
||||||
|
- RCON commands and responses are untrusted, bounded, rendered only as text, and excluded from persistent history and logs.
|
||||||
|
|
||||||
## Database invariants
|
## Database invariants
|
||||||
|
|
||||||
- A Discord user ID maps to one user.
|
- A Discord user ID maps to one user.
|
||||||
- An active Minecraft UUID or case-insensitive username maps to one account.
|
- An active Minecraft UUID or case-insensitive username maps to one account.
|
||||||
- A user has at most one active primary Minecraft account.
|
- A user has at most one active primary Minecraft account.
|
||||||
|
- Group schedule boundaries are integer UTC minutes of the recurring Monday-based week; malformed or overlapping persisted windows fail closed during admission.
|
||||||
- Removed accounts are soft deleted to retain audit history.
|
- Removed accounts are soft deleted to retain audit history.
|
||||||
- Audit events are CloudEvents-shaped, append-only application records.
|
- Audit events are CloudEvents-shaped, append-only application records.
|
||||||
- `published_at` reserves an outbox-style path for later Kafka publishing.
|
- `published_at` reserves an outbox-style path for later Kafka publishing.
|
||||||
|
|
||||||
## IP intelligence
|
## IP intelligence
|
||||||
|
|
||||||
ProxyCheck.io supplies approximate city/region/country, coordinates, timezone, ASN, provider, risk, and VPN/proxy/Tor classification. Results are cached in PostgreSQL for 48 hours by default. Portal and game login events are enriched when data is available; lookup failures do not deny login. User Minecraft-account additions fail closed for unknown, VPN, proxy, or Tor classifications and record denied attempts. Hosting-provider blocking is optional through `BLOCK_HOSTING_IPS=true`. Private and reserved addresses are never sent to ProxyCheck.
|
ProxyCheck.io supplies approximate city/region/country, coordinates, timezone, ASN, network company, connection type, proxy signal, risk, and VPN/proxy/Tor classification. Results are cached in PostgreSQL for 48 hours by default. Portal logins and every bearer-authenticated Velocity login are enriched through the cache before identity resolution; lookup failures do not independently deny a registered player. Confirmed VPN, proxy, and Tor game connections require an exception on the player's effective group. User Minecraft-account additions fail closed for unknown, VPN, proxy, or Tor classifications and record denied attempts. Hosting-provider blocking is optional through `BLOCK_HOSTING_IPS=true`. Private and reserved addresses are never sent to ProxyCheck.
|
||||||
|
|
||||||
## Event naming
|
## Event naming
|
||||||
|
|
||||||
@@ -52,3 +59,4 @@ Events use reverse-DNS names beneath `games.minecraft.account-manager`, includin
|
|||||||
- `games.minecraft.account-manager.network.vpn-blocked`
|
- `games.minecraft.account-manager.network.vpn-blocked`
|
||||||
- `games.minecraft.account-manager.game.login.allowed`
|
- `games.minecraft.account-manager.game.login.allowed`
|
||||||
- `games.minecraft.account-manager.game.login.denied`
|
- `games.minecraft.account-manager.game.login.denied`
|
||||||
|
- `games.minecraft.account-manager.game.player.connected`
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# RCON administration
|
||||||
|
|
||||||
|
The administrator RCON console proxies commands through the Next.js server runtime. Browsers never receive RCON credentials and never open RCON sockets.
|
||||||
|
|
||||||
|
## Application configuration
|
||||||
|
|
||||||
|
Administrators may configure any syntactically valid DNS hostname and TCP port without deployment-managed endpoint configuration. IP literals, trailing-dot hostnames, and malformed DNS names are rejected whenever a connection is saved, tested, or used.
|
||||||
|
|
||||||
|
This flexibility means an authorized or compromised administrator can make RCON connection attempts to any DNS hostname and port reachable from the web runtime. Use cluster egress policy and administrator access controls to constrain that trust boundary where required.
|
||||||
|
|
||||||
|
Saved passwords are encrypted with AES-256-GCM and connection-bound authenticated data. By default, domain-separated credential and audit keys are derived from `AUTH_SECRET`. Deployments may instead provide independent 32-byte base64 values through `RCON_CREDENTIAL_KEY` and `RCON_AUDIT_KEY`. Rotating the credential key requires replacing saved RCON passwords.
|
||||||
|
|
||||||
|
## Minecraft server configuration
|
||||||
|
|
||||||
|
Enable RCON with a high-entropy password supplied through the deployment secret. Server addresses may resolve internally or externally. Prefer private networking, a VPN, or an encrypted tunnel; do not expose plaintext RCON directly to the public internet.
|
||||||
|
|
||||||
|
The password entered in the administrator connection form must match the server password. Existing passwords are write-only; leave the replacement field blank when editing unrelated connection settings.
|
||||||
|
|
||||||
|
## Security behavior
|
||||||
|
|
||||||
|
- Existing account-manager administrator authorization is rechecked for every connection mutation, test, and command.
|
||||||
|
- Commands are limited to 1,024 UTF-8 bytes and reject control characters.
|
||||||
|
- Each web process allows one operation per connection and at most eight RCON operations total. Size replica counts with that aggregate ceiling in mind.
|
||||||
|
- Each complete connect-and-response operation times out after five seconds and tears down the socket; cleanup is independently capped at one second.
|
||||||
|
- Responses are sanitized and limited to 64 KiB.
|
||||||
|
- Full commands and responses are not persisted or logged. Audit events contain the command verb and a domain-separated HMAC digest.
|
||||||
|
- Connection passwords are never selected by page queries or returned to the browser.
|
||||||
|
|
||||||
|
RCON is plaintext TCP. Internal deployments should use network policy; external connections should use private routing, a VPN, or an encrypted tunnel rather than direct public exposure.
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Apply the generated Drizzle migration before deploying the web image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx drizzle-kit migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
Never use `drizzle push` for this schema change.
|
||||||
+12
-6
@@ -1,6 +1,6 @@
|
|||||||
# Security review
|
# Security review
|
||||||
|
|
||||||
Review date: 2026-08-01
|
Review date: 2026-08-02
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -22,17 +22,23 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
|
|||||||
- User mutations verify ownership server-side.
|
- User mutations verify ownership server-side.
|
||||||
- Mojang lookup is server-side and targets a fixed host, avoiding client-forged validation and SSRF.
|
- Mojang lookup is server-side and targets a fixed host, avoiding client-forged validation and SSRF.
|
||||||
- Velocity credentials are high-entropy bearer tokens stored only as hashes.
|
- Velocity credentials are high-entropy bearer tokens stored only as hashes.
|
||||||
- Velocity requests have a 45-second clock window and database-unique request IDs for cross-instance replay prevention.
|
- Velocity admission and confirmed-connection requests have a 45-second clock window and database-unique request IDs for cross-instance replay prevention.
|
||||||
- Velocity and its API fail closed.
|
- Velocity and its API fail closed.
|
||||||
- Registered players require an enabled effective group; explicit assignments replace rather than combine with the protected, disabled-by-default `everyone` fallback.
|
- Registered players require an enabled effective group; explicit assignments replace rather than combine with the protected, disabled-by-default `everyone` fallback. Disabled access always overrides scheduling.
|
||||||
- Group and membership mutations re-check the Keycloak administrator role server-side; destructive group deletion and its audit event commit atomically.
|
- Enabled groups with recurring UTC windows admit only during an active login window. Window boundaries and overlap are validated server-side, malformed persisted policy fails closed, and connected sessions are not re-evaluated.
|
||||||
|
- Confirmed VPN, proxy, and Tor game connections are denied unless that same effective group has an explicit exception; `everyone` and new groups default to no exception.
|
||||||
|
- Static admission templates use a fixed allowlist of non-executable variables. Unknown placeholders and control characters are rejected, and rendered values remain plain JSON/text.
|
||||||
|
- Group, schedule, VPN-policy, identity, and membership mutations re-check the Keycloak administrator role server-side; registry assignments, policy changes, group edits, creation, deletion, and message settings commit atomically with their audit events.
|
||||||
|
- Group identity creation/rename and membership assignment/deletion use compatible PostgreSQL advisory and row locks to prevent duplicate names, stale audit records, or membership/deletion races. The protected default name and deletion restriction are enforced server-side.
|
||||||
|
- Every bearer-authenticated Velocity login uses cached IP intelligence before identity resolution, preventing account-creation races from bypassing network policy; malformed provider proxy signals classify as unknown.
|
||||||
- Event filters accept only event types already present in the ledger, and event detail routes remain role-protected.
|
- Event filters accept only event types already present in the ledger, and event detail routes remain role-protected.
|
||||||
- The administrator-only location map uses bundled Natural Earth boundaries and approximate cached IP intelligence; it sends no coordinates or map requests to third parties.
|
- The administrator-only map defaults to bundled Natural Earth boundaries. OpenStreetMap tile requests begin only after an explicit operator opt-in; marker coordinates are not transmitted as data, but the requested tiles disclose the viewed geographic extent along with the administrator's IP and portal origin.
|
||||||
|
- Grouped-map popup labels and links are created with DOM `textContent` and server-rendered React escaping rather than interpolated HTML.
|
||||||
- ORM-parameterized queries are used throughout.
|
- ORM-parameterized queries are used throughout.
|
||||||
- CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured.
|
- CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured.
|
||||||
- Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured.
|
- Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured.
|
||||||
- Private and reserved addresses are not sent to ProxyCheck.io; lookup results are cached to reduce disclosure and API usage.
|
- Private and reserved addresses are not sent to ProxyCheck.io; lookup results are cached to reduce disclosure and API usage.
|
||||||
- Portal and game login events include approximate network location and VPN/proxy classification when available.
|
- Portal and game login events include approximate network location and VPN/proxy classification when available. The administrator-only location list also exposes enriched network company, ASN, connection type, and the provider's proxy signal.
|
||||||
- Structured Pino logging redacts credential fields, and secrets are excluded from logs and repository configuration.
|
- Structured Pino logging redacts credential fields, and secrets are excluded from logs and repository configuration.
|
||||||
|
|
||||||
## Outstanding production requirements
|
## Outstanding production requirements
|
||||||
|
|||||||
Generated
+730
-46
File diff suppressed because it is too large
Load Diff
@@ -115,6 +115,26 @@ export function resolveEffectiveGroup<T>(explicitGroup: T | null, defaultGroup:
|
|||||||
return explicitGroup ?? defaultGroup;
|
return explicitGroup ?? defaultGroup;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isGameNetworkAllowed(
|
||||||
|
classification: "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor",
|
||||||
|
anonymizedNetworksAllowed: boolean,
|
||||||
|
) {
|
||||||
|
return anonymizedNetworksAllowed || !["vpn", "proxy", "tor"].includes(classification);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function gameAdmissionDenialReason(
|
||||||
|
group: { accessEnabled: boolean; anonymizedNetworksAllowed: boolean } | null,
|
||||||
|
classification: "unknown" | "clear" | "vpn" | "proxy" | "hosting" | "tor",
|
||||||
|
scheduleAllowed = true,
|
||||||
|
) {
|
||||||
|
if (!group?.accessEnabled) return "group_access_disabled" as const;
|
||||||
|
if (!scheduleAllowed) return "schedule_disallowed" as const;
|
||||||
|
if (!isGameNetworkAllowed(classification, group.anonymizedNetworksAllowed)) {
|
||||||
|
return "anonymized_network_disallowed" as const;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function verifyHashedToken(providedToken: string, expectedHash: string) {
|
export function verifyHashedToken(providedToken: string, expectedHash: string) {
|
||||||
const provided = Buffer.from(hashToken(providedToken), "utf8");
|
const provided = Buffer.from(hashToken(providedToken), "utf8");
|
||||||
const expected = Buffer.from(expectedHash, "utf8");
|
const expected = Buffer.from(expectedHash, "utf8");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveEffectiveGroup } from "../src/index";
|
import { gameAdmissionDenialReason, isGameNetworkAllowed, resolveEffectiveGroup } from "../src/index";
|
||||||
|
|
||||||
describe("group-based admission", () => {
|
describe("group-based admission", () => {
|
||||||
const everyone = { name: "everyone", accessEnabled: false };
|
const everyone = { name: "everyone", accessEnabled: false };
|
||||||
@@ -17,4 +17,28 @@ describe("group-based admission", () => {
|
|||||||
const limited = { name: "limited", accessEnabled: false };
|
const limited = { name: "limited", accessEnabled: false };
|
||||||
expect(resolveEffectiveGroup(limited, enabledDefault)?.accessEnabled).toBe(false);
|
expect(resolveEffectiveGroup(limited, enabledDefault)?.accessEnabled).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("denies confirmed anonymized game networks unless the effective group allows them", () => {
|
||||||
|
for (const classification of ["vpn", "proxy", "tor"] as const) {
|
||||||
|
expect(isGameNetworkAllowed(classification, false)).toBe(false);
|
||||||
|
expect(isGameNetworkAllowed(classification, true)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not apply the group exception policy to clear, hosting, or unavailable intelligence", () => {
|
||||||
|
for (const classification of ["clear", "hosting", "unknown"] as const) {
|
||||||
|
expect(isGameNetworkAllowed(classification, false)).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes disabled group access, then schedule, then network policy", () => {
|
||||||
|
expect(gameAdmissionDenialReason({ accessEnabled: false, anonymizedNetworksAllowed: false }, "vpn", false))
|
||||||
|
.toBe("group_access_disabled");
|
||||||
|
expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: false }, "vpn", false))
|
||||||
|
.toBe("schedule_disallowed");
|
||||||
|
expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: false }, "vpn", true))
|
||||||
|
.toBe("anonymized_network_disallowed");
|
||||||
|
expect(gameAdmissionDenialReason({ accessEnabled: true, anonymizedNetworksAllowed: true }, "vpn", true))
|
||||||
|
.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ export const velocityAccessRequestSchema = z.object({
|
|||||||
|
|
||||||
export type VelocityAccessRequest = z.infer<typeof velocityAccessRequestSchema>;
|
export type VelocityAccessRequest = z.infer<typeof velocityAccessRequestSchema>;
|
||||||
|
|
||||||
|
export const velocityConnectionRequestSchema = z.object({
|
||||||
|
requestId: z.uuid(),
|
||||||
|
serverId: z.string().min(1).max(100),
|
||||||
|
minecraftUuid: minecraftUuidSchema,
|
||||||
|
username: minecraftUsernameSchema,
|
||||||
|
occurredAt: isoDateTimeSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type VelocityConnectionRequest = z.infer<typeof velocityConnectionRequestSchema>;
|
||||||
|
|
||||||
export const velocityAccessResponseSchema = z.discriminatedUnion("allowed", [
|
export const velocityAccessResponseSchema = z.discriminatedUnion("allowed", [
|
||||||
z.object({
|
z.object({
|
||||||
allowed: z.literal(true),
|
allowed: z.literal(true),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
cloudEventSchema,
|
cloudEventSchema,
|
||||||
velocityAccessRequestSchema,
|
velocityAccessRequestSchema,
|
||||||
velocityAccessResponseSchema,
|
velocityAccessResponseSchema,
|
||||||
|
velocityConnectionRequestSchema,
|
||||||
} from "../src/index";
|
} from "../src/index";
|
||||||
|
|
||||||
describe("shared service contracts", () => {
|
describe("shared service contracts", () => {
|
||||||
@@ -37,6 +38,19 @@ describe("shared service contracts", () => {
|
|||||||
).toThrow();
|
).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("validates a confirmed Velocity connection report", () => {
|
||||||
|
const request = velocityConnectionRequestSchema.parse({
|
||||||
|
requestId: "8dd9dbdc-020a-4077-983c-77747522de8f",
|
||||||
|
serverId: "velocity-main",
|
||||||
|
minecraftUuid: "069a79f444e94726a5befca90e38aaf5",
|
||||||
|
username: "Notch",
|
||||||
|
occurredAt: "2026-03-06T12:00:01.000Z",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(request.username).toBe("Notch");
|
||||||
|
expect(() => velocityConnectionRequestSchema.parse({ ...request, username: "bad name" })).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
it("only returns explicit allow or deny decisions to Velocity", () => {
|
it("only returns explicit allow or deny decisions to Velocity", () => {
|
||||||
expect(
|
expect(
|
||||||
velocityAccessResponseSchema.parse({
|
velocityAccessResponseSchema.parse({
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ALTER TABLE "app_settings" ADD COLUMN "group_access_denied_message" text DEFAULT 'Your account group does not currently have server access. Contact a host if you believe this is a mistake.' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "app_settings" ADD COLUMN "vpn_denied_message" text DEFAULT 'VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception.' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "groups" ADD COLUMN "anonymized_networks_allowed" boolean DEFAULT false NOT NULL;
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE "group_access_windows" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"group_id" uuid NOT NULL,
|
||||||
|
"start_minute_of_week" integer NOT NULL,
|
||||||
|
"end_minute_of_week" integer NOT NULL,
|
||||||
|
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "group_access_windows_minute_range_check" CHECK ("group_access_windows"."start_minute_of_week" >= 0 and "group_access_windows"."start_minute_of_week" < 10080 and "group_access_windows"."end_minute_of_week" >= 0 and "group_access_windows"."end_minute_of_week" < 10080 and "group_access_windows"."start_minute_of_week" <> "group_access_windows"."end_minute_of_week")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "app_settings" ADD COLUMN "scheduled_access_denied_message" text DEFAULT 'Your group is only allowed access from {next_start} to {next_end}.' NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "group_access_windows" ADD CONSTRAINT "group_access_windows_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE INDEX "group_access_windows_group_idx" ON "group_access_windows" USING btree ("group_id");
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
CREATE TABLE "rcon_servers" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" varchar(100) NOT NULL,
|
||||||
|
"host" varchar(253) NOT NULL,
|
||||||
|
"port" integer DEFAULT 25575 NOT NULL,
|
||||||
|
"encrypted_password" text NOT NULL,
|
||||||
|
"enabled" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "rcon_servers_port_check" CHECK ("rcon_servers"."port" between 1 and 65535)
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "rcon_servers_name_uidx" ON "rcon_servers" USING btree (lower("name"));
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,27 @@
|
|||||||
"when": 1785625186545,
|
"when": 1785625186545,
|
||||||
"tag": "0003_smiling_silver_samurai",
|
"tag": "0003_smiling_silver_samurai",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 4,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785678753029,
|
||||||
|
"tag": "0004_zippy_silver_centurion",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785692345708,
|
||||||
|
"tag": "0005_young_vertigo",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1786151282526,
|
||||||
|
"tag": "0006_curious_lester",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -15,12 +15,12 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@minecraft-account-manager/auth": "*",
|
"@minecraft-account-manager/auth": "*",
|
||||||
|
"drizzle-kit": "^0.31.10",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"postgres": "^3.4.8"
|
"postgres": "^3.4.8",
|
||||||
|
"tsx": "^4.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"drizzle-kit": "^0.31.10",
|
|
||||||
"tsx": "^4.21.0",
|
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import {
|
import {
|
||||||
boolean,
|
boolean,
|
||||||
|
check,
|
||||||
index,
|
index,
|
||||||
|
integer,
|
||||||
inet,
|
inet,
|
||||||
jsonb,
|
jsonb,
|
||||||
pgEnum,
|
pgEnum,
|
||||||
@@ -70,6 +72,7 @@ export const groups = pgTable(
|
|||||||
slug: varchar("slug", { length: 50 }).notNull(),
|
slug: varchar("slug", { length: 50 }).notNull(),
|
||||||
description: text("description"),
|
description: text("description"),
|
||||||
accessEnabled: boolean("access_enabled").notNull().default(false),
|
accessEnabled: boolean("access_enabled").notNull().default(false),
|
||||||
|
anonymizedNetworksAllowed: boolean("anonymized_networks_allowed").notNull().default(false),
|
||||||
isDefault: boolean("is_default").notNull().default(false),
|
isDefault: boolean("is_default").notNull().default(false),
|
||||||
...timestamps(),
|
...timestamps(),
|
||||||
},
|
},
|
||||||
@@ -79,6 +82,26 @@ export const groups = pgTable(
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const groupAccessWindows = pgTable(
|
||||||
|
"group_access_windows",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
groupId: uuid("group_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => groups.id, { onDelete: "cascade" }),
|
||||||
|
startMinuteOfWeek: integer("start_minute_of_week").notNull(),
|
||||||
|
endMinuteOfWeek: integer("end_minute_of_week").notNull(),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
index("group_access_windows_group_idx").on(table.groupId),
|
||||||
|
check(
|
||||||
|
"group_access_windows_minute_range_check",
|
||||||
|
sql`${table.startMinuteOfWeek} >= 0 and ${table.startMinuteOfWeek} < 10080 and ${table.endMinuteOfWeek} >= 0 and ${table.endMinuteOfWeek} < 10080 and ${table.startMinuteOfWeek} <> ${table.endMinuteOfWeek}`,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const userGroupMemberships = pgTable(
|
export const userGroupMemberships = pgTable(
|
||||||
"user_group_memberships",
|
"user_group_memberships",
|
||||||
{
|
{
|
||||||
@@ -171,9 +194,35 @@ export const appSettings = pgTable("app_settings", {
|
|||||||
registrationMessage: text("registration_message")
|
registrationMessage: text("registration_message")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("Please register your Minecraft account before joining."),
|
.default("Please register your Minecraft account before joining."),
|
||||||
|
groupAccessDeniedMessage: text("group_access_denied_message")
|
||||||
|
.notNull()
|
||||||
|
.default("Your account group does not currently have server access. Contact a host if you believe this is a mistake."),
|
||||||
|
vpnDeniedMessage: text("vpn_denied_message")
|
||||||
|
.notNull()
|
||||||
|
.default("VPN, proxy, and Tor connections are not allowed. Contact a host to request an exception."),
|
||||||
|
scheduledAccessDeniedMessage: text("scheduled_access_denied_message")
|
||||||
|
.notNull()
|
||||||
|
.default("Your group is only allowed access from {next_start} to {next_end}."),
|
||||||
...timestamps(),
|
...timestamps(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const rconServers = pgTable(
|
||||||
|
"rcon_servers",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
name: varchar("name", { length: 100 }).notNull(),
|
||||||
|
host: varchar("host", { length: 253 }).notNull(),
|
||||||
|
port: integer("port").notNull().default(25575),
|
||||||
|
encryptedPassword: text("encrypted_password").notNull(),
|
||||||
|
enabled: boolean("enabled").notNull().default(false),
|
||||||
|
...timestamps(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("rcon_servers_name_uidx").on(sql`lower(${table.name})`),
|
||||||
|
check("rcon_servers_port_check", sql`${table.port} between 1 and 65535`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const ipIntelligence = pgTable("ip_intelligence", {
|
export const ipIntelligence = pgTable("ip_intelligence", {
|
||||||
ipAddress: inet("ip_address").primaryKey(),
|
ipAddress: inet("ip_address").primaryKey(),
|
||||||
classification: ipClassification("classification").notNull().default("unknown"),
|
classification: ipClassification("classification").notNull().default("unknown"),
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export interface IpLocation {
|
|||||||
export interface IpNetwork {
|
export interface IpNetwork {
|
||||||
asn: string | null;
|
asn: string | null;
|
||||||
provider: string | null;
|
provider: string | null;
|
||||||
|
connectionType?: string | null;
|
||||||
|
proxy?: boolean | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IpIntelligenceResult {
|
export interface IpIntelligenceResult {
|
||||||
@@ -49,7 +51,9 @@ function numberValue(value: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function proxyClassification(proxy: unknown, type: unknown): IpClassification {
|
function proxyClassification(proxy: unknown, type: unknown): IpClassification {
|
||||||
if (String(proxy).toLowerCase() !== "yes") return "clear";
|
const normalizedProxy = String(proxy).toLowerCase();
|
||||||
|
if (normalizedProxy === "no") return "clear";
|
||||||
|
if (normalizedProxy !== "yes") return "unknown";
|
||||||
const normalizedType = String(type ?? "").toLowerCase();
|
const normalizedType = String(type ?? "").toLowerCase();
|
||||||
if (normalizedType.includes("tor")) return "tor";
|
if (normalizedType.includes("tor")) return "tor";
|
||||||
if (normalizedType.includes("vpn")) return "vpn";
|
if (normalizedType.includes("vpn")) return "vpn";
|
||||||
@@ -112,6 +116,10 @@ export class ProxyCheckProvider implements IpIntelligenceProvider {
|
|||||||
network: {
|
network: {
|
||||||
asn: stringValue(data.asn),
|
asn: stringValue(data.asn),
|
||||||
provider: stringValue(data.provider) ?? stringValue(data.organisation),
|
provider: stringValue(data.provider) ?? stringValue(data.organisation),
|
||||||
|
connectionType: stringValue(data.type),
|
||||||
|
proxy: String(data.proxy).toLowerCase() === "yes"
|
||||||
|
? true
|
||||||
|
: String(data.proxy).toLowerCase() === "no" ? false : null,
|
||||||
},
|
},
|
||||||
rawResponse: root,
|
rawResponse: root,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe("ProxyCheck.io intelligence", () => {
|
|||||||
longitude: -122.0775,
|
longitude: -122.0775,
|
||||||
timezone: "America/Los_Angeles",
|
timezone: "America/Los_Angeles",
|
||||||
},
|
},
|
||||||
network: { asn: "AS15169", provider: "Google LLC" },
|
network: { asn: "AS15169", provider: "Google LLC", connectionType: "Business", proxy: false },
|
||||||
});
|
});
|
||||||
expect(request).toHaveBeenCalledWith(
|
expect(request).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("https://proxycheck.io/v2/8.8.8.8"),
|
expect.stringContaining("https://proxycheck.io/v2/8.8.8.8"),
|
||||||
@@ -50,6 +50,14 @@ describe("ProxyCheck.io intelligence", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("treats missing or malformed proxy signals as unknown", async () => {
|
||||||
|
for (const proxy of [undefined, null, "maybe"] as const) {
|
||||||
|
const request = vi.fn<typeof fetch>().mockResolvedValue(response({ proxy, type: "Residential" }));
|
||||||
|
await expect(new ProxyCheckProvider({ apiKey: "secret", request }).classify(ipAddress))
|
||||||
|
.resolves.toMatchObject({ classification: "unknown", network: { proxy: null } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("maps VPN and Tor responses to explicit classifications", async () => {
|
it("maps VPN and Tor responses to explicit classifications", async () => {
|
||||||
const vpnRequest = vi.fn<typeof fetch>().mockResolvedValue(response({ proxy: "yes", type: "VPN" }));
|
const vpnRequest = vi.fn<typeof fetch>().mockResolvedValue(response({ proxy: "yes", type: "VPN" }));
|
||||||
const torRequest = vi.fn<typeof fetch>().mockResolvedValue(response({ proxy: "yes", type: "TOR" }));
|
const torRequest = vi.fn<typeof fetch>().mockResolvedValue(response({ proxy: "yes", type: "TOR" }));
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Velocity admission plugin
|
# Velocity admission plugin
|
||||||
|
|
||||||
The plugin checks every online-mode Java login against the account-manager API. It fails closed: unavailable, unauthorized, stale, replayed, malformed, and unknown requests are denied.
|
The plugin checks every online-mode Java login against the account-manager API. It fails closed: unavailable, unauthorized, stale, replayed, malformed, unknown-account, group-disabled, and group-policy VPN/proxy/Tor requests are denied. After a player completes proxy login, the plugin sends best-effort `PostLoginEvent` telemetry used for confirmed-connection activity metrics; reporting failure is logged without disconnecting the player.
|
||||||
|
|
||||||
## Download or build
|
## Download or build
|
||||||
|
|
||||||
@@ -31,4 +31,4 @@ npm run plugin:create-credential --workspace @minecraft-account-manager/database
|
|||||||
|
|
||||||
Copy the displayed token into the plugin's `api-token`. Configure the HTTPS account-manager URL and ensure `server-id` matches. Restrict access to the plugin configuration because it contains the bearer token, then restart Velocity.
|
Copy the displayed token into the plugin's `api-token`. Configure the HTTPS account-manager URL and ensure `server-id` matches. Restrict access to the plugin configuration because it contains the bearer token, then restart Velocity.
|
||||||
|
|
||||||
The proxy must run in online mode. Unknown players and API failures receive the configured registration message.
|
The proxy must run in online mode. Unknown players, disabled groups, and disallowed VPN/proxy/Tor connections receive their operator-configured API message. API failures receive the plugin's local registration fallback.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user