Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a01c0ed64 | ||
|
|
df17c021c6 | ||
|
|
ed3f3cd843 | ||
|
|
168a7a2c36 | ||
|
|
56cbecc3f7 | ||
|
|
d45ea4db68 | ||
|
|
19486150c3 | ||
|
|
3564d24a45 | ||
|
|
7f6d69e0a7 | ||
|
|
f9ccfd821d | ||
|
|
e43db34402 | ||
|
|
20dfc58d63 | ||
|
|
6425a5056a | ||
|
|
6fa33c9f7b | ||
|
|
c131465ff5 | ||
|
|
eb1c5b6de4 | ||
|
|
9f0832a5b5 | ||
|
|
ae623f5316 | ||
|
|
d4afb71798 | ||
|
|
71856bb869 | ||
|
|
24808b0f8c | ||
|
|
aa0b757814 | ||
|
|
ebc7c7df17 | ||
|
|
9116107917 |
@@ -14,6 +14,8 @@ KEYCLOAK_REQUIRED_ROLE=minecraft-account-manager-admin
|
|||||||
DISCORD_BOT_TOKEN=
|
DISCORD_BOT_TOKEN=
|
||||||
DISCORD_APPLICATION_ID=
|
DISCORD_APPLICATION_ID=
|
||||||
DISCORD_GUILD_ID=
|
DISCORD_GUILD_ID=
|
||||||
|
# Optional admin suggestions reader; set the real forum ID only through GitOps.
|
||||||
|
DISCORD_SUGGESTIONS_FORUM_ID=
|
||||||
DISCORD_INVITE_URL=https://discord.gg/your-invite
|
DISCORD_INVITE_URL=https://discord.gg/your-invite
|
||||||
|
|
||||||
# Trust forwarding headers only when your reverse proxy overwrites them
|
# Trust forwarding headers only when your reverse proxy overwrites them
|
||||||
@@ -25,5 +27,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
|
||||||
|
|||||||
@@ -44,9 +44,6 @@ jobs:
|
|||||||
commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.sha }}" \
|
commitlint --from "${{ github.event.pull_request.base.sha }}" --to "${{ github.sha }}" \
|
||||||
--extends @commitlint/config-conventional
|
--extends @commitlint/config-conventional
|
||||||
|
|
||||||
- name: Validate OKF design
|
|
||||||
run: npm run design:validate
|
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
@@ -38,7 +37,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Validate release source
|
- name: Validate release source
|
||||||
run: |
|
run: |
|
||||||
npm run design:validate
|
|
||||||
npm run lint
|
npm run lint
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm test
|
npm test
|
||||||
@@ -116,9 +114,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 +127,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 +140,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 +153,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'
|
||||||
|
|||||||
@@ -1,41 +1,9 @@
|
|||||||
# Repository Agent Guidance
|
# minecraft-account-manager agent entrypoint
|
||||||
|
|
||||||
## User-story-driven development
|
The canonical stories, engineering guidance, and **all process documents** are in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/index.md).
|
||||||
|
|
||||||
The `design/` directory is the OKF v0.1 product record for this repository. Use user stories to plan, implement, verify, and track all behavior.
|
Before work, read the sibling `../somc-okf/index.md`, `../somc-okf/processes/index.md`, `../somc-okf/projects/minecraft-account-manager/index.md`, `engineering.md` in that project section, and relevant `../somc-okf/user-stories/minecraft-account-manager/` stories. Also follow the parent workspace `AGENTS.md` when present.
|
||||||
|
|
||||||
Before changing behavior:
|
For standalone checkouts, start at the [project page](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/minecraft-account-manager/index.md) and [shared process](https://git.garvis.dev/dmg/somc-okf/src/branch/main/processes/development.md). Obtain wiki access before feature work; do not recreate a local knowledge bundle. Source builds do not require private wiki access.
|
||||||
|
|
||||||
1. Read `design/index.md` and every story related to the requested behavior.
|
Development follows [Development cycle](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/development-cycle.md): approved stories, failing tests, passing implementation, verification, then source/wiki commit and push. GitOps updates are committed locally **without pushing**; only [Do release](https://git.garvis.dev/dmg/somc-okf/src/branch/main/runbooks/do-release.md) authorizes a reviewed GitOps push.
|
||||||
2. Update an existing story or create a new `design/us-NNN-short-name.md` story before implementation.
|
|
||||||
3. Define observable acceptance criteria using user or operator language.
|
|
||||||
4. Set story status to `proposed` or `in-progress` while the work is incomplete.
|
|
||||||
|
|
||||||
While implementing:
|
|
||||||
|
|
||||||
1. Work in vertical slices against the documented acceptance criteria.
|
|
||||||
2. Add tests for important behavior before implementation when practical.
|
|
||||||
3. Keep implementation references and related-story links current.
|
|
||||||
4. Do not mark an acceptance criterion complete until the behavior exists and has been validated.
|
|
||||||
|
|
||||||
Before completing or committing:
|
|
||||||
|
|
||||||
1. Set completed story status to `implemented` or `verified` as appropriate.
|
|
||||||
2. Check completed acceptance criteria and record validation evidence.
|
|
||||||
3. Update `design/index.md` whenever stories are added, renamed, moved, or materially reclassified.
|
|
||||||
4. Add a high-level entry to `design/log.md` under the verified current date.
|
|
||||||
5. Run `npm run design:validate` along with relevant tests, type checks, lint, and builds.
|
|
||||||
|
|
||||||
## OKF conventions
|
|
||||||
|
|
||||||
- Every non-reserved Markdown file in `design/` must have YAML frontmatter with a non-empty `type`.
|
|
||||||
- User stories use `type: User Story` and include `story_id`, `status`, `title`, `description`, `tags`, and `timestamp`.
|
|
||||||
- Allowed story statuses are `proposed`, `in-progress`, `implemented`, and `verified`.
|
|
||||||
- `design/index.md` and `design/log.md` are reserved OKF files and follow the OKF index/log structures.
|
|
||||||
- Prefer structured sections: `# User Story`, `# Acceptance Criteria`, `# Implementation`, `# Validation`, and `# Related Stories`.
|
|
||||||
- Use repository-relative links and keep them valid when files move.
|
|
||||||
- Preserve unknown frontmatter extensions.
|
|
||||||
|
|
||||||
## Timestamps
|
|
||||||
|
|
||||||
Always run `date -u +%Y-%m-%dT%H:%M:%SZ` before adding or updating story timestamps or dated log entries. Never guess dates.
|
|
||||||
|
|||||||
+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}" \
|
||||||
|
|||||||
@@ -32,14 +32,17 @@ Set `IP_INTELLIGENCE_PROVIDER=proxycheck`, add `PROXYCHECK_API_KEY`, and configu
|
|||||||
|
|
||||||
Open `http://localhost:3000`.
|
Open `http://localhost:3000`.
|
||||||
|
|
||||||
|
## Admin suggestions
|
||||||
|
|
||||||
|
Administrators can read the configured Discord forum through the session-protected [suggestions API](docs/admin-suggestions-api.md). Set `DISCORD_SUGGESTIONS_FORUM_ID` through GitOps; the existing bot token stays server-side. This integration is read-only and does not synchronize data into the database.
|
||||||
|
|
||||||
## Product design
|
## Product design
|
||||||
|
|
||||||
Implemented and proposed behavior is tracked as OKF user stories in [`design/index.md`](design/index.md). Validate the bundle with `npm run design:validate`.
|
Implemented and proposed behavior is tracked in the private [SoMC OKF wiki](https://git.garvis.dev/dmg/somc-okf/src/branch/main/projects/minecraft-account-manager/index.md). Validate canonical knowledge in that repository with `okflint validate --manifest okf-base.yaml`; source builds do not require wiki access.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run design:validate
|
|
||||||
npm test
|
npm test
|
||||||
npm run typecheck
|
npm run typecheck
|
||||||
npm run lint
|
npm run lint
|
||||||
@@ -76,12 +79,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, 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'",
|
||||||
|
|||||||
+11
-1
@@ -17,19 +17,29 @@
|
|||||||
"@minecraft-account-manager/logging": "*",
|
"@minecraft-account-manager/logging": "*",
|
||||||
"@minecraft-account-manager/minecraft": "*",
|
"@minecraft-account-manager/minecraft": "*",
|
||||||
"@minecraft-account-manager/network": "*",
|
"@minecraft-account-manager/network": "*",
|
||||||
|
"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",
|
||||||
|
"world-atlas": "^2.0.2"
|
||||||
},
|
},
|
||||||
"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/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",
|
||||||
"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 })));
|
||||||
});
|
|
||||||
redirect(groupPath(group.id, "saved=member-removed"));
|
|
||||||
}
|
}
|
||||||
|
await tx.insert(events).values({
|
||||||
export async function assignDefaultGroup(formData: FormData) {
|
id: randomUUID(),
|
||||||
const admin = await requireAdminSession();
|
source: "/web/admin",
|
||||||
const groupId = String(formData.get("groupId") ?? "");
|
type: "games.minecraft.account-manager.group.schedule-updated",
|
||||||
const userId = String(formData.get("userId") ?? "");
|
subject: `group/${group.id}`,
|
||||||
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
|
time: new Date(),
|
||||||
|
data: auditData(admin, { name: group.name, previousWindows: previous, windows }),
|
||||||
const [[defaultGroup], [user]] = await Promise.all([
|
ipAddress: ipAddress ?? null,
|
||||||
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"));
|
return group;
|
||||||
|
});
|
||||||
|
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,8 +1,11 @@
|
|||||||
import { events, 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 { 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 { MAP_LOCATION_CLASSIFICATIONS, parseUserLocation, parseUserNetwork } from "@/lib/user-location-map";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -13,28 +16,57 @@ 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], 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
|
||||||
.select({
|
.selectDistinctOn([ipObservations.userId], {
|
||||||
|
userId: ipObservations.userId,
|
||||||
|
name: users.firstName,
|
||||||
|
discordUsername: users.discordUsername,
|
||||||
|
primaryUsername: minecraftAccounts.username,
|
||||||
|
classification: ipIntelligence.classification,
|
||||||
|
source: ipObservations.source,
|
||||||
|
observedAt: ipObservations.observedAt,
|
||||||
|
intelligence: ipIntelligence.rawResponse,
|
||||||
|
})
|
||||||
|
.from(ipObservations)
|
||||||
|
.innerJoin(users, eq(users.id, ipObservations.userId))
|
||||||
|
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||||
|
.leftJoin(minecraftAccounts, and(
|
||||||
|
eq(minecraftAccounts.userId, users.id),
|
||||||
|
eq(minecraftAccounts.isPrimary, true),
|
||||||
|
isNull(minecraftAccounts.deletedAt),
|
||||||
|
))
|
||||||
|
.where(and(
|
||||||
|
isNotNull(ipObservations.userId),
|
||||||
|
inArray(ipIntelligence.classification, MAP_LOCATION_CLASSIFICATIONS),
|
||||||
|
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'latitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'latitude')::double precision between -90 and 90 else false end`,
|
||||||
|
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'longitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'longitude')::double precision between -180 and 180 else false end`,
|
||||||
|
))
|
||||||
|
.orderBy(ipObservations.userId, desc(ipObservations.observedAt), desc(ipObservations.id)),
|
||||||
|
db
|
||||||
|
.selectDistinctOn([ipObservations.userId], {
|
||||||
id: ipObservations.id,
|
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,
|
||||||
@@ -43,17 +75,58 @@ 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 parsed = parseUserLocation(row.intelligence);
|
||||||
|
if (!parsed || !row.userId) return [];
|
||||||
|
const network = parseUserNetwork(row.intelligence);
|
||||||
|
return [{
|
||||||
|
userId: row.userId,
|
||||||
|
name: row.name ?? row.discordUsername,
|
||||||
|
discordUsername: row.discordUsername,
|
||||||
|
nickname: formatManagedDiscordNickname(row.name ?? row.discordUsername, row.primaryUsername ?? null),
|
||||||
|
latitude: parsed.latitude,
|
||||||
|
longitude: parsed.longitude,
|
||||||
|
location: parsed.label,
|
||||||
|
classification: row.classification,
|
||||||
|
networkProvider: network.provider,
|
||||||
|
networkAsn: network.asn,
|
||||||
|
connectionType: network.connectionType,
|
||||||
|
proxy: network.proxy,
|
||||||
|
source: row.source,
|
||||||
|
observedAt: row.observedAt,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||||
@@ -63,18 +136,20 @@ export default async function AdminDashboardPage() {
|
|||||||
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Live, server-rendered registration, activity, and network-risk signals from the account registry.</p>
|
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Live, server-rendered registration, activity, and network-risk signals from the account registry.</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section aria-label="Key metrics" className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
<UserWorldMap locations={locations} unavailableCount={Math.max(0, (totals?.users ?? 0) - locations.length)} />
|
||||||
|
|
||||||
|
<section aria-label="Key metrics" className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<Metric label="Registered users" value={totals?.users ?? 0} detail="All time" />
|
<Metric label="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">
|
||||||
@@ -83,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>
|
||||||
@@ -108,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;
|
||||||
@@ -121,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,248 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const actionState = vi.hoisted(() => ({
|
||||||
|
authorized: 0,
|
||||||
|
selected: [] as unknown[],
|
||||||
|
transactionSelected: [] as unknown[],
|
||||||
|
updates: [] as Record<string, unknown>[],
|
||||||
|
inserts: [] as unknown[],
|
||||||
|
audits: [] as Array<{ admin: unknown; subject: string; type: string; data: Record<string, unknown>; correlationId?: string }>,
|
||||||
|
auditFailure: false,
|
||||||
|
executions: [] as Array<{ connection: Record<string, unknown>; command: string }>,
|
||||||
|
gatewayResult: { ok: true, response: "private response" } as
|
||||||
|
| { ok: true; response: string }
|
||||||
|
| { ok: false; reason: "busy" | "timeout" | "unavailable" },
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/auth/require-admin", () => ({
|
||||||
|
requireAdminSession: async () => {
|
||||||
|
actionState.authorized += 1;
|
||||||
|
return { email: "admin@example.test", name: "Admin" };
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/headers", () => ({ headers: async () => new Headers() }));
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
redirect: (path: string) => {
|
||||||
|
throw new Error(`REDIRECT:${path}`);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/database", () => {
|
||||||
|
function selection(result: unknown[]) {
|
||||||
|
const chain = {
|
||||||
|
from: () => chain,
|
||||||
|
where: () => chain,
|
||||||
|
limit: async () => result,
|
||||||
|
};
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
const tx = {
|
||||||
|
execute: async () => undefined,
|
||||||
|
select: () => selection(actionState.transactionSelected),
|
||||||
|
update: () => ({
|
||||||
|
set: (value: Record<string, unknown>) => ({
|
||||||
|
where: async () => { actionState.updates.push(value); },
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
insert: () => ({
|
||||||
|
values: async (value: unknown) => { actionState.inserts.push(value); },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
db: {
|
||||||
|
select: () => selection(actionState.selected),
|
||||||
|
transaction: async (callback: (transaction: typeof tx) => Promise<unknown>) => callback(tx),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/rcon-validation", () => ({
|
||||||
|
validateRconCommand: (value: unknown) => typeof value === "string" && value.trim() ? value.trim() : null,
|
||||||
|
validateRconConnection: (input: { name?: string; host?: string; port?: number; password?: string }) => {
|
||||||
|
if (!input.name || !input.host || !input.port) return null;
|
||||||
|
return input;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/rcon-credentials", () => ({
|
||||||
|
decryptRconPassword: () => "decrypted-password",
|
||||||
|
encryptRconPassword: vi.fn(),
|
||||||
|
rconCommandDigest: () => "hmac-sha256:v1:digest",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/rcon-gateway", () => ({
|
||||||
|
executeRcon: async (connection: Record<string, unknown>, command: string) => {
|
||||||
|
actionState.executions.push({ connection, command });
|
||||||
|
return actionState.gatewayResult;
|
||||||
|
},
|
||||||
|
testRconConnection: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/audit", () => ({
|
||||||
|
recordAdminSubjectEvent: async (
|
||||||
|
admin: unknown,
|
||||||
|
subject: string,
|
||||||
|
type: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
options?: { correlationId?: string },
|
||||||
|
) => {
|
||||||
|
if (actionState.auditFailure) throw new Error("audit unavailable");
|
||||||
|
actionState.audits.push({ admin, subject, type, data, correlationId: options?.correlationId });
|
||||||
|
return "22222222-2222-4222-8222-222222222222";
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
createRconServer,
|
||||||
|
deleteRconServer,
|
||||||
|
executeRconCommand,
|
||||||
|
setRconServerEnabled,
|
||||||
|
testSavedRconServer,
|
||||||
|
updateRconServer,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
|
const serverId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
const savedServer = {
|
||||||
|
id: serverId,
|
||||||
|
name: "Season 4",
|
||||||
|
host: "season4.somc.svc.cluster.local",
|
||||||
|
port: 25575,
|
||||||
|
encryptedPassword: "ciphertext",
|
||||||
|
enabled: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("RCON server actions", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
actionState.authorized = 0;
|
||||||
|
actionState.selected = [];
|
||||||
|
actionState.transactionSelected = [];
|
||||||
|
actionState.updates = [];
|
||||||
|
actionState.inserts = [];
|
||||||
|
actionState.audits = [];
|
||||||
|
actionState.auditFailure = false;
|
||||||
|
actionState.executions = [];
|
||||||
|
actionState.gatewayResult = { ok: true, response: "private response" };
|
||||||
|
});
|
||||||
|
|
||||||
|
it("independently authorizes every exported operation before accepting input", async () => {
|
||||||
|
await expect(createRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
|
||||||
|
await expect(updateRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=invalid-connection");
|
||||||
|
await expect(setRconServerEnabled(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=unknown-connection");
|
||||||
|
await expect(deleteRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=confirmation-required");
|
||||||
|
await expect(testSavedRconServer(new FormData())).rejects.toThrow("REDIRECT:/admin/rcon?error=connection-unavailable");
|
||||||
|
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, new FormData())).resolves.toEqual({
|
||||||
|
status: "error",
|
||||||
|
message: "Enter one command of at most 1,024 bytes without control characters.",
|
||||||
|
serverId: "",
|
||||||
|
});
|
||||||
|
expect(actionState.authorized).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the encrypted password on an unrelated connection update", async () => {
|
||||||
|
actionState.transactionSelected = [savedServer];
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("serverId", serverId);
|
||||||
|
formData.set("name", "Renamed server");
|
||||||
|
formData.set("host", "season4.somc.svc.cluster.local");
|
||||||
|
formData.set("port", "25575");
|
||||||
|
formData.set("password", "");
|
||||||
|
formData.set("enabled", "yes");
|
||||||
|
|
||||||
|
await expect(updateRconServer(formData)).rejects.toThrow("REDIRECT:/admin/rcon?saved=updated");
|
||||||
|
expect(actionState.updates).toEqual([
|
||||||
|
expect.objectContaining({ encryptedPassword: "ciphertext", enabled: true }),
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(actionState.inserts)).not.toContain("ciphertext");
|
||||||
|
expect(JSON.stringify(actionState.inserts)).not.toContain("decrypted-password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rechecks enabled saved state and records complete command lifecycle audits", async () => {
|
||||||
|
actionState.selected = [savedServer];
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("serverId", serverId);
|
||||||
|
formData.set("command", "say private value");
|
||||||
|
|
||||||
|
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
|
||||||
|
status: "success",
|
||||||
|
message: "private response",
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(actionState.authorized).toBe(1);
|
||||||
|
expect(actionState.executions).toEqual([{
|
||||||
|
connection: expect.objectContaining({ id: serverId, enabled: true, password: "decrypted-password" }),
|
||||||
|
command: "say private value",
|
||||||
|
}]);
|
||||||
|
expect(actionState.audits).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
admin: { email: "admin@example.test", name: "Admin" },
|
||||||
|
subject: `rcon-server/${serverId}`,
|
||||||
|
type: "games.minecraft.account-manager.rcon.command.requested",
|
||||||
|
data: expect.objectContaining({ command: "say private value", verb: "say", commandDigest: "hmac-sha256:v1:digest" }),
|
||||||
|
correlationId: expect.stringMatching(/^[0-9a-f-]{36}$/),
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
subject: `rcon-server/${serverId}`,
|
||||||
|
type: "games.minecraft.account-manager.rcon.command.completed",
|
||||||
|
data: expect.objectContaining({ success: true, durationMs: expect.any(Number) }),
|
||||||
|
correlationId: expect.stringMatching(/^[0-9a-f-]{36}$/),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const serializedAudits = JSON.stringify(actionState.audits);
|
||||||
|
expect(actionState.audits[0]?.correlationId).toBe(actionState.audits[1]?.correlationId);
|
||||||
|
expect(serializedAudits).toContain("private value");
|
||||||
|
expect(serializedAudits).not.toContain("private response");
|
||||||
|
expect(serializedAudits).not.toContain("decrypted-password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["busy", "Another command is already running for this server."],
|
||||||
|
["timeout", "The RCON request timed out."],
|
||||||
|
["unavailable", "The RCON server was unavailable or rejected authentication."],
|
||||||
|
] as const)("returns a safe %s failure without exposing transport details", async (reason, message) => {
|
||||||
|
actionState.selected = [savedServer];
|
||||||
|
actionState.gatewayResult = { ok: false, reason };
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("serverId", serverId);
|
||||||
|
formData.set("command", "list");
|
||||||
|
|
||||||
|
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
|
||||||
|
status: "error",
|
||||||
|
message,
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(actionState.audits)).not.toContain("decrypted-password");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not send a command when its requested audit cannot be recorded", async () => {
|
||||||
|
actionState.selected = [savedServer];
|
||||||
|
actionState.auditFailure = true;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("serverId", serverId);
|
||||||
|
formData.set("command", "list");
|
||||||
|
|
||||||
|
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
|
||||||
|
status: "error",
|
||||||
|
message: "Command not sent because its audit record could not be created.",
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
expect(actionState.executions).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not execute or audit when the enabled connection is unavailable", async () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set("serverId", serverId);
|
||||||
|
formData.set("command", "list");
|
||||||
|
|
||||||
|
await expect(executeRconCommand({ status: "idle", message: "", serverId: "" }, formData)).resolves.toEqual({
|
||||||
|
status: "error",
|
||||||
|
message: "That RCON connection is disabled or unavailable.",
|
||||||
|
serverId,
|
||||||
|
});
|
||||||
|
expect(actionState.executions).toEqual([]);
|
||||||
|
expect(actionState.audits).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { events, rconServers } from "@minecraft-account-manager/database";
|
||||||
|
import { getClientIp } from "@minecraft-account-manager/network";
|
||||||
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||||
|
import { db } from "@/lib/database";
|
||||||
|
import { isUniqueConstraintViolation } from "@/lib/database-errors";
|
||||||
|
import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "@/lib/rcon-credentials";
|
||||||
|
import { executeRcon, testRconConnection } from "@/lib/rcon-gateway";
|
||||||
|
import { recordAdminSubjectEvent } from "@/lib/audit";
|
||||||
|
import { validateRconCommand, validateRconConnection } from "@/lib/rcon-validation";
|
||||||
|
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
type Admin = Awaited<ReturnType<typeof requireAdminSession>>;
|
||||||
|
export type RconCommandState = {
|
||||||
|
status: "idle" | "success" | "error";
|
||||||
|
message: string;
|
||||||
|
serverId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function formConnection(formData: FormData, passwordRequired: boolean) {
|
||||||
|
return validateRconConnection({
|
||||||
|
name: formData.get("name"),
|
||||||
|
host: formData.get("host"),
|
||||||
|
port: formData.get("port"),
|
||||||
|
password: formData.get("password"),
|
||||||
|
}, { passwordRequired });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function auditContext() {
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
return getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditData(admin: Admin, data: Record<string, unknown>) {
|
||||||
|
return { ...data, adminEmail: admin.email, adminName: admin.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function rconPath(query: string) {
|
||||||
|
return `/admin/rcon?${query}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createRconServer(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const details = formConnection(formData, true);
|
||||||
|
if (!details?.password) redirect(rconPath("error=invalid-connection"));
|
||||||
|
const id = randomUUID();
|
||||||
|
let encryptedPassword: string;
|
||||||
|
try {
|
||||||
|
encryptedPassword = encryptRconPassword(details.password, id);
|
||||||
|
} catch {
|
||||||
|
redirect(rconPath("error=configuration"));
|
||||||
|
}
|
||||||
|
const ipAddress = await auditContext();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.transaction(async (tx) => {
|
||||||
|
await tx.insert(rconServers).values({
|
||||||
|
id,
|
||||||
|
name: details.name,
|
||||||
|
host: details.host,
|
||||||
|
port: details.port,
|
||||||
|
encryptedPassword,
|
||||||
|
enabled: formData.get("enabled") === "yes",
|
||||||
|
});
|
||||||
|
await tx.insert(events).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
source: "/web/admin",
|
||||||
|
type: "games.minecraft.account-manager.rcon.connection.created",
|
||||||
|
subject: `rcon-server/${id}`,
|
||||||
|
time: new Date(),
|
||||||
|
data: auditData(admin, { name: details.name, host: details.host, port: details.port }),
|
||||||
|
ipAddress: ipAddress ?? null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueConstraintViolation(error, "rcon_servers_name_uidx")) redirect(rconPath("error=duplicate-name"));
|
||||||
|
redirect(rconPath("error=save-failed"));
|
||||||
|
}
|
||||||
|
redirect(rconPath("saved=created"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateRconServer(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const serverId = String(formData.get("serverId") ?? "");
|
||||||
|
const details = formConnection(formData, false);
|
||||||
|
if (!UUID_PATTERN.test(serverId) || !details) redirect(rconPath("error=invalid-connection"));
|
||||||
|
const ipAddress = await auditContext();
|
||||||
|
|
||||||
|
let result: string | null;
|
||||||
|
try {
|
||||||
|
result = await db.transaction(async (tx) => {
|
||||||
|
await tx.execute(sql`select ${rconServers.id} from ${rconServers} where ${rconServers.id} = ${serverId} for update`);
|
||||||
|
const [current] = await tx.select().from(rconServers).where(eq(rconServers.id, serverId)).limit(1);
|
||||||
|
if (!current) return null;
|
||||||
|
let encryptedPassword = current.encryptedPassword;
|
||||||
|
if (details.password) encryptedPassword = encryptRconPassword(details.password, current.id);
|
||||||
|
const enabled = formData.get("enabled") === "yes";
|
||||||
|
await tx.update(rconServers).set({
|
||||||
|
name: details.name,
|
||||||
|
host: details.host,
|
||||||
|
port: details.port,
|
||||||
|
encryptedPassword,
|
||||||
|
enabled,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}).where(eq(rconServers.id, current.id));
|
||||||
|
await tx.insert(events).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
source: "/web/admin",
|
||||||
|
type: "games.minecraft.account-manager.rcon.connection.updated",
|
||||||
|
subject: `rcon-server/${current.id}`,
|
||||||
|
time: new Date(),
|
||||||
|
data: auditData(admin, {
|
||||||
|
name: details.name,
|
||||||
|
host: details.host,
|
||||||
|
port: details.port,
|
||||||
|
enabled,
|
||||||
|
passwordReplaced: Boolean(details.password),
|
||||||
|
}),
|
||||||
|
ipAddress: ipAddress ?? null,
|
||||||
|
});
|
||||||
|
return current.id;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueConstraintViolation(error, "rcon_servers_name_uidx")) redirect(rconPath("error=duplicate-name"));
|
||||||
|
redirect(rconPath("error=save-failed"));
|
||||||
|
}
|
||||||
|
if (!result) redirect(rconPath("error=unknown-connection"));
|
||||||
|
redirect(rconPath("saved=updated"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setRconServerEnabled(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const serverId = String(formData.get("serverId") ?? "");
|
||||||
|
if (!UUID_PATTERN.test(serverId)) redirect(rconPath("error=unknown-connection"));
|
||||||
|
const enabled = formData.get("enabled") === "yes";
|
||||||
|
const ipAddress = await auditContext();
|
||||||
|
|
||||||
|
const result = await db.transaction(async (tx) => {
|
||||||
|
await tx.execute(sql`select ${rconServers.id} from ${rconServers} where ${rconServers.id} = ${serverId} for update`);
|
||||||
|
const [current] = await tx.select().from(rconServers).where(eq(rconServers.id, serverId)).limit(1);
|
||||||
|
if (!current) return "missing" as const;
|
||||||
|
if (enabled && !validateRconConnection({ ...current, password: "placeholder" }, { passwordRequired: true })) return "invalid" as const;
|
||||||
|
await tx.update(rconServers).set({ enabled, updatedAt: new Date() }).where(eq(rconServers.id, current.id));
|
||||||
|
await tx.insert(events).values({
|
||||||
|
id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.rcon.connection.enabled-updated",
|
||||||
|
subject: `rcon-server/${current.id}`, time: new Date(), data: auditData(admin, { name: current.name, enabled }), ipAddress: ipAddress ?? null,
|
||||||
|
});
|
||||||
|
return "updated" as const;
|
||||||
|
});
|
||||||
|
if (result === "missing") redirect(rconPath("error=unknown-connection"));
|
||||||
|
if (result === "invalid") redirect(rconPath("error=invalid-connection"));
|
||||||
|
redirect(rconPath(`saved=${enabled ? "enabled" : "disabled"}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteRconServer(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const serverId = String(formData.get("serverId") ?? "");
|
||||||
|
if (!UUID_PATTERN.test(serverId) || formData.get("confirmation") !== serverId) redirect(rconPath("error=confirmation-required"));
|
||||||
|
const ipAddress = await auditContext();
|
||||||
|
|
||||||
|
const deleted = await db.transaction(async (tx) => {
|
||||||
|
const [server] = await tx.delete(rconServers).where(eq(rconServers.id, serverId)).returning({ id: rconServers.id, name: rconServers.name });
|
||||||
|
if (!server) return null;
|
||||||
|
await tx.insert(events).values({
|
||||||
|
id: randomUUID(), source: "/web/admin", type: "games.minecraft.account-manager.rcon.connection.deleted",
|
||||||
|
subject: `rcon-server/${server.id}`, time: new Date(), data: auditData(admin, { name: server.name }), ipAddress: ipAddress ?? null,
|
||||||
|
});
|
||||||
|
return server;
|
||||||
|
});
|
||||||
|
if (!deleted) redirect(rconPath("error=unknown-connection"));
|
||||||
|
redirect(rconPath("saved=deleted"));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savedConnection(serverId: string, requireEnabled: boolean) {
|
||||||
|
if (!UUID_PATTERN.test(serverId)) return null;
|
||||||
|
const [server] = await db.select().from(rconServers).where(requireEnabled
|
||||||
|
? and(eq(rconServers.id, serverId), eq(rconServers.enabled, true))
|
||||||
|
: eq(rconServers.id, serverId)).limit(1);
|
||||||
|
if (!server) return null;
|
||||||
|
const validated = validateRconConnection({ ...server, password: "placeholder" }, { passwordRequired: true });
|
||||||
|
if (!validated) return null;
|
||||||
|
try {
|
||||||
|
return { ...server, password: decryptRconPassword(server.encryptedPassword, server.id) };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testSavedRconServer(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const serverId = String(formData.get("serverId") ?? "");
|
||||||
|
const server = await savedConnection(serverId, false);
|
||||||
|
if (!server) redirect(rconPath("error=connection-unavailable"));
|
||||||
|
const started = Date.now();
|
||||||
|
const result = await testRconConnection(server);
|
||||||
|
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.connection.tested", {
|
||||||
|
serverId: server.id,
|
||||||
|
name: server.name,
|
||||||
|
success: result.ok,
|
||||||
|
reason: result.ok ? null : result.reason,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
});
|
||||||
|
redirect(rconPath(result.ok ? "saved=tested" : `error=test-${result.reason}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeRconCommand(
|
||||||
|
_previous: RconCommandState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<RconCommandState> {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const serverId = String(formData.get("serverId") ?? "");
|
||||||
|
const command = validateRconCommand(formData.get("command"));
|
||||||
|
if (!command) return { status: "error", message: "Enter one command of at most 1,024 bytes without control characters.", serverId };
|
||||||
|
const server = await savedConnection(serverId, true);
|
||||||
|
if (!server) return { status: "error", message: "That RCON connection is disabled or unavailable.", serverId };
|
||||||
|
const verb = command.split(/\s+/u, 1)[0]!.toLowerCase().slice(0, 64);
|
||||||
|
let commandDigest: string;
|
||||||
|
try {
|
||||||
|
commandDigest = rconCommandDigest(command);
|
||||||
|
} catch {
|
||||||
|
return { status: "error", message: "RCON command auditing is not configured.", serverId };
|
||||||
|
}
|
||||||
|
const started = Date.now();
|
||||||
|
const correlationId = randomUUID();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.requested", {
|
||||||
|
serverId: server.id,
|
||||||
|
name: server.name,
|
||||||
|
command,
|
||||||
|
verb,
|
||||||
|
commandDigest,
|
||||||
|
}, { correlationId });
|
||||||
|
} catch {
|
||||||
|
return { status: "error", message: "Command not sent because its audit record could not be created.", serverId };
|
||||||
|
}
|
||||||
|
const result = await executeRcon(server, command);
|
||||||
|
await recordAdminSubjectEvent(admin, `rcon-server/${server.id}`, "games.minecraft.account-manager.rcon.command.completed", {
|
||||||
|
serverId: server.id,
|
||||||
|
name: server.name,
|
||||||
|
verb,
|
||||||
|
commandDigest,
|
||||||
|
success: result.ok,
|
||||||
|
reason: result.ok ? null : result.reason,
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
}, { correlationId });
|
||||||
|
if (!result.ok) {
|
||||||
|
const message = result.reason === "busy"
|
||||||
|
? "Another command is already running for this server."
|
||||||
|
: result.reason === "timeout"
|
||||||
|
? "The RCON request timed out."
|
||||||
|
: "The RCON server was unavailable or rejected authentication.";
|
||||||
|
return { status: "error", message, serverId };
|
||||||
|
}
|
||||||
|
return { status: "success", message: result.response || "Command completed with no response.", serverId };
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { events, rconServers } from "@minecraft-account-manager/database";
|
||||||
|
import { and, desc, eq, ilike, inArray, or, sql, type SQL } from "drizzle-orm";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { db } from "@/lib/database";
|
||||||
|
import {
|
||||||
|
buildRconCommandHistory,
|
||||||
|
normalizeRconHistoryFilters,
|
||||||
|
RCON_COMMAND_COMPLETED,
|
||||||
|
RCON_COMMAND_REQUESTED,
|
||||||
|
} from "@/lib/rcon-command-history";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const requestedFields = {
|
||||||
|
id: events.id,
|
||||||
|
time: events.time,
|
||||||
|
correlationId: events.correlationId,
|
||||||
|
data: events.data,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function RconHistoryPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
const [query, servers] = await Promise.all([
|
||||||
|
searchParams,
|
||||||
|
db.select({ id: rconServers.id, name: rconServers.name }).from(rconServers).orderBy(rconServers.name),
|
||||||
|
]);
|
||||||
|
const filters = normalizeRconHistoryFilters(query, servers.map((server) => server.id));
|
||||||
|
const conditions: SQL[] = [eq(events.type, RCON_COMMAND_REQUESTED)];
|
||||||
|
if (filters.serverId) conditions.push(eq(events.subject, `rcon-server/${filters.serverId}`));
|
||||||
|
if (filters.command) conditions.push(ilike(sql<string>`${events.data} ->> 'command'`, `%${filters.command}%`));
|
||||||
|
if (filters.admin) {
|
||||||
|
conditions.push(or(
|
||||||
|
ilike(sql<string>`${events.data} ->> 'adminEmail'`, `%${filters.admin}%`),
|
||||||
|
ilike(sql<string>`${events.data} ->> 'adminName'`, `%${filters.admin}%`),
|
||||||
|
)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
const requested = await db.select(requestedFields)
|
||||||
|
.from(events)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(events.time))
|
||||||
|
.limit(100);
|
||||||
|
const correlationIds = requested.flatMap((event) => event.correlationId ? [event.correlationId] : []);
|
||||||
|
const completed = correlationIds.length
|
||||||
|
? await db.select(requestedFields).from(events).where(and(
|
||||||
|
eq(events.type, RCON_COMMAND_COMPLETED),
|
||||||
|
inArray(events.correlationId, correlationIds),
|
||||||
|
))
|
||||||
|
: [];
|
||||||
|
const history = buildRconCommandHistory(requested, completed);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-7xl px-6 py-14">
|
||||||
|
<Link className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted underline underline-offset-4" href="/admin/rcon">← RCON console</Link>
|
||||||
|
<header className="mt-7 grid gap-5 border-b-2 border-ink pb-8 lg:grid-cols-[1fr_auto] lg:items-end">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Persistent audit ledger</p>
|
||||||
|
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">Command history</h1>
|
||||||
|
<p className="mt-4 max-w-2xl text-sm leading-6 text-muted">Search commands sent through the portal. Responses and RCON credentials are never retained here.</p>
|
||||||
|
</div>
|
||||||
|
<div className="border border-line bg-panel px-4 py-3 font-mono text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
<span className="text-accent">{history.length}</span> matching records
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form className="mt-8 border border-line bg-panel p-5 shadow-[6px_6px_0_var(--color-shadow)]" method="get">
|
||||||
|
<div className="grid gap-5 md:grid-cols-3">
|
||||||
|
<Filter label="Command text" name="command" placeholder="say, whitelist add…" value={filters.command} />
|
||||||
|
<Filter label="Administrator" name="admin" placeholder="name or email" value={filters.admin} />
|
||||||
|
<label className="font-mono text-[10px] font-bold uppercase tracking-wider" htmlFor="history-server">
|
||||||
|
Server
|
||||||
|
<select className="mt-2 block w-full border border-line bg-canvas px-3 py-3 font-sans text-sm font-normal normal-case outline-none focus:border-accent" defaultValue={filters.serverId} id="history-server" name="server">
|
||||||
|
<option value="">All servers</option>
|
||||||
|
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex flex-wrap gap-4">
|
||||||
|
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Search history</button>
|
||||||
|
<Link className="self-center font-mono text-[10px] font-bold uppercase underline underline-offset-4" href="/admin/rcon/history">Clear filters</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-8 overflow-x-auto border-2 border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||||
|
<table className="w-full min-w-[980px] border-collapse text-left">
|
||||||
|
<caption className="sr-only">RCON command audit history</caption>
|
||||||
|
<thead className="border-b-2 border-ink bg-canvas font-mono text-[10px] uppercase tracking-widest text-muted">
|
||||||
|
<tr><th className="p-4" scope="col">Time</th><th className="p-4" scope="col">Server</th><th className="p-4" scope="col">Administrator</th><th className="p-4" scope="col">Command</th><th className="p-4" scope="col">Outcome</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-line text-xs">
|
||||||
|
{history.map((entry) => (
|
||||||
|
<tr className="align-top hover:bg-canvas/60" key={entry.eventId}>
|
||||||
|
<td className="whitespace-nowrap p-4 font-mono text-muted"><Link className="underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/events/${entry.eventId}`}><time dateTime={entry.time.toISOString()}>{entry.time.toISOString()}</time></Link></td>
|
||||||
|
<td className="p-4"><span className="font-mono font-bold">{entry.serverName}</span><span className="mt-1 block font-mono text-[9px] text-muted">{entry.serverId}</span></td>
|
||||||
|
<td className="p-4"><span className="font-bold">{entry.adminName ?? "Unknown administrator"}</span><span className="mt-1 block font-mono text-[10px] text-muted">{entry.adminEmail ?? "Email unavailable"}</span></td>
|
||||||
|
<td className="max-w-xl p-4"><code className="whitespace-pre-wrap break-words font-mono text-xs"><span className="mr-2 text-accent">$</span>{entry.command}</code></td>
|
||||||
|
<td className="p-4"><Outcome status={entry.status} />{entry.reason && <span className="mt-2 block font-mono text-[9px] text-muted">{entry.reason}</span>}{entry.durationMs !== null && <span className="mt-1 block font-mono text-[9px] text-muted">{entry.durationMs} ms</span>}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!history.length && <tr><td className="p-10 text-center text-muted" colSpan={5}>No RCON commands match these filters.</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Filter({ label, name, placeholder, value }: { label: string; name: string; placeholder: string; value: string }) {
|
||||||
|
return <label className="font-mono text-[10px] font-bold uppercase tracking-wider" htmlFor={`history-${name}`}>{label}<input className="mt-2 block w-full border border-line bg-canvas px-3 py-3 font-sans text-sm font-normal normal-case outline-none placeholder:text-muted focus:border-accent" defaultValue={value} id={`history-${name}`} maxLength={name === "command" ? 1024 : 320} name={name} placeholder={placeholder} /></label>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Outcome({ status }: { status: "pending" | "succeeded" | "failed" }) {
|
||||||
|
const className = status === "succeeded" ? "border-signal text-ink" : status === "failed" ? "border-accent text-accent" : "border-line text-muted";
|
||||||
|
return <span className={`inline-block border-l-2 pl-2 font-mono text-[9px] font-bold uppercase tracking-wider ${className}`}>{status}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { rconServers } from "@minecraft-account-manager/database";
|
||||||
|
import { asc } from "drizzle-orm";
|
||||||
|
import { RconConsole, type RconTerminalNotice } from "@/components/rcon-console";
|
||||||
|
import { db } from "@/lib/database";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const savedMessages: Record<string, string> = {
|
||||||
|
created: "RCON connection created.",
|
||||||
|
updated: "RCON connection updated.",
|
||||||
|
enabled: "RCON connection enabled.",
|
||||||
|
disabled: "RCON connection disabled.",
|
||||||
|
deleted: "RCON connection deleted.",
|
||||||
|
tested: "RCON authentication succeeded.",
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorMessages: Record<string, string> = {
|
||||||
|
"invalid-connection": "Enter a valid DNS hostname, port, name, and password.",
|
||||||
|
"duplicate-name": "Connection names must be unique.",
|
||||||
|
configuration: "RCON credential encryption is not configured.",
|
||||||
|
"save-failed": "The RCON connection could not be saved.",
|
||||||
|
"unknown-connection": "That RCON connection no longer exists.",
|
||||||
|
"confirmation-required": "Confirm the connection before deleting it.",
|
||||||
|
"connection-unavailable": "The connection is invalid or its credential is unavailable.",
|
||||||
|
"test-busy": "Another RCON operation is already using that server.",
|
||||||
|
"test-timeout": "RCON authentication timed out.",
|
||||||
|
"test-unavailable": "The RCON server was unavailable or rejected authentication.",
|
||||||
|
};
|
||||||
|
|
||||||
|
function queryValue(value: string | string[] | undefined) {
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function RconPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
const query = await searchParams;
|
||||||
|
const saved = queryValue(query.saved);
|
||||||
|
const error = queryValue(query.error);
|
||||||
|
const notice: RconTerminalNotice | undefined = error
|
||||||
|
? { status: "error", message: errorMessages[error] ?? "The RCON operation failed." }
|
||||||
|
: saved
|
||||||
|
? { status: "success", message: savedMessages[saved] ?? "RCON settings saved." }
|
||||||
|
: undefined;
|
||||||
|
const servers = await db.select({
|
||||||
|
id: rconServers.id,
|
||||||
|
name: rconServers.name,
|
||||||
|
host: rconServers.host,
|
||||||
|
port: rconServers.port,
|
||||||
|
enabled: rconServers.enabled,
|
||||||
|
}).from(rconServers).orderBy(asc(rconServers.name));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||||
|
<header className="border-b border-line pb-8">
|
||||||
|
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Server operations</p>
|
||||||
|
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">RCON</h1>
|
||||||
|
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Select and manage a connection, then run commands through the portal backend. Credentials are never sent to the browser.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="mt-10">
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Command proxy</p>
|
||||||
|
<h2 className="mt-2 font-display text-3xl font-black uppercase">Terminal</h2>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-xl text-xs leading-5 text-muted">Only the latest bounded response is shown. Commands and responses are not saved as console history.</p>
|
||||||
|
</div>
|
||||||
|
<RconConsole notice={notice} servers={servers} />
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,14 @@
|
|||||||
|
import { suggestionQuery, suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export function GET(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
return suggestionsApi(request, async (client) => client.messages((await context.params).id, suggestionQuery(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST = suggestionsReadOnly;
|
||||||
|
export const PUT = suggestionsReadOnly;
|
||||||
|
export const PATCH = suggestionsReadOnly;
|
||||||
|
export const DELETE = suggestionsReadOnly;
|
||||||
|
export const OPTIONS = suggestionsReadOnly;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export function GET(request: Request, context: { params: Promise<{ id: string }> }) {
|
||||||
|
return suggestionsApi(request, async (client) => client.detail((await context.params).id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST = suggestionsReadOnly;
|
||||||
|
export const PUT = suggestionsReadOnly;
|
||||||
|
export const PATCH = suggestionsReadOnly;
|
||||||
|
export const DELETE = suggestionsReadOnly;
|
||||||
|
export const OPTIONS = suggestionsReadOnly;
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
const auth = vi.hoisted(() => ({ session: vi.fn() }));
|
||||||
|
vi.mock("next-auth", () => ({ getServerSession: auth.session }));
|
||||||
|
vi.mock("@/lib/auth/admin-auth", () => ({ adminAuthOptions: {}, requiredAdminRole: "ops" }));
|
||||||
|
import { GET, POST } from "./route";
|
||||||
|
import { GET as detail } from "./[id]/route";
|
||||||
|
import { GET as messages } from "./[id]/messages/route";
|
||||||
|
const context = { params: Promise.resolve({ id: "100000000000000009" }) };
|
||||||
|
|
||||||
|
const request = () => new Request("https://portal.example/api/suggestions");
|
||||||
|
afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); vi.unstubAllEnvs(); });
|
||||||
|
it("serves suggestions to the existing admin session using runtime env configuration", async () => {
|
||||||
|
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||||
|
vi.stubEnv("DISCORD_BOT_TOKEN", "test-token");
|
||||||
|
vi.stubEnv("DISCORD_GUILD_ID", "100000000000000001");
|
||||||
|
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", "100000000000000002");
|
||||||
|
const fetcher = vi.fn().mockResolvedValueOnce(Response.json({ id: "100000000000000002", guild_id: "100000000000000001", type: 15, available_tags: [] })).mockResolvedValueOnce(Response.json({ threads: [] }));
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
const response = await GET(request());
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toEqual({ items: [], nextCursor: null });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
it.each([detail, messages])("independently protects detail and message routes", async (handler) => {
|
||||||
|
const fetcher = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
auth.session.mockResolvedValue(null);
|
||||||
|
expect((await handler(request(), context)).status).toBe(401);
|
||||||
|
auth.session.mockResolvedValue({ user: { roles: ["player"] } });
|
||||||
|
expect((await handler(request(), context)).status).toBe(403);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it.each(["?limit=0", "?limit=101", "?limit=1.2", "?limit=", "?limit=1&limit=2", "?channel=100000000000000099", "?status=all", "?cursor=../secret"])('rejects invalid query %s without contacting Discord', async (query) => {
|
||||||
|
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||||
|
const fetcher = vi.fn();
|
||||||
|
vi.stubGlobal("fetch", fetcher);
|
||||||
|
const response = await GET(new Request(`https://portal.example/api/suggestions${query}`));
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it("returns a read-only problem for writes", async () => {
|
||||||
|
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||||
|
const response = await POST(request());
|
||||||
|
expect(response.status).toBe(405);
|
||||||
|
expect(response.headers.get("allow")).toBe("GET, HEAD");
|
||||||
|
});
|
||||||
|
it("reports missing configuration without exposing environment values", async () => {
|
||||||
|
auth.session.mockResolvedValue({ user: { roles: ["ops"] } });
|
||||||
|
vi.stubEnv("DISCORD_SUGGESTIONS_FORUM_ID", "");
|
||||||
|
const response = await GET(request());
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(await response.json()).toMatchObject({ type: "urn:error:suggestions-not-configured", status: 503, instance: "/api/suggestions" });
|
||||||
|
});
|
||||||
|
it("returns sanitized problems for unexpected failures", async () => {
|
||||||
|
auth.session.mockRejectedValue(new Error("private session details"));
|
||||||
|
const response = await GET(request());
|
||||||
|
expect(response.status).toBe(503);
|
||||||
|
expect(await response.text()).not.toContain("private session details");
|
||||||
|
});
|
||||||
|
it("rejects a signed-in user without the required admin role", async () => {
|
||||||
|
auth.session.mockResolvedValue({ user: { roles: ["player"] } });
|
||||||
|
expect((await GET(request())).status).toBe(403);
|
||||||
|
});
|
||||||
|
it("rejects unauthenticated readers with a JSON problem instead of a redirect", async () => {
|
||||||
|
auth.session.mockResolvedValue(null);
|
||||||
|
const response = await GET(request());
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(response.headers.get("content-type")).toBe("application/problem+json");
|
||||||
|
expect(response.headers.get("location")).toBeNull();
|
||||||
|
});
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { suggestionQuery, suggestionsApi, suggestionsReadOnly } from "@/lib/discord/suggestions-api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
|
export function GET(request: Request) {
|
||||||
|
return suggestionsApi(request, (client) => client.list(suggestionQuery(request, true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const POST = suggestionsReadOnly;
|
||||||
|
export const PUT = suggestionsReadOnly;
|
||||||
|
export const PATCH = suggestionsReadOnly;
|
||||||
|
export const DELETE = suggestionsReadOnly;
|
||||||
|
export const OPTIONS = suggestionsReadOnly;
|
||||||
@@ -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 {
|
||||||
@@ -58,6 +59,34 @@ body {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
svg a:hover .map-marker,
|
||||||
|
svg a:focus .map-marker {
|
||||||
|
stroke: var(--ink);
|
||||||
|
stroke-width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker-tooltip {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-marker-link:hover .map-marker-tooltip,
|
||||||
|
.map-marker-link:focus .map-marker-tooltip {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-user-cluster {
|
||||||
|
display: grid !important;
|
||||||
|
place-items: center;
|
||||||
|
border: 3px solid var(--panel);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--panel);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
box-shadow: 0 0 0 1px var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--panel);
|
color: var(--panel);
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { GET } from "./route";
|
import { GET } from "./route";
|
||||||
|
|
||||||
describe("health endpoint", () => {
|
describe("health endpoint", () => {
|
||||||
it("reports process readiness without requiring external services", async () => {
|
afterEach(() => vi.unstubAllEnvs());
|
||||||
|
|
||||||
|
it("reports process readiness and the immutable build version without requiring external services", async () => {
|
||||||
|
vi.stubEnv("APP_VERSION", "1.19.0");
|
||||||
|
|
||||||
const response = GET();
|
const response = GET();
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
expect(await response.json()).toEqual({ status: "ok" });
|
expect(await response.json()).toEqual({ status: "ok", version: "1.19.0" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a development version when no build version is supplied", async () => {
|
||||||
|
vi.stubEnv("APP_VERSION", "");
|
||||||
|
|
||||||
|
expect(await GET().json()).toEqual({ status: "ok", version: "development" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export function GET(): Response {
|
export function GET(): Response {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ status: "ok" },
|
{ status: "ok", version: process.env.APP_VERSION?.trim() || "development" },
|
||||||
{
|
{
|
||||||
headers: {
|
headers: {
|
||||||
"Cache-Control": "no-store",
|
"Cache-Control": "no-store",
|
||||||
|
|||||||
@@ -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,134 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const actionMocks = vi.hoisted(() => ({
|
||||||
|
execute: vi.fn(async (_previous: unknown, formData: FormData) => ({
|
||||||
|
status: "success" as const,
|
||||||
|
message: `Executed ${String(formData.get("command") ?? "")}`,
|
||||||
|
serverId: String(formData.get("serverId") ?? ""),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/app/admin/(console)/rcon/actions", () => ({
|
||||||
|
createRconServer: vi.fn(),
|
||||||
|
deleteRconServer: vi.fn(),
|
||||||
|
executeRconCommand: actionMocks.execute,
|
||||||
|
setRconServerEnabled: vi.fn(),
|
||||||
|
testSavedRconServer: vi.fn(),
|
||||||
|
updateRconServer: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { RconConsole } from "./rcon-console";
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
const server = {
|
||||||
|
id: "11111111-1111-4111-8111-111111111111",
|
||||||
|
name: "Season 4",
|
||||||
|
host: "season4.somc.svc.cluster.local",
|
||||||
|
port: 25575,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const creative = {
|
||||||
|
...server,
|
||||||
|
id: "22222222-2222-4222-8222-222222222222",
|
||||||
|
name: "Creative",
|
||||||
|
host: "creative.example.com",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("RconConsole", () => {
|
||||||
|
it("renders one wide terminal workspace with connection controls and modal forms", () => {
|
||||||
|
const markup = renderToStaticMarkup(<RconConsole servers={[server]} />);
|
||||||
|
expect(markup).toContain('aria-label="RCON terminal"');
|
||||||
|
expect(markup).toContain('for="rcon-console-server"');
|
||||||
|
expect(markup).toContain('for="rcon-command"');
|
||||||
|
expect(markup).toContain("w-full");
|
||||||
|
expect(markup).toContain("Season 4");
|
||||||
|
expect(markup).toContain("server://");
|
||||||
|
expect(markup).toContain("Awaiting command");
|
||||||
|
expect(markup).toContain("Add");
|
||||||
|
expect(markup).toContain("Edit");
|
||||||
|
expect(markup).toContain("Test");
|
||||||
|
expect(markup).toContain("Disable");
|
||||||
|
expect(markup).toContain("Delete");
|
||||||
|
expect(markup).toContain("Add RCON connection");
|
||||||
|
expect(markup).toContain('href="/admin/rcon/history"');
|
||||||
|
expect(markup).toContain("Command history");
|
||||||
|
expect(markup).toContain("Edit Season 4");
|
||||||
|
expect(markup).toContain("Delete Season 4?");
|
||||||
|
expect(markup).toContain("Enter ↵");
|
||||||
|
expect(markup).not.toContain("Latest response");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders connection operation notices inside the terminal viewport", () => {
|
||||||
|
const markup = renderToStaticMarkup(<RconConsole notice={{ status: "error", message: "RCON authentication timed out." }} servers={[server]} />);
|
||||||
|
expect(markup).toContain("RCON authentication timed out.");
|
||||||
|
expect(markup).toContain('role="alert"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the terminal and add action available when no connection exists", () => {
|
||||||
|
const markup = renderToStaticMarkup(<RconConsole servers={[]} />);
|
||||||
|
expect(markup).toContain('aria-label="RCON terminal"');
|
||||||
|
expect(markup).toContain("No connections configured");
|
||||||
|
expect(markup).toContain("Add");
|
||||||
|
expect(markup).not.toContain("Edit");
|
||||||
|
expect(markup).not.toContain("Delete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates page-memory command history and restores the unsent draft", async () => {
|
||||||
|
render(<RconConsole servers={[server]} />);
|
||||||
|
const input = screen.getByLabelText("Command") as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "list" } });
|
||||||
|
fireEvent.submit(input.form!);
|
||||||
|
await waitFor(() => expect(screen.getByText("Executed list")).toBeTruthy());
|
||||||
|
expect(document.activeElement).toBe(input);
|
||||||
|
expect(input.value).toBe("");
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "say hello" } });
|
||||||
|
fireEvent.submit(input.form!);
|
||||||
|
await waitFor(() => expect(screen.getByText("Executed say hello")).toBeTruthy());
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "draft command" } });
|
||||||
|
fireEvent.keyDown(input, { key: "ArrowUp" });
|
||||||
|
expect(input.value).toBe("say hello");
|
||||||
|
fireEvent.keyDown(input, { key: "ArrowUp" });
|
||||||
|
expect(input.value).toBe("list");
|
||||||
|
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||||
|
expect(input.value).toBe("say hello");
|
||||||
|
fireEvent.keyDown(input, { key: "ArrowDown" });
|
||||||
|
expect(input.value).toBe("draft command");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retains chronological command and response exchanges in the terminal transcript", async () => {
|
||||||
|
render(<RconConsole servers={[server]} />);
|
||||||
|
const input = screen.getByLabelText("Command") as HTMLInputElement;
|
||||||
|
|
||||||
|
let listResponses = 0;
|
||||||
|
for (const command of ["list", "say hello", "list"]) {
|
||||||
|
fireEvent.change(input, { target: { value: command } });
|
||||||
|
fireEvent.submit(input.form!);
|
||||||
|
if (command === "list") listResponses += 1;
|
||||||
|
await waitFor(() => expect(screen.getAllByText(`Executed ${command}`)).toHaveLength(command === "list" ? listResponses : 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcript = screen.getByLabelText("Terminal transcript");
|
||||||
|
const text = transcript.textContent ?? "";
|
||||||
|
expect(text.indexOf("$ list")).toBeLessThan(text.indexOf("Executed list"));
|
||||||
|
expect(text.indexOf("Executed list")).toBeLessThan(text.indexOf("$ say hello"));
|
||||||
|
expect(text.indexOf("$ say hello")).toBeLessThan(text.indexOf("Executed say hello"));
|
||||||
|
expect(screen.getAllByText("Executed list")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns focus to the command prompt after changing servers", async () => {
|
||||||
|
render(<RconConsole servers={[server, creative]} />);
|
||||||
|
const select = screen.getByLabelText("Server");
|
||||||
|
select.focus();
|
||||||
|
fireEvent.change(select, { target: { value: creative.id } });
|
||||||
|
await waitFor(() => expect(document.activeElement).toBe(screen.getByLabelText("Command")));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useActionState, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
createRconServer,
|
||||||
|
deleteRconServer,
|
||||||
|
executeRconCommand,
|
||||||
|
setRconServerEnabled,
|
||||||
|
testSavedRconServer,
|
||||||
|
type RconCommandState,
|
||||||
|
updateRconServer,
|
||||||
|
} from "@/app/admin/(console)/rcon/actions";
|
||||||
|
import { AdminModalForm } from "@/components/admin-modal-form";
|
||||||
|
|
||||||
|
const initialState: RconCommandState = { status: "idle", message: "", serverId: "" };
|
||||||
|
const MAX_COMMAND_HISTORY = 50;
|
||||||
|
const MAX_TRANSCRIPT_EXCHANGES = 50;
|
||||||
|
|
||||||
|
type TranscriptExchange = {
|
||||||
|
id: number;
|
||||||
|
serverName: string;
|
||||||
|
command: string;
|
||||||
|
status: "pending" | "success" | "error";
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RconServerOption = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RconTerminalNotice = {
|
||||||
|
status: "success" | "error";
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RconConsole({
|
||||||
|
notice,
|
||||||
|
servers,
|
||||||
|
}: {
|
||||||
|
notice?: RconTerminalNotice;
|
||||||
|
servers: RconServerOption[];
|
||||||
|
}) {
|
||||||
|
const [selectedId, setSelectedId] = useState(servers[0]?.id ?? "");
|
||||||
|
const [state, action, pending] = useActionState(executeRconCommand, initialState);
|
||||||
|
const [command, setCommand] = useState("");
|
||||||
|
const [history, setHistory] = useState<string[]>([]);
|
||||||
|
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||||
|
const [transcript, setTranscript] = useState<TranscriptExchange[]>([]);
|
||||||
|
const draftRef = useRef("");
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const nextExchangeIdRef = useRef(0);
|
||||||
|
const pendingExchangeIdRef = useRef<number | null>(null);
|
||||||
|
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||||
|
const selected = servers.find((server) => server.id === selectedId) ?? servers[0];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, [selectedId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pending && state.status !== "idle") inputRef.current?.focus();
|
||||||
|
}, [pending, state.status]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const exchangeId = pendingExchangeIdRef.current;
|
||||||
|
if (exchangeId === null || state.status === "idle") return;
|
||||||
|
const resultStatus: TranscriptExchange["status"] = state.status === "error" ? "error" : "success";
|
||||||
|
setTranscript((current) => current.map((exchange) => exchange.id === exchangeId
|
||||||
|
? { ...exchange, status: resultStatus, message: state.message }
|
||||||
|
: exchange));
|
||||||
|
pendingExchangeIdRef.current = null;
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (transcriptRef.current) transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight;
|
||||||
|
}, [transcript]);
|
||||||
|
|
||||||
|
function navigateHistory(direction: "older" | "newer") {
|
||||||
|
if (!history.length) return;
|
||||||
|
if (direction === "older") {
|
||||||
|
const nextIndex = historyIndex === null ? history.length - 1 : Math.max(0, historyIndex - 1);
|
||||||
|
if (historyIndex === null) draftRef.current = command;
|
||||||
|
setHistoryIndex(nextIndex);
|
||||||
|
setCommand(history[nextIndex]!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (historyIndex === null) return;
|
||||||
|
if (historyIndex < history.length - 1) {
|
||||||
|
const nextIndex = historyIndex + 1;
|
||||||
|
setHistoryIndex(nextIndex);
|
||||||
|
setCommand(history[nextIndex]!);
|
||||||
|
} else {
|
||||||
|
setHistoryIndex(null);
|
||||||
|
setCommand(draftRef.current);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberSubmittedCommand() {
|
||||||
|
const submitted = command.trim();
|
||||||
|
if (!submitted || !selected) return;
|
||||||
|
const exchangeId = ++nextExchangeIdRef.current;
|
||||||
|
pendingExchangeIdRef.current = exchangeId;
|
||||||
|
const exchange: TranscriptExchange = {
|
||||||
|
id: exchangeId,
|
||||||
|
serverName: selected.name,
|
||||||
|
command: submitted,
|
||||||
|
status: "pending",
|
||||||
|
message: "Command in progress…",
|
||||||
|
};
|
||||||
|
setTranscript((current) => [...current, exchange].slice(-MAX_TRANSCRIPT_EXCHANGES));
|
||||||
|
setHistory((current) => [...current, submitted].slice(-MAX_COMMAND_HISTORY));
|
||||||
|
setHistoryIndex(null);
|
||||||
|
draftRef.current = "";
|
||||||
|
setCommand("");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="RCON terminal" className="mt-8 w-full overflow-hidden border-2 border-ink bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||||
|
<div className="flex flex-col gap-4 border-b-2 border-ink bg-canvas px-4 py-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="flex min-w-0 flex-wrap items-center gap-3">
|
||||||
|
<div className="flex items-center gap-2 font-mono text-[10px] font-bold uppercase tracking-wider text-muted">
|
||||||
|
<span aria-hidden="true" className={`size-2 rounded-full shadow-[0_0_0_1px_var(--color-ink)] ${selected?.enabled ? "bg-signal" : "bg-line"}`} />
|
||||||
|
<span>server://</span>
|
||||||
|
</div>
|
||||||
|
{servers.length ? (
|
||||||
|
<label className="flex min-w-0 items-center gap-2 font-mono text-[9px] font-bold uppercase tracking-wider" htmlFor="rcon-console-server">
|
||||||
|
<span className="sr-only">Server</span>
|
||||||
|
<select
|
||||||
|
className="max-w-full border border-line bg-panel px-3 py-2 font-mono text-xs font-bold normal-case outline-none focus:border-accent"
|
||||||
|
id="rcon-console-server"
|
||||||
|
onChange={(event) => setSelectedId(event.target.value)}
|
||||||
|
value={selected?.id}
|
||||||
|
>
|
||||||
|
{servers.map((server) => <option key={server.id} value={server.id}>{server.name}{server.enabled ? "" : " — disabled"}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono text-xs font-bold text-muted">no-target</span>
|
||||||
|
)}
|
||||||
|
{selected && <span className="font-mono text-[9px] text-muted">{selected.host}:{selected.port}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Link className="border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider hover:border-ink" href="/admin/rcon/history">Command history</Link>
|
||||||
|
<ConnectionModal mode="add" />
|
||||||
|
{selected && (
|
||||||
|
<>
|
||||||
|
<form action={testSavedRconServer}>
|
||||||
|
<input name="serverId" type="hidden" value={selected.id} />
|
||||||
|
<HeaderButton label="Test" />
|
||||||
|
</form>
|
||||||
|
<form action={setRconServerEnabled}>
|
||||||
|
<input name="serverId" type="hidden" value={selected.id} />
|
||||||
|
<input name="enabled" type="hidden" value={selected.enabled ? "no" : "yes"} />
|
||||||
|
<HeaderButton label={selected.enabled ? "Disable" : "Enable"} />
|
||||||
|
</form>
|
||||||
|
<ConnectionModal mode="edit" server={selected} />
|
||||||
|
<AdminModalForm
|
||||||
|
action={deleteRconServer}
|
||||||
|
description={`Delete ${selected.name} and its encrypted credential. This cannot be undone.`}
|
||||||
|
intent="danger"
|
||||||
|
submitLabel="Delete connection"
|
||||||
|
title={`Delete ${selected.name}?`}
|
||||||
|
triggerClassName="border border-accent px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-accent"
|
||||||
|
triggerLabel="Delete"
|
||||||
|
>
|
||||||
|
<input name="serverId" type="hidden" value={selected.id} />
|
||||||
|
<input name="confirmation" type="hidden" value={selected.id} />
|
||||||
|
</AdminModalForm>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div aria-label="Terminal transcript" aria-live="polite" aria-relevant="additions text" className="min-h-72 max-h-[32rem] overflow-auto p-5 font-mono text-xs leading-5" ref={transcriptRef} role="status">
|
||||||
|
{notice && (
|
||||||
|
<div className={`mb-5 border-l-2 pl-3 ${notice.status === "error" ? "border-accent" : "border-signal"}`} role={notice.status === "error" ? "alert" : "status"}>
|
||||||
|
<p className={`text-[9px] font-bold uppercase tracking-wider ${notice.status === "error" ? "text-accent" : "text-muted"}`}>{notice.status === "error" ? "Connection error" : "Connection update"}</p>
|
||||||
|
<p className="mt-2">{notice.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!transcript.length && <TerminalIdle selected={selected} />}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{transcript.map((exchange) => (
|
||||||
|
<article className="border-l-2 border-line pl-3" key={exchange.id}>
|
||||||
|
<p className="break-words">
|
||||||
|
<span className="mr-2 text-[9px] font-bold uppercase tracking-wider text-muted">server://{exchange.serverName}</span>
|
||||||
|
<span className="text-accent">$</span> {exchange.command}
|
||||||
|
</p>
|
||||||
|
<div className={`mt-2 ${exchange.status === "error" ? "text-accent" : "text-ink"}`} role={exchange.status === "error" ? "alert" : undefined}>
|
||||||
|
{exchange.status === "pending" ? <p className="text-muted">Command in progress…</p> : <pre className="whitespace-pre-wrap break-words font-mono text-xs leading-5">{exchange.message}</pre>}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={action} className="flex items-center gap-3 border-t-2 border-ink bg-canvas p-3" onSubmit={rememberSubmittedCommand}>
|
||||||
|
<input name="serverId" type="hidden" value={selected?.id ?? ""} />
|
||||||
|
<span aria-hidden="true" className="font-mono text-lg font-black text-accent">$</span>
|
||||||
|
<label className="sr-only" htmlFor="rcon-command">Command</label>
|
||||||
|
<input
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
className="min-w-0 flex-1 bg-transparent px-1 py-2 font-mono text-sm outline-none placeholder:text-muted focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||||
|
disabled={!selected?.enabled || pending}
|
||||||
|
id="rcon-command"
|
||||||
|
key={selected?.id ?? "no-server"}
|
||||||
|
maxLength={1024}
|
||||||
|
name="command"
|
||||||
|
onChange={(event) => setCommand(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
navigateHistory(event.key === "ArrowUp" ? "older" : "newer");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={selected ? (selected.enabled ? "list" : "Enable this connection to run commands") : "Add a connection to begin"}
|
||||||
|
ref={inputRef}
|
||||||
|
required
|
||||||
|
spellCheck={false}
|
||||||
|
value={command}
|
||||||
|
/>
|
||||||
|
<button className="border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas disabled:cursor-not-allowed disabled:opacity-50" disabled={!selected?.enabled || pending} type="submit">{pending ? "Running…" : "Enter ↵"}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TerminalIdle({ selected }: { selected?: RconServerOption }) {
|
||||||
|
if (!selected) return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready</p><p className="mt-3">No connections configured. Use Add to create a server connection.</p></>;
|
||||||
|
if (!selected.enabled) return <><p className="text-[9px] font-bold uppercase tracking-wider text-accent">Disabled — {selected.name}</p><p className="mt-3">Enable this connection before testing commands.</p></>;
|
||||||
|
return <><p className="text-[9px] font-bold uppercase tracking-wider text-muted">Ready — {selected.name}</p><p className="mt-3">Awaiting command</p></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeaderButton({ label }: { label: string }) {
|
||||||
|
return <button className="border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider hover:border-ink" type="submit">{label}</button>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionModal({
|
||||||
|
mode,
|
||||||
|
server,
|
||||||
|
}: {
|
||||||
|
mode: "add" | "edit";
|
||||||
|
server?: RconServerOption;
|
||||||
|
}) {
|
||||||
|
const editing = mode === "edit" ? server : undefined;
|
||||||
|
return (
|
||||||
|
<AdminModalForm
|
||||||
|
action={editing ? updateRconServer : createRconServer}
|
||||||
|
description={editing ? `Update ${editing.name}. Leave the password blank to preserve its encrypted credential.` : "Add an internal or external RCON server address. The password is encrypted before storage."}
|
||||||
|
submitLabel={editing ? "Save connection" : "Add connection"}
|
||||||
|
title={editing ? `Edit ${editing.name}` : "Add RCON connection"}
|
||||||
|
triggerClassName={editing ? "border border-line px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider" : "border border-ink bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas"}
|
||||||
|
triggerLabel={editing ? "Edit" : "Add"}
|
||||||
|
>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{editing && <input name="serverId" type="hidden" value={editing.id} />}
|
||||||
|
<ConnectionFields defaults={editing} prefix={editing?.id ?? "new"} />
|
||||||
|
<label className="flex items-center gap-3 font-mono text-[10px] font-bold uppercase">
|
||||||
|
<input className="size-4" defaultChecked={editing?.enabled ?? false} name="enabled" type="checkbox" value="yes" />
|
||||||
|
{editing ? "Enabled" : "Enable immediately"}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</AdminModalForm>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConnectionFields({
|
||||||
|
defaults,
|
||||||
|
prefix,
|
||||||
|
}: {
|
||||||
|
defaults?: { name: string; host: string; port: number };
|
||||||
|
prefix: string;
|
||||||
|
}) {
|
||||||
|
const fieldClass = "mt-2 block w-full border border-line bg-canvas px-4 py-3 font-mono text-sm outline-none focus:border-accent";
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-name`}>Name<input className={fieldClass} defaultValue={defaults?.name} id={`${prefix}-rcon-name`} maxLength={100} name="name" required /></label>
|
||||||
|
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-host`}>Server address<input autoCapitalize="none" autoCorrect="off" className={fieldClass} defaultValue={defaults?.host} id={`${prefix}-rcon-host`} maxLength={253} name="host" placeholder="minecraft.example.com" required spellCheck={false} /></label>
|
||||||
|
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-port`}>Port<input className={fieldClass} defaultValue={defaults?.port ?? 25575} id={`${prefix}-rcon-port`} max={65535} min={1} name="port" required type="number" /></label>
|
||||||
|
<label className="block font-mono text-[10px] font-bold uppercase" htmlFor={`${prefix}-rcon-password`}>{defaults ? "Replacement password" : "Password"}<input autoComplete="new-password" className={fieldClass} id={`${prefix}-rcon-password`} maxLength={512} name="password" required={!defaults} type="password" />{defaults && <span className="mt-2 block font-sans text-[10px] font-normal normal-case text-muted">Leave blank to preserve the current password.</span>}</label>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { UserGroupSelect } from "./user-group-select";
|
||||||
|
|
||||||
|
const groups = [{ id: "group-everyone", name: "everyone" }, { id: "group-ops", name: "Ops" }];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
HTMLDialogElement.prototype.showModal = function showModal() { this.open = true; };
|
||||||
|
HTMLDialogElement.prototype.close = function close() {
|
||||||
|
this.open = false;
|
||||||
|
this.dispatchEvent(new Event("close"));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("UserGroupSelect", () => {
|
||||||
|
it("renders the effective group and preserves the return path", () => {
|
||||||
|
const markup = renderToStaticMarkup(<UserGroupSelect
|
||||||
|
action={async () => undefined}
|
||||||
|
effectiveGroupId="group-ops"
|
||||||
|
groups={groups}
|
||||||
|
returnTo="/admin/users?q=alex%20smith"
|
||||||
|
userId="user-one"
|
||||||
|
userLabel="Alex"
|
||||||
|
/>);
|
||||||
|
|
||||||
|
expect(markup).toContain('aria-label="Group for Alex"');
|
||||||
|
expect(markup).toContain('<option value="group-ops" selected="">Ops</option>');
|
||||||
|
expect(markup).toContain('<input type="hidden" name="returnTo" value="/admin/users?q=alex%20smith"/>');
|
||||||
|
expect(markup).toContain("Changing this selection opens a confirmation dialog.");
|
||||||
|
expect(markup).toContain("Confirm move");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires confirmation and restores the effective group when cancelled", () => {
|
||||||
|
render(<UserGroupSelect action={async () => undefined} effectiveGroupId="group-ops" groups={groups} returnTo="/admin/users" userId="user-one" userLabel="Alex" />);
|
||||||
|
const select = screen.getByRole("combobox", { name: "Group for Alex" }) as HTMLSelectElement;
|
||||||
|
|
||||||
|
fireEvent.change(select, { target: { value: "group-everyone" } });
|
||||||
|
const dialog = screen.getByRole("dialog") as HTMLDialogElement;
|
||||||
|
expect(dialog.open).toBe(true);
|
||||||
|
expect(select.value).toBe("group-everyone");
|
||||||
|
expect(select.disabled).toBe(true);
|
||||||
|
expect(screen.getByText(/from/).textContent).toContain("Ops");
|
||||||
|
expect(screen.getByText(/from/).textContent).toContain("everyone");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||||
|
expect(dialog.open).toBe(false);
|
||||||
|
expect(select.value).toBe("group-ops");
|
||||||
|
expect(select.disabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { useFormStatus } from "react-dom";
|
||||||
|
|
||||||
|
export function UserGroupSelect({
|
||||||
|
action,
|
||||||
|
effectiveGroupId,
|
||||||
|
groups,
|
||||||
|
returnTo,
|
||||||
|
userId,
|
||||||
|
userLabel,
|
||||||
|
}: {
|
||||||
|
action: (formData: FormData) => Promise<void>;
|
||||||
|
effectiveGroupId: string;
|
||||||
|
groups: Array<{ id: string; name: string }>;
|
||||||
|
returnTo: string;
|
||||||
|
userId: string;
|
||||||
|
userLabel: string;
|
||||||
|
}) {
|
||||||
|
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
|
const descriptionId = useId();
|
||||||
|
const helpId = useId();
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState(effectiveGroupId);
|
||||||
|
const [proposedGroupId, setProposedGroupId] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const currentGroup = groups.find((group) => group.id === effectiveGroupId);
|
||||||
|
const proposedGroup = groups.find((group) => group.id === proposedGroupId);
|
||||||
|
|
||||||
|
function resetSelection() {
|
||||||
|
setSelectedGroupId(effectiveGroupId);
|
||||||
|
setProposedGroupId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="sr-only" id={helpId}>Changing this selection opens a confirmation dialog.</span>
|
||||||
|
<select
|
||||||
|
aria-describedby={helpId}
|
||||||
|
aria-label={`Group for ${userLabel}`}
|
||||||
|
className="max-w-44 border border-line bg-canvas px-3 py-2 font-mono text-xs outline-none focus:border-accent disabled:cursor-wait disabled:opacity-60"
|
||||||
|
disabled={proposedGroupId !== null || submitting}
|
||||||
|
onChange={(event) => {
|
||||||
|
const nextGroupId = event.currentTarget.value;
|
||||||
|
if (nextGroupId === effectiveGroupId) return;
|
||||||
|
setSelectedGroupId(nextGroupId);
|
||||||
|
setProposedGroupId(nextGroupId);
|
||||||
|
dialogRef.current?.showModal();
|
||||||
|
}}
|
||||||
|
value={selectedGroupId}
|
||||||
|
>
|
||||||
|
{groups.map((group) => <option key={group.id} value={group.id}>{group.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<dialog
|
||||||
|
aria-describedby={descriptionId}
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
className="admin-modal m-auto w-[min(92vw,34rem)] border border-ink bg-panel p-0 text-ink shadow-[10px_10px_0_var(--color-shadow)] backdrop:bg-ink/70"
|
||||||
|
onCancel={(event) => { if (submitting) event.preventDefault(); }}
|
||||||
|
onClose={() => { if (!submitting) resetSelection(); }}
|
||||||
|
ref={dialogRef}
|
||||||
|
>
|
||||||
|
<form action={action} className="p-6 sm:p-8" onSubmit={() => setSubmitting(true)}>
|
||||||
|
<input name="userId" type="hidden" value={userId} />
|
||||||
|
<input name="groupId" type="hidden" value={proposedGroupId ?? effectiveGroupId} />
|
||||||
|
<input name="returnTo" type="hidden" value={returnTo} />
|
||||||
|
<p className="font-mono text-[9px] font-bold uppercase tracking-[0.2em] text-accent">Confirm membership</p>
|
||||||
|
<h2 className="mt-3 font-display text-3xl font-black uppercase" id={titleId}>Move {userLabel}?</h2>
|
||||||
|
<p className="mt-3 text-sm leading-6 text-muted" id={descriptionId}>
|
||||||
|
Change the effective group from <strong className="text-ink">{currentGroup?.name ?? "unknown"}</strong> to <strong className="text-ink">{proposedGroup?.name ?? "unknown"}</strong>. Their access policy changes immediately.
|
||||||
|
</p>
|
||||||
|
<AssignmentActions dialogRef={dialogRef} onPendingChange={setSubmitting} />
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssignmentActions({ dialogRef, onPendingChange }: { dialogRef: RefObject<HTMLDialogElement | null>; onPendingChange: (pending: boolean) => void }) {
|
||||||
|
const { pending } = useFormStatus();
|
||||||
|
const observedPending = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (pending) {
|
||||||
|
observedPending.current = true;
|
||||||
|
onPendingChange(true);
|
||||||
|
} else if (observedPending.current) {
|
||||||
|
observedPending.current = false;
|
||||||
|
onPendingChange(false);
|
||||||
|
}
|
||||||
|
}, [onPendingChange, pending]);
|
||||||
|
return (
|
||||||
|
<div className="mt-8 flex justify-end gap-3 border-t border-line pt-5">
|
||||||
|
<button className="border border-line px-5 py-3 font-mono text-[10px] font-bold uppercase" disabled={pending} onClick={() => dialogRef.current?.close()} type="button">Cancel</button>
|
||||||
|
<button className="bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase text-canvas disabled:cursor-wait disabled:opacity-60" disabled={pending} type="submit">{pending ? "Moving…" : "Confirm move"}</button>
|
||||||
|
<span aria-live="polite" className="sr-only">{pending ? "Group change in progress." : ""}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { UserWorldMap } from "./user-world-map";
|
||||||
|
|
||||||
|
describe("UserWorldMap", () => {
|
||||||
|
it("renders an accessible linked marker, text fallback, and open-data attribution", () => {
|
||||||
|
const markup = renderToStaticMarkup(<UserWorldMap locations={[{
|
||||||
|
userId: "11111111-1111-4111-8111-111111111111",
|
||||||
|
name: "Dani",
|
||||||
|
discordUsername: "dani",
|
||||||
|
nickname: "Dani (Steve)",
|
||||||
|
latitude: 37.4056,
|
||||||
|
longitude: -122.0775,
|
||||||
|
location: "Mountain View, California, US",
|
||||||
|
classification: "clear",
|
||||||
|
networkProvider: "Comcast Cable Communications, LLC",
|
||||||
|
networkAsn: "AS7922",
|
||||||
|
connectionType: "Residential",
|
||||||
|
proxy: false,
|
||||||
|
source: "game",
|
||||||
|
observedAt: new Date("2026-08-01T12:00:00Z"),
|
||||||
|
}, {
|
||||||
|
userId: "22222222-2222-4222-8222-222222222222",
|
||||||
|
name: "Alex",
|
||||||
|
discordUsername: "alex",
|
||||||
|
nickname: "Alex (AlexMC)",
|
||||||
|
latitude: 37.4057,
|
||||||
|
longitude: -122.0774,
|
||||||
|
location: "Mountain View, California, US",
|
||||||
|
classification: "vpn",
|
||||||
|
networkProvider: "Proton AG",
|
||||||
|
networkAsn: "AS62371",
|
||||||
|
connectionType: "VPN",
|
||||||
|
proxy: true,
|
||||||
|
source: "web",
|
||||||
|
observedAt: new Date("2026-08-01T13:00:00Z"),
|
||||||
|
}]} unavailableCount={2} />);
|
||||||
|
|
||||||
|
expect(markup).toContain('role="group"');
|
||||||
|
expect(markup).toContain('class="map-marker-target"');
|
||||||
|
expect(markup).toContain("Latest approximate location for registered users");
|
||||||
|
expect(markup).toContain('href="/admin/users/11111111-1111-4111-8111-111111111111"');
|
||||||
|
expect(markup).toContain("Dani (Steve)");
|
||||||
|
expect(markup).toContain("Alex (AlexMC)");
|
||||||
|
expect(markup).toContain("2 users near Mountain View, California, US");
|
||||||
|
expect(markup).toMatch(/<text[^>]*>2<\/text>/);
|
||||||
|
expect(markup).toContain('<details class="mt-5 border-t border-line pt-4" id="map-location-list">');
|
||||||
|
expect(markup).not.toContain('id="map-location-list" open');
|
||||||
|
expect(markup).toContain("Mountain View, California, US");
|
||||||
|
expect(markup).toContain("Comcast Cable Communications, LLC");
|
||||||
|
expect(markup).toContain("AS7922");
|
||||||
|
expect(markup).toContain("Residential");
|
||||||
|
expect(markup).toContain("Proton AG");
|
||||||
|
expect(markup).toContain(">Proxy/VPN<");
|
||||||
|
expect(markup).toContain(">Yes<");
|
||||||
|
expect(markup).toContain(">No<");
|
||||||
|
expect(markup).toContain("World overview");
|
||||||
|
expect(markup).toContain("Interactive OpenStreetMap");
|
||||||
|
expect(markup).toContain("OpenStreetMap, which receives your IP address");
|
||||||
|
expect(markup).toContain("map-marker-tooltip");
|
||||||
|
expect(markup).toContain('id="map-overview-panel"');
|
||||||
|
expect(markup).not.toContain("tile.openstreetmap.org");
|
||||||
|
expect(markup).toContain("Natural Earth, public domain");
|
||||||
|
expect(markup).toContain("2 without coordinates");
|
||||||
|
|
||||||
|
const countryPaths = [...markup.matchAll(/<path d="([^"]+)"/g)].map((match) => match[1] ?? "");
|
||||||
|
expect(countryPaths.length).toBeGreaterThan(100);
|
||||||
|
for (const path of countryPaths) {
|
||||||
|
const subpaths = path.split("M").slice(1);
|
||||||
|
for (const subpath of subpaths) {
|
||||||
|
const xCoordinates = [...subpath.matchAll(/(?:^|L)(-?\d+(?:\.\d+)?),/g)].map((match) => Number(match[1]));
|
||||||
|
for (let index = 1; index < xCoordinates.length; index += 1) {
|
||||||
|
expect(Math.abs(xCoordinates[index]! - xCoordinates[index - 1]!)).toBeLessThan(500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import type { FeatureCollection } from "geojson";
|
||||||
|
import type { GeometryCollection, Topology } from "topojson-specification";
|
||||||
|
import { geoEquirectangular, geoPath } from "d3-geo";
|
||||||
|
import { feature } from "topojson-client";
|
||||||
|
import countriesTopologyJson from "world-atlas/countries-110m.json";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { groupMapLocations } from "@/lib/user-location-map";
|
||||||
|
import { MapViewToggle } from "./map-view-toggle";
|
||||||
|
|
||||||
|
const WIDTH = 1_000;
|
||||||
|
const HEIGHT = 500;
|
||||||
|
const topology = countriesTopologyJson as unknown as Topology<{ countries: GeometryCollection }>;
|
||||||
|
const countries = feature(topology, topology.objects.countries) as FeatureCollection;
|
||||||
|
const projection = geoEquirectangular().fitExtent([[1, 1], [WIDTH - 1, HEIGHT - 1]], { type: "Sphere" });
|
||||||
|
const countryPath = geoPath(projection);
|
||||||
|
|
||||||
|
export interface UserMapLocation {
|
||||||
|
userId: string;
|
||||||
|
name: string;
|
||||||
|
discordUsername: string;
|
||||||
|
nickname: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
location: string;
|
||||||
|
classification: string;
|
||||||
|
networkProvider: string | null;
|
||||||
|
networkAsn: string | null;
|
||||||
|
connectionType: string | null;
|
||||||
|
proxy: boolean | null;
|
||||||
|
source: string;
|
||||||
|
observedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserWorldMap({ locations, unavailableCount }: { locations: UserMapLocation[]; unavailableCount: number }) {
|
||||||
|
const locationGroups = groupMapLocations(locations);
|
||||||
|
return (
|
||||||
|
<section className="mt-8 border border-line bg-panel p-5 shadow-[8px_8px_0_var(--color-shadow)] sm:p-7">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Latest known location</p>
|
||||||
|
<h2 className="mt-2 font-display text-3xl font-black uppercase">Community world</h2>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-sm text-xs leading-5 text-muted">{locations.length} mapped · {unavailableCount} without coordinates. Locations are approximate IP intelligence, not precise device positions.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MapViewToggle locations={locations}>
|
||||||
|
<div className="mt-3 overflow-hidden border border-line bg-[#b9d4d1]">
|
||||||
|
<svg aria-labelledby="user-world-map-title user-world-map-description" className="h-auto w-full" role="group" viewBox={`0 0 ${WIDTH} ${HEIGHT}`}>
|
||||||
|
<title id="user-world-map-title">Latest approximate location for registered users</title>
|
||||||
|
<desc id="user-world-map-description">An open-data world map with one linked marker for every user whose latest geolocated observation has valid coordinates. A complete text list follows.</desc>
|
||||||
|
<rect fill="#b9d4d1" height={HEIGHT} width={WIDTH} />
|
||||||
|
<g aria-hidden="true" fill="var(--canvas)" stroke="var(--line)" strokeWidth="0.7">
|
||||||
|
{countries.features.map((country, index) => {
|
||||||
|
const path = countryPath(country);
|
||||||
|
return path ? <path d={path} key={country.id ?? index} /> : null;
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
{locationGroups.map((group) => {
|
||||||
|
const projected = projection([group.longitude, group.latitude]);
|
||||||
|
if (!projected) return null;
|
||||||
|
const x = Math.min(WIDTH - 16, Math.max(16, projected[0]));
|
||||||
|
const y = Math.min(HEIGHT - 16, Math.max(16, projected[1]));
|
||||||
|
const markerRadius = group.count > 1 ? 13 : 7;
|
||||||
|
const longestNickname = Math.max(...group.nicknames.map((nickname) => nickname.length));
|
||||||
|
const tooltipColumns = Math.ceil(group.nicknames.length / 10);
|
||||||
|
const tooltipRows = Math.ceil(group.nicknames.length / tooltipColumns);
|
||||||
|
const tooltipWidth = Math.min(WIDTH - 8, Math.max(110, longestNickname * 8 + 24) * tooltipColumns);
|
||||||
|
const tooltipColumnWidth = tooltipWidth / tooltipColumns;
|
||||||
|
const tooltipHeight = tooltipRows * 18 + 10;
|
||||||
|
const tooltipX = Math.min(WIDTH - tooltipWidth - 4, Math.max(4, x - tooltipWidth / 2));
|
||||||
|
const preferredTooltipY = y > tooltipHeight + 18 ? y - tooltipHeight - 12 : y + 18;
|
||||||
|
const tooltipY = Math.min(HEIGHT - tooltipHeight - 4, Math.max(4, preferredTooltipY));
|
||||||
|
const firstUser = group.locations[0]!;
|
||||||
|
const label = group.count === 1
|
||||||
|
? `${firstUser.nickname}, ${firstUser.location}`
|
||||||
|
: `${group.count} users near ${firstUser.location}: ${group.nicknames.join(", ")}`;
|
||||||
|
return (
|
||||||
|
<a aria-label={label} className="map-marker-link" href={group.count === 1 ? `/admin/users/${firstUser.userId}` : "#map-location-list"} key={group.key}>
|
||||||
|
<circle className="map-marker-target" cx={x} cy={y} fill="none" pointerEvents="stroke" r={markerRadius} stroke="transparent" strokeWidth="24" vectorEffect="non-scaling-stroke">
|
||||||
|
<title>{label}</title>
|
||||||
|
</circle>
|
||||||
|
<circle className="map-marker" cx={x} cy={y} fill="var(--accent)" pointerEvents="none" r={markerRadius} stroke="var(--panel)" strokeWidth="3" />
|
||||||
|
{group.count > 1 && <text aria-hidden="true" dominantBaseline="middle" fill="var(--panel)" fontFamily="var(--font-mono)" fontSize="12" fontWeight="700" pointerEvents="none" textAnchor="middle" x={x} y={y}>{group.count}</text>}
|
||||||
|
<g aria-hidden="true" className="map-marker-tooltip" pointerEvents="none">
|
||||||
|
<rect fill="var(--ink)" height={tooltipHeight} rx="2" width={tooltipWidth} x={tooltipX} y={tooltipY} />
|
||||||
|
{group.nicknames.map((nickname, index) => {
|
||||||
|
const column = Math.floor(index / tooltipRows);
|
||||||
|
const row = index % tooltipRows;
|
||||||
|
return <text dominantBaseline="middle" fill="var(--panel)" fontFamily="var(--font-mono)" fontSize="12" key={`${nickname}-${index}`} textAnchor="middle" x={tooltipX + tooltipColumnWidth * (column + 0.5)} y={tooltipY + 14 + row * 18}>{nickname}</text>;
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</MapViewToggle>
|
||||||
|
<p className="mt-2 text-right font-mono text-[9px] text-muted">Map boundaries: Natural Earth, public domain</p>
|
||||||
|
|
||||||
|
<details className="mt-5 border-t border-line pt-4" id="map-location-list">
|
||||||
|
<summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase underline underline-offset-4">View accessible location list</summary>
|
||||||
|
<div className="mt-4 overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[980px] border-collapse text-left text-xs">
|
||||||
|
<caption className="sr-only">Latest approximate registered-user locations and enriched network details</caption>
|
||||||
|
<thead className="border-b border-line font-mono text-[9px] uppercase tracking-wider text-muted"><tr><th className="py-3 pr-4" scope="col">User</th><th className="p-3" scope="col">Location</th><th className="p-3" scope="col">Network</th><th className="p-3" scope="col">Connection</th><th className="p-3" scope="col">Proxy/VPN</th><th className="p-3" scope="col">Risk</th><th className="p-3" scope="col">Source</th><th className="py-3 pl-4" scope="col">Last observed</th></tr></thead>
|
||||||
|
<tbody className="divide-y divide-line">
|
||||||
|
{locations.map((user) => <tr key={user.userId}><th className="py-3 pr-4 text-left" scope="row"><Link className="font-mono font-bold underline underline-offset-4" href={`/admin/users/${user.userId}`}>{user.nickname}</Link><span className="mt-1 block font-mono text-[9px] font-normal text-muted">@{user.discordUsername}</span></th><td className="p-3">{user.location}</td><td className="p-3"><span className="block">{user.networkProvider ?? "Unknown"}</span>{user.networkAsn && <span className="mt-1 block font-mono text-[9px] text-muted">{user.networkAsn}</span>}</td><td className="p-3">{user.connectionType ?? "Unknown"}</td><td className="p-3 font-mono font-bold uppercase">{user.proxy === null ? "Unknown" : user.proxy ? "Yes" : "No"}</td><td className="p-3 font-mono uppercase">{user.classification}</td><td className="p-3">{user.source}</td><td className="py-3 pl-4 font-mono text-[9px]"><time dateTime={user.observedAt.toISOString()}>{user.observedAt.toISOString()}</time></td></tr>)}
|
||||||
|
{!locations.length && <tr><td className="py-6 text-muted" colSpan={8}>No user observations currently include valid coordinates.</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 @@ export async function recordAdminSubjectEvent(
|
|||||||
subject: string,
|
subject: string,
|
||||||
type: string,
|
type: string,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
|
options: { correlationId?: string } = {},
|
||||||
) {
|
) {
|
||||||
const requestHeaders = await headers();
|
const requestHeaders = await headers();
|
||||||
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||||
@@ -18,6 +19,7 @@ export async function recordAdminSubjectEvent(
|
|||||||
source: "/web/admin",
|
source: "/web/admin",
|
||||||
subject,
|
subject,
|
||||||
ipAddress: ipAddress ?? undefined,
|
ipAddress: ipAddress ?? undefined,
|
||||||
|
correlationId: options.correlationId,
|
||||||
data: { ...data, adminEmail: admin.email, adminName: admin.name },
|
data: { ...data, adminEmail: admin.email, adminName: admin.name },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export type SuggestionTag = { id: string; name: string };
|
||||||
|
export type Suggestion = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
authorId: string;
|
||||||
|
createdAt: string;
|
||||||
|
archived: boolean;
|
||||||
|
locked: boolean;
|
||||||
|
tags: SuggestionTag[];
|
||||||
|
messageCount: number;
|
||||||
|
discordUrl: string;
|
||||||
|
};
|
||||||
|
export type SuggestionMessage = {
|
||||||
|
id: string;
|
||||||
|
author: { id: string; name: string };
|
||||||
|
content: string;
|
||||||
|
createdAt: string;
|
||||||
|
editedAt: string | null;
|
||||||
|
reactions: { emoji: string; count: number }[];
|
||||||
|
discordUrl: string;
|
||||||
|
};
|
||||||
|
export type SuggestionPage = { items: Suggestion[]; nextCursor: string | null };
|
||||||
|
export type MessagePage = { items: SuggestionMessage[]; nextCursor: string | null };
|
||||||
|
export type SuggestionDetail = Suggestion & { originalPost: SuggestionMessage | null };
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { getServerSession } from "next-auth";
|
||||||
|
import { problemDetails } from "@minecraft-account-manager/contracts";
|
||||||
|
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
|
||||||
|
import { problemInstance, problemResponse } from "@/lib/problem-response";
|
||||||
|
import { createSuggestionsClient, SuggestionsError } from "./suggestions";
|
||||||
|
|
||||||
|
type Client = ReturnType<typeof createSuggestionsClient>;
|
||||||
|
let runtime: { token: string; guildId: string; forumId: string; client: Client } | undefined;
|
||||||
|
function getClient() {
|
||||||
|
const token = process.env.DISCORD_BOT_TOKEN?.trim() ?? "";
|
||||||
|
const guildId = process.env.DISCORD_GUILD_ID?.trim() ?? "";
|
||||||
|
const forumId = process.env.DISCORD_SUGGESTIONS_FORUM_ID?.trim() ?? "";
|
||||||
|
if (!runtime || runtime.token !== token || runtime.guildId !== guildId || runtime.forumId !== forumId) {
|
||||||
|
runtime = { token, guildId, forumId, client: createSuggestionsClient({ token, guildId, forumId }) };
|
||||||
|
}
|
||||||
|
return runtime.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function suggestionsApi(request: Request, operation: (client: Client) => Promise<unknown>) {
|
||||||
|
try {
|
||||||
|
const session = await getServerSession(adminAuthOptions);
|
||||||
|
if (!session) throw new SuggestionsError(401, "unauthorized", "Sign in as an administrator.");
|
||||||
|
const roles = (session.user as { roles?: unknown } | undefined)?.roles;
|
||||||
|
if (!Array.isArray(roles) || !roles.includes(requiredAdminRole)) throw new SuggestionsError(403, "forbidden", "This API is restricted to administrators.");
|
||||||
|
return Response.json(await operation(getClient()), { headers: { "cache-control": "no-store" } });
|
||||||
|
} catch (error) {
|
||||||
|
const safe = error instanceof SuggestionsError ? error : new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
|
||||||
|
const titles: Record<number, string> = { 400: "Invalid request", 401: "Authentication required", 403: "Administrator role required", 404: "Suggestion not found", 405: "Method not allowed", 503: "Suggestions unavailable" };
|
||||||
|
const response = problemResponse(problemDetails(`urn:error:${safe.code}`, titles[safe.status] ?? "Suggestions unavailable", safe.status, safe.message, problemInstance(request)));
|
||||||
|
if (safe.retryAfter) response.headers.set("retry-after", String(safe.retryAfter));
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suggestionQuery(request: Request, list = false) {
|
||||||
|
const params = new URL(request.url).searchParams;
|
||||||
|
const allowed = list ? ["limit", "cursor", "status"] : ["limit", "cursor"];
|
||||||
|
for (const key of params.keys()) {
|
||||||
|
if (!allowed.includes(key) || params.getAll(key).length !== 1 || !params.get(key)) throw new SuggestionsError(400, "invalid-request", "Unsupported or repeated query parameter.");
|
||||||
|
}
|
||||||
|
const rawLimit = params.get("limit");
|
||||||
|
if (rawLimit !== null && !/^\d{1,3}$/.test(rawLimit)) throw new SuggestionsError(400, "invalid-request", "Limit must be between 1 and 100.");
|
||||||
|
return { limit: rawLimit === null ? undefined : Number(rawLimit), cursor: params.get("cursor") ?? undefined, ...(list ? { status: params.get("status") ?? undefined } : {}) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suggestionsReadOnly(request: Request) {
|
||||||
|
return suggestionsApi(request, async () => {
|
||||||
|
throw new SuggestionsError(405, "method-not-allowed", "Suggestions are read-only. Use GET.");
|
||||||
|
}).then((response) => { if (response.status === 405) response.headers.set("allow", "GET, HEAD"); return response; });
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { afterEach, expect, it, vi } from "vitest";
|
||||||
|
import { createSuggestionsClient } from "./suggestions";
|
||||||
|
afterEach(() => vi.useRealTimers());
|
||||||
|
const guildId = "100000000000000001";
|
||||||
|
const forumId = "100000000000000002";
|
||||||
|
const threadId = "100000000000000009";
|
||||||
|
const thread = { id: threadId, guild_id: guildId, parent_id: forumId, type: 11, name: "More railway stations", owner_id: "100000000000000003", applied_tags: ["100000000000000004"], message_count: 3, thread_metadata: { archived: false, locked: false, archive_timestamp: "2026-01-01T00:00:00.000Z" } };
|
||||||
|
function setup(responses: Record<string, unknown>) {
|
||||||
|
const fetcher = vi.fn<typeof fetch>(async (input) => {
|
||||||
|
const path = String(input).replace("https://discord.com/api/v10", "");
|
||||||
|
if (!(path in responses)) throw new Error(`Unexpected path: ${path}`);
|
||||||
|
const value = responses[path];
|
||||||
|
return value instanceof Response ? value : Response.json(value);
|
||||||
|
});
|
||||||
|
const client = createSuggestionsClient({ token: "test-token", guildId, forumId, fetch: fetcher });
|
||||||
|
return { client, fetcher };
|
||||||
|
}
|
||||||
|
it("reads archived forum pages using Discord's archive timestamp cursor", async () => {
|
||||||
|
const cursor = "2026-01-01T00:00:00Z";
|
||||||
|
const { client } = setup({
|
||||||
|
[`/channels/${forumId}`]: forum,
|
||||||
|
[`/channels/${forumId}/threads/archived/public?limit=1`]: { threads: [{ ...thread, thread_metadata: { ...thread.thread_metadata, archived: true } }], has_more: true },
|
||||||
|
[`/channels/${forumId}/threads/archived/public?limit=1&before=${encodeURIComponent(cursor)}`]: { threads: [], has_more: false },
|
||||||
|
});
|
||||||
|
expect(await client.list({ status: "archived", limit: 1 })).toMatchObject({ items: [{ archived: true }], nextCursor: cursor });
|
||||||
|
expect(await client.list({ status: "archived", limit: 1, cursor })).toEqual({ items: [], nextCursor: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
const message = { id: threadId, content: "Please add stations", timestamp: "2026-01-01T00:00:00.000Z", edited_timestamp: null, author: { id: "100000000000000003", username: "builder", global_name: "Builder" }, reactions: [{ emoji: { name: "👍" }, count: 4 }] };
|
||||||
|
it("returns the starter post and paginates discussion with authors and reactions", async () => {
|
||||||
|
const { client } = setup({
|
||||||
|
[`/channels/${forumId}`]: forum,
|
||||||
|
[`/channels/${threadId}`]: thread,
|
||||||
|
[`/channels/${threadId}/messages/${threadId}`]: message,
|
||||||
|
[`/channels/${threadId}/messages?limit=1`]: [{ ...message, id: "100000000000000020" }],
|
||||||
|
[`/channels/${threadId}/messages?limit=1&before=100000000000000020`]: [],
|
||||||
|
});
|
||||||
|
expect(await client.detail(threadId)).toMatchObject({ title: thread.name, originalPost: { content: message.content, author: { name: "Builder" }, reactions: [{ emoji: "👍", count: 4 }], createdAt: "2026-01-01T00:00:00Z" } });
|
||||||
|
expect(await client.messages(threadId, { limit: 1 })).toMatchObject({ items: [{ id: "100000000000000020" }], nextCursor: "100000000000000020" });
|
||||||
|
expect(await client.messages(threadId, { limit: 1, cursor: "100000000000000020" })).toEqual({ items: [], nextCursor: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["detail", "messages"] as const)("blocks %s of a thread outside the forum before reading messages", async (method) => {
|
||||||
|
const { client, fetcher } = setup({ [`/channels/${forumId}`]: forum, [`/channels/${threadId}`]: { ...thread, parent_id: "100000000000000099" } });
|
||||||
|
await expect(client[method](threadId)).rejects.toMatchObject({ status: 404 });
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
it("keeps a deleted starter post distinguishable from an empty message", async () => {
|
||||||
|
const { client } = setup({ [`/channels/${forumId}`]: forum, [`/channels/${threadId}`]: thread, [`/channels/${threadId}/messages/${threadId}`]: new Response(null, { status: 404 }) });
|
||||||
|
expect(await client.detail(threadId)).toMatchObject({ originalPost: null });
|
||||||
|
});
|
||||||
|
it("backs off on Discord rate limits without exposing Discord error bodies", async () => {
|
||||||
|
const { client, fetcher } = setup({ [`/channels/${forumId}`]: Response.json({ retry_after: 2.5, message: "secret upstream details" }, { status: 429 }) });
|
||||||
|
await expect(client.list()).rejects.toMatchObject({ status: 503, code: "discord-rate-limited", retryAfter: 3 });
|
||||||
|
await expect(client.list()).rejects.toMatchObject({ status: 503, code: "discord-rate-limited" });
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it.each([401, 403, 404, 500])("translates Discord %s into a safe service error", async (status) => {
|
||||||
|
const { client } = setup({ [`/channels/${forumId}`]: new Response("sensitive error", { status }) });
|
||||||
|
await expect(client.list()).rejects.toMatchObject({ status: 503 });
|
||||||
|
await expect(client.list()).rejects.not.toThrow("sensitive error");
|
||||||
|
});
|
||||||
|
it("sanitizes network failures", async () => {
|
||||||
|
const client = createSuggestionsClient({ token: "test", guildId, forumId, fetch: vi.fn().mockRejectedValue(new Error("token leaked by upstream")) });
|
||||||
|
await expect(client.list()).rejects.toMatchObject({ status: 503, message: "Discord suggestions are unavailable." });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coalesces concurrent reads and refreshes expired cache entries", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { client, fetcher } = setup({ [`/channels/${forumId}`]: forum, [`/guilds/${guildId}/threads/active`]: { threads: [thread] } });
|
||||||
|
await Promise.all([client.list(), client.list()]);
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||||
|
vi.advanceTimersByTime(30_001);
|
||||||
|
await client.list();
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(4);
|
||||||
|
});
|
||||||
|
it.each([{ status: "all" }, { limit: 0 }, { limit: 101 }, { limit: 1.5 }, { cursor: "../secret" }, { status: "archived", cursor: "bad-date" }])("validates list query %j before network access", async (query) => {
|
||||||
|
const { client, fetcher } = setup({});
|
||||||
|
await expect(client.list(query)).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it.each(["detail", "messages"] as const)("validates %s IDs before network access", async (method) => {
|
||||||
|
const { client, fetcher } = setup({});
|
||||||
|
await expect(client[method]("../secret")).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(fetcher).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it.each([{ ...forumPlaceholder(), type: 0 }, { ...forumPlaceholder(), guild_id: "100000000000000099" }])("refuses a non-forum or wrong-guild configured channel", async (value) => {
|
||||||
|
const { client, fetcher } = setup({ [`/channels/${forumId}`]: value });
|
||||||
|
await expect(client.list()).rejects.toMatchObject({ status: 503 });
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it("bounds concurrent upstream requests instead of flooding Discord", async () => {
|
||||||
|
const releases: (() => void)[] = [];
|
||||||
|
const fetcher = vi.fn<typeof fetch>(async (input) => {
|
||||||
|
if (String(input).endsWith(`/channels/${forumId}`)) return Response.json(forum);
|
||||||
|
if (String(input).endsWith("/threads/active")) return Response.json({ threads: [] });
|
||||||
|
return new Promise<Response>((resolve) => { releases.push(() => resolve(new Response(null, { status: 404 }))); });
|
||||||
|
});
|
||||||
|
const client = createSuggestionsClient({ token: "test", guildId, forumId, fetch: fetcher });
|
||||||
|
await client.list();
|
||||||
|
const results = Array.from({ length: 9 }, (_, index) => client.detail(`1000000000000001${index.toString().padStart(2, "0")}`).catch((error: unknown) => error));
|
||||||
|
await vi.waitFor(() => expect(fetcher.mock.calls.length).toBeGreaterThanOrEqual(10));
|
||||||
|
const count = releases.length;
|
||||||
|
releases.forEach((release) => release());
|
||||||
|
const errors = await Promise.all(results);
|
||||||
|
expect(count).toBe(8);
|
||||||
|
expect(errors).toContainEqual(expect.objectContaining({ code: "discord-busy", status: 503 }));
|
||||||
|
});
|
||||||
|
function forumPlaceholder() { return { id: forumId, guild_id: guildId, type: 15 }; }
|
||||||
|
|
||||||
|
const forum = { id: forumId, guild_id: guildId, type: 15, available_tags: [{ id: "100000000000000004", name: "World" }] };
|
||||||
|
|
||||||
|
it("lists only configured-forum active suggestions, resolves tags and paginates newest first", async () => {
|
||||||
|
const { client, fetcher } = setup({
|
||||||
|
[`/channels/${forumId}`]: forum,
|
||||||
|
[`/guilds/${guildId}/threads/active`]: { threads: [
|
||||||
|
{ ...thread, id: "100000000000000008" }, thread,
|
||||||
|
{ ...thread, id: "100000000000000010", parent_id: "100000000000000099" },
|
||||||
|
] },
|
||||||
|
});
|
||||||
|
const first = await client.list({ limit: 1 });
|
||||||
|
expect(first).toMatchObject({ items: [{ id: threadId, title: "More railway stations", tags: [{ name: "World" }], discordUrl: `https://discord.com/channels/${guildId}/${threadId}` }], nextCursor: threadId });
|
||||||
|
const second = await client.list({ limit: 1, cursor: threadId });
|
||||||
|
expect(second).toMatchObject({ items: [{ id: "100000000000000008" }], nextCursor: null });
|
||||||
|
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ headers: { Authorization: "Bot test-token" }, cache: "no-store", redirect: "error" });
|
||||||
|
});
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import type { MessagePage, SuggestionDetail, SuggestionMessage, Suggestion, SuggestionPage, SuggestionTag } from "./suggestion-types";
|
||||||
|
|
||||||
|
type Forum = { id: string; guild_id: string; type: number; available_tags: SuggestionTag[] };
|
||||||
|
type Thread = {
|
||||||
|
id: string; guild_id?: string; parent_id: string; type: number; name: string; owner_id: string;
|
||||||
|
applied_tags?: string[]; message_count?: number;
|
||||||
|
thread_metadata: { archived: boolean; locked: boolean; archive_timestamp: string };
|
||||||
|
};
|
||||||
|
type Message = {
|
||||||
|
id: string; content: string; timestamp: string; edited_timestamp?: string | null;
|
||||||
|
author: { id: string; username: string; global_name?: string | null };
|
||||||
|
reactions?: { emoji: { id?: string | null; name: string | null }; count: number }[];
|
||||||
|
};
|
||||||
|
export type ListQuery = { status?: string; cursor?: string; limit?: number };
|
||||||
|
|
||||||
|
export class SuggestionsError extends Error {
|
||||||
|
constructor(public readonly status: number, public readonly code: string, message: string, public readonly retryAfter?: number) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const snowflake = /^[1-9]\d{16,19}$/;
|
||||||
|
function checkId(id: string) {
|
||||||
|
if (!snowflake.test(id)) throw new SuggestionsError(400, "invalid-request", "A valid Discord ID is required.");
|
||||||
|
}
|
||||||
|
function limitValue(limit = 25) {
|
||||||
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw new SuggestionsError(400, "invalid-request", "Limit must be between 1 and 100.");
|
||||||
|
return limit;
|
||||||
|
}
|
||||||
|
function timestamp(value: string | number) {
|
||||||
|
return new Date(value).toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSuggestionsClient(options: { token: string; guildId: string; forumId: string; fetch?: typeof fetch }) {
|
||||||
|
const { token, guildId, forumId } = options;
|
||||||
|
const fetcher = options.fetch ?? fetch;
|
||||||
|
const cache = new Map<string, { expires: number; value: unknown }>();
|
||||||
|
let retryAt = 0;
|
||||||
|
let activeRequests = 0;
|
||||||
|
const pending = new Map<string, Promise<unknown>>();
|
||||||
|
async function get<T>(path: string): Promise<T> {
|
||||||
|
const existing = pending.get(path);
|
||||||
|
if (existing) return existing as Promise<T>;
|
||||||
|
const request = load<T>(path);
|
||||||
|
pending.set(path, request);
|
||||||
|
try { return await request; } finally { pending.delete(path); }
|
||||||
|
}
|
||||||
|
async function load<T>(path: string): Promise<T> {
|
||||||
|
const cached = cache.get(path);
|
||||||
|
if (cached && cached.expires > Date.now()) return cached.value as T;
|
||||||
|
if (Date.now() < retryAt) throw new SuggestionsError(503, "discord-rate-limited", "Discord is rate limited. Try again shortly.", Math.ceil((retryAt - Date.now()) / 1000));
|
||||||
|
if (activeRequests >= 8) throw new SuggestionsError(503, "discord-busy", "Suggestions are busy. Try again shortly.", 1);
|
||||||
|
activeRequests += 1;
|
||||||
|
try {
|
||||||
|
const response = await fetcher(`https://discord.com/api/v10${path}`, {
|
||||||
|
headers: { Authorization: `Bot ${token}` }, cache: "no-store", redirect: "error", signal: AbortSignal.timeout(8000),
|
||||||
|
});
|
||||||
|
if (response.status === 429) {
|
||||||
|
const body = await response.json().catch(() => null) as { retry_after?: number } | null;
|
||||||
|
const raw = Number(body?.retry_after ?? response.headers.get("retry-after") ?? 1);
|
||||||
|
const seconds = Number.isFinite(raw) && raw > 0 ? Math.ceil(raw) : 1;
|
||||||
|
retryAt = Date.now() + seconds * 1000;
|
||||||
|
throw new SuggestionsError(503, "discord-rate-limited", "Discord is rate limited. Try again shortly.", seconds);
|
||||||
|
}
|
||||||
|
if (response.status === 404) throw new SuggestionsError(404, "suggestion-not-found", "The suggestion or message was not found.");
|
||||||
|
if (!response.ok) throw new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
|
||||||
|
const value: unknown = await response.json();
|
||||||
|
if (cache.size >= 200) cache.delete(cache.keys().next().value!);
|
||||||
|
cache.set(path, { value, expires: Date.now() + 30_000 });
|
||||||
|
return value as T;
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof SuggestionsError) throw error;
|
||||||
|
throw new SuggestionsError(503, "discord-unavailable", "Discord suggestions are unavailable.");
|
||||||
|
} finally {
|
||||||
|
activeRequests -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function getForum() {
|
||||||
|
if (!token || !snowflake.test(guildId) || !snowflake.test(forumId)) throw new SuggestionsError(503, "suggestions-not-configured", "Discord suggestions are not configured.");
|
||||||
|
const forum = await get<Forum>(`/channels/${forumId}`).catch((error: unknown) => {
|
||||||
|
if (error instanceof SuggestionsError && error.status === 404) throw new SuggestionsError(503, "suggestions-not-configured", "The configured suggestions forum is unavailable.");
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
if (forum.id !== forumId || forum.guild_id !== guildId || forum.type !== 15) throw new SuggestionsError(503, "suggestions-not-configured", "The configured channel must be a forum in the configured guild.");
|
||||||
|
return forum;
|
||||||
|
}
|
||||||
|
function belongs(thread: Thread) {
|
||||||
|
return thread.parent_id === forumId && (!thread.guild_id || thread.guild_id === guildId) && thread.type === 11;
|
||||||
|
}
|
||||||
|
function summary(thread: Thread, forum: Forum): Suggestion {
|
||||||
|
return {
|
||||||
|
id: thread.id, title: thread.name, authorId: thread.owner_id,
|
||||||
|
createdAt: timestamp(Number((BigInt(thread.id) >> 22n) + 1420070400000n)),
|
||||||
|
archived: thread.thread_metadata.archived, locked: thread.thread_metadata.locked,
|
||||||
|
tags: (forum.available_tags ?? []).filter((tag) => thread.applied_tags?.includes(tag.id)).map(({ id, name }) => ({ id, name })),
|
||||||
|
messageCount: thread.message_count ?? 0, discordUrl: `https://discord.com/channels/${guildId}/${thread.id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function getThread(id: string) {
|
||||||
|
checkId(id);
|
||||||
|
const forum = await getForum();
|
||||||
|
const thread = await get<Thread>(`/channels/${id}`);
|
||||||
|
if (thread.id !== id || !belongs(thread)) throw new SuggestionsError(404, "suggestion-not-found", "The suggestion was not found in the configured forum.");
|
||||||
|
return { thread, forum };
|
||||||
|
}
|
||||||
|
function messageView(message: Message, threadId: string): SuggestionMessage {
|
||||||
|
return {
|
||||||
|
id: message.id, content: message.content,
|
||||||
|
author: { id: message.author.id, name: message.author.global_name || message.author.username },
|
||||||
|
createdAt: timestamp(message.timestamp), editedAt: message.edited_timestamp ? timestamp(message.edited_timestamp) : null,
|
||||||
|
reactions: (message.reactions ?? []).map(({ emoji, count }) => ({ emoji: emoji.id ? `:${emoji.name ?? "emoji"}:` : emoji.name ?? "emoji", count })),
|
||||||
|
discordUrl: `https://discord.com/channels/${guildId}/${threadId}/${message.id}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
async detail(id: string): Promise<SuggestionDetail> {
|
||||||
|
const { thread, forum } = await getThread(id);
|
||||||
|
const message = await get<Message>(`/channels/${id}/messages/${id}`).catch((error: unknown) => {
|
||||||
|
if (error instanceof SuggestionsError && error.status === 404) return null;
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
return { ...summary(thread, forum), originalPost: message ? messageView(message, id) : null };
|
||||||
|
},
|
||||||
|
async messages(id: string, query: { cursor?: string; limit?: number } = {}): Promise<MessagePage> {
|
||||||
|
const limit = limitValue(query.limit);
|
||||||
|
if (query.cursor) checkId(query.cursor);
|
||||||
|
await getThread(id);
|
||||||
|
const before = query.cursor ? `&before=${query.cursor}` : "";
|
||||||
|
const messages = await get<Message[]>(`/channels/${id}/messages?limit=${limit}${before}`);
|
||||||
|
return { items: messages.map((message) => messageView(message, id)), nextCursor: messages.length === limit ? messages.at(-1)!.id : null };
|
||||||
|
},
|
||||||
|
async list(query: ListQuery = {}): Promise<SuggestionPage> {
|
||||||
|
const limit = limitValue(query.limit);
|
||||||
|
const status = query.status ?? "active";
|
||||||
|
if (status !== "active" && status !== "archived") throw new SuggestionsError(400, "invalid-request", "Status must be active or archived.");
|
||||||
|
if (query.cursor) {
|
||||||
|
if (status === "active") checkId(query.cursor);
|
||||||
|
else if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/.test(query.cursor) || !Number.isFinite(Date.parse(query.cursor))) {
|
||||||
|
throw new SuggestionsError(400, "invalid-request", "The archive cursor must be a UTC timestamp.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const forum = await getForum();
|
||||||
|
if (status === "archived") {
|
||||||
|
const before = query.cursor ? `&before=${encodeURIComponent(query.cursor)}` : "";
|
||||||
|
const data = await get<{ threads: Thread[]; has_more: boolean }>(`/channels/${forumId}/threads/archived/public?limit=${limit}${before}`);
|
||||||
|
return {
|
||||||
|
items: data.threads.filter(belongs).map((thread) => summary(thread, forum)),
|
||||||
|
nextCursor: data.has_more && data.threads.length ? data.threads.at(-1)!.thread_metadata.archive_timestamp.replace(/\.000Z$/, "Z") : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const data = await get<{ threads: Thread[] }>(`/guilds/${guildId}/threads/active`);
|
||||||
|
const threads = data.threads.filter(belongs).sort((a, b) => BigInt(a.id) > BigInt(b.id) ? -1 : 1)
|
||||||
|
.filter((thread) => !query.cursor || BigInt(thread.id) < BigInt(query.cursor));
|
||||||
|
const page = threads.slice(0, limit);
|
||||||
|
return { items: page.map((thread) => summary(thread, forum)), nextCursor: threads.length > limit ? page.at(-1)!.id : null };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,66 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { buildRconCommandHistory, normalizeRconHistoryFilters } from "./rcon-command-history";
|
||||||
|
|
||||||
|
const correlationId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
|
||||||
|
function event(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id: "22222222-2222-4222-8222-222222222222",
|
||||||
|
time: new Date("2026-08-14T01:00:00Z"),
|
||||||
|
correlationId,
|
||||||
|
data: {
|
||||||
|
command: "say hello operators",
|
||||||
|
serverId: "33333333-3333-4333-8333-333333333333",
|
||||||
|
name: "Season 4",
|
||||||
|
adminEmail: "admin@example.test",
|
||||||
|
adminName: "Admin",
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RCON command history", () => {
|
||||||
|
it("normalizes bounded search filters and accepts only known servers", () => {
|
||||||
|
expect(normalizeRconHistoryFilters({
|
||||||
|
command: [" say hello ", "ignored"],
|
||||||
|
admin: " admin@example.test ",
|
||||||
|
server: "33333333-3333-4333-8333-333333333333",
|
||||||
|
}, ["33333333-3333-4333-8333-333333333333"])).toEqual({
|
||||||
|
command: "say hello",
|
||||||
|
admin: "admin@example.test",
|
||||||
|
serverId: "33333333-3333-4333-8333-333333333333",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(normalizeRconHistoryFilters({ server: "unknown" }, [])).toEqual({
|
||||||
|
command: "",
|
||||||
|
admin: "",
|
||||||
|
serverId: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pairs requested commands with their completion outcome without exposing responses", () => {
|
||||||
|
const requested = event();
|
||||||
|
const completed = event({
|
||||||
|
id: "44444444-4444-4444-8444-444444444444",
|
||||||
|
data: { success: false, reason: "timeout", durationMs: 5001 },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(buildRconCommandHistory([requested], [completed])).toEqual([{
|
||||||
|
eventId: requested.id,
|
||||||
|
time: requested.time,
|
||||||
|
command: "say hello operators",
|
||||||
|
serverId: "33333333-3333-4333-8333-333333333333",
|
||||||
|
serverName: "Season 4",
|
||||||
|
adminEmail: "admin@example.test",
|
||||||
|
adminName: "Admin",
|
||||||
|
status: "failed",
|
||||||
|
reason: "timeout",
|
||||||
|
durationMs: 5001,
|
||||||
|
}]);
|
||||||
|
expect(JSON.stringify(buildRconCommandHistory([requested], [completed]))).not.toContain("response");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a requested command pending when no completion event exists", () => {
|
||||||
|
expect(buildRconCommandHistory([event()], [event({ correlationId: null })])[0]?.status).toBe("pending");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
export const RCON_COMMAND_REQUESTED = "games.minecraft.account-manager.rcon.command.requested";
|
||||||
|
export const RCON_COMMAND_COMPLETED = "games.minecraft.account-manager.rcon.command.completed";
|
||||||
|
|
||||||
|
export type RconHistoryEvent = {
|
||||||
|
id: string;
|
||||||
|
time: Date;
|
||||||
|
correlationId: string | null;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RconCommandHistoryRow = {
|
||||||
|
eventId: string;
|
||||||
|
time: Date;
|
||||||
|
command: string;
|
||||||
|
serverId: string;
|
||||||
|
serverName: string;
|
||||||
|
adminEmail: string | null;
|
||||||
|
adminName: string | null;
|
||||||
|
status: "pending" | "succeeded" | "failed";
|
||||||
|
reason: string | null;
|
||||||
|
durationMs: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchParams = Record<string, string | string[] | undefined>;
|
||||||
|
|
||||||
|
function first(value: string | string[] | undefined) {
|
||||||
|
return (Array.isArray(value) ? value[0] : value)?.trim() ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(data: Record<string, unknown>, key: string) {
|
||||||
|
const value = data[key];
|
||||||
|
return typeof value === "string" && value ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRconHistoryFilters(query: SearchParams, availableServerIds: string[]) {
|
||||||
|
const requestedServerId = first(query.server);
|
||||||
|
return {
|
||||||
|
command: first(query.command).slice(0, 1024),
|
||||||
|
admin: first(query.admin).slice(0, 320),
|
||||||
|
serverId: availableServerIds.includes(requestedServerId) ? requestedServerId : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRconCommandHistory(
|
||||||
|
requestedEvents: RconHistoryEvent[],
|
||||||
|
completedEvents: RconHistoryEvent[],
|
||||||
|
): RconCommandHistoryRow[] {
|
||||||
|
const completions = new Map(completedEvents
|
||||||
|
.filter((event) => event.correlationId)
|
||||||
|
.map((event) => [event.correlationId, event]));
|
||||||
|
|
||||||
|
return requestedEvents.flatMap((event) => {
|
||||||
|
const command = text(event.data, "command");
|
||||||
|
const serverId = text(event.data, "serverId");
|
||||||
|
const serverName = text(event.data, "name");
|
||||||
|
if (!command || !serverId || !serverName) return [];
|
||||||
|
|
||||||
|
const completed = event.correlationId ? completions.get(event.correlationId) : undefined;
|
||||||
|
const success = completed?.data.success;
|
||||||
|
const duration = completed?.data.durationMs;
|
||||||
|
return [{
|
||||||
|
eventId: event.id,
|
||||||
|
time: event.time,
|
||||||
|
command,
|
||||||
|
serverId,
|
||||||
|
serverName,
|
||||||
|
adminEmail: text(event.data, "adminEmail"),
|
||||||
|
adminName: text(event.data, "adminName"),
|
||||||
|
status: success === true ? "succeeded" as const : success === false ? "failed" as const : "pending" as const,
|
||||||
|
reason: completed ? text(completed.data, "reason") : null,
|
||||||
|
durationMs: typeof duration === "number" && Number.isFinite(duration) ? duration : null,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { randomBytes } from "node:crypto";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { decryptRconPassword, encryptRconPassword, rconCommandDigest } from "./rcon-credentials";
|
||||||
|
|
||||||
|
const key = randomBytes(32).toString("base64");
|
||||||
|
const otherKey = randomBytes(32).toString("base64");
|
||||||
|
const connectionId = "11111111-1111-4111-8111-111111111111";
|
||||||
|
|
||||||
|
describe("RCON credential encryption", () => {
|
||||||
|
it("round trips with randomized authenticated encryption", () => {
|
||||||
|
const first = encryptRconPassword("super-secret", connectionId, key);
|
||||||
|
const second = encryptRconPassword("super-secret", connectionId, key);
|
||||||
|
|
||||||
|
expect(first).not.toBe(second);
|
||||||
|
expect(first).not.toContain("super-secret");
|
||||||
|
expect(decryptRconPassword(first, connectionId, key)).toBe("super-secret");
|
||||||
|
expect(decryptRconPassword(second, connectionId, key)).toBe("super-secret");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails closed for tampering, another connection, or another key", () => {
|
||||||
|
const encrypted = encryptRconPassword("super-secret", connectionId, key);
|
||||||
|
expect(() => decryptRconPassword(`${encrypted}x`, connectionId, key)).toThrow("RCON credential unavailable");
|
||||||
|
expect(() => decryptRconPassword(encrypted, "22222222-2222-4222-8222-222222222222", key)).toThrow("RCON credential unavailable");
|
||||||
|
expect(() => decryptRconPassword(encrypted, connectionId, otherKey)).toThrow("RCON credential unavailable");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires an exact 32-byte deployment key", () => {
|
||||||
|
expect(() => encryptRconPassword("secret", connectionId, "not-base64")).toThrow("RCON credential key is not configured");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a keyed, versioned command digest", () => {
|
||||||
|
const digest = rconCommandDigest("say secret message", key);
|
||||||
|
expect(digest).toMatch(/^hmac-sha256:v1:[a-f0-9]{64}$/u);
|
||||||
|
expect(digest).not.toContain("secret message");
|
||||||
|
expect(rconCommandDigest("say secret message", key)).toBe(digest);
|
||||||
|
expect(rconCommandDigest("say secret message", otherKey)).not.toBe(digest);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
|
||||||
|
|
||||||
|
const VERSION = "v1";
|
||||||
|
const KEY_BYTES = 32;
|
||||||
|
const IV_BYTES = 12;
|
||||||
|
|
||||||
|
function explicitKey(encoded: string) {
|
||||||
|
const key = Buffer.from(encoded, "base64");
|
||||||
|
if (key.length !== KEY_BYTES || key.toString("base64").replace(/=+$/u, "") !== encoded.trim().replace(/=+$/u, "")) {
|
||||||
|
throw new Error("invalid key");
|
||||||
|
}
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function credentialKey(encoded: string | undefined, purpose: "credential" | "audit" = "credential") {
|
||||||
|
if (encoded !== undefined) return explicitKey(encoded);
|
||||||
|
const configured = purpose === "credential" ? process.env.RCON_CREDENTIAL_KEY : process.env.RCON_AUDIT_KEY;
|
||||||
|
if (configured) return explicitKey(configured);
|
||||||
|
const authSecret = process.env.AUTH_SECRET;
|
||||||
|
if (!authSecret) throw new Error("missing key");
|
||||||
|
return createHash("sha256").update(`minecraft-account-manager:rcon:${purpose}:v1\0${authSecret}`, "utf8").digest();
|
||||||
|
}
|
||||||
|
|
||||||
|
function additionalData(connectionId: string) {
|
||||||
|
return Buffer.from(`${VERSION}:${connectionId}`, "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64url(value: string) {
|
||||||
|
const decoded = Buffer.from(value, "base64url");
|
||||||
|
if (decoded.toString("base64url") !== value) throw new Error("invalid envelope");
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptRconPassword(password: string, connectionId: string, encodedKey?: string) {
|
||||||
|
let key: Buffer;
|
||||||
|
try {
|
||||||
|
key = credentialKey(encodedKey);
|
||||||
|
} catch {
|
||||||
|
throw new Error("RCON credential key is not configured");
|
||||||
|
}
|
||||||
|
const iv = randomBytes(IV_BYTES);
|
||||||
|
const cipher = createCipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
|
||||||
|
cipher.setAAD(additionalData(connectionId));
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(password, "utf8"), cipher.final()]);
|
||||||
|
return [VERSION, iv.toString("base64url"), cipher.getAuthTag().toString("base64url"), ciphertext.toString("base64url")].join(":");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function rconCommandDigest(command: string, encodedKey?: string) {
|
||||||
|
let key: Buffer;
|
||||||
|
try {
|
||||||
|
key = credentialKey(encodedKey, "audit");
|
||||||
|
} catch {
|
||||||
|
throw new Error("RCON audit key is not configured");
|
||||||
|
}
|
||||||
|
return `hmac-sha256:v1:${createHmac("sha256", key).update(command, "utf8").digest("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptRconPassword(envelope: string, connectionId: string, encodedKey?: string) {
|
||||||
|
try {
|
||||||
|
const key = credentialKey(encodedKey);
|
||||||
|
const [version, ivValue, tagValue, ciphertextValue, extra] = envelope.split(":");
|
||||||
|
if (version !== VERSION || !ivValue || !tagValue || !ciphertextValue || extra) throw new Error("invalid envelope");
|
||||||
|
const iv = decodeBase64url(ivValue);
|
||||||
|
const tag = decodeBase64url(tagValue);
|
||||||
|
const ciphertext = decodeBase64url(ciphertextValue);
|
||||||
|
if (iv.length !== IV_BYTES || tag.length !== 16) throw new Error("invalid envelope");
|
||||||
|
const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: 16 });
|
||||||
|
decipher.setAAD(additionalData(connectionId));
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
|
||||||
|
} catch {
|
||||||
|
throw new Error("RCON credential unavailable");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { executeRcon, testRconConnection, type RconTransport } from "./rcon-gateway";
|
||||||
|
|
||||||
|
function transport(overrides: Partial<RconTransport> = {}): RconTransport {
|
||||||
|
return {
|
||||||
|
connect: vi.fn().mockResolvedValue(undefined),
|
||||||
|
send: vi.fn().mockResolvedValue("20 players online"),
|
||||||
|
end: vi.fn().mockResolvedValue(undefined),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RCON gateway", () => {
|
||||||
|
it("authenticates a connection without sending a command", async () => {
|
||||||
|
const client = transport();
|
||||||
|
await expect(testRconConnection({ host: "season4", port: 25575, password: "secret" }, () => client)).resolves.toEqual({ ok: true });
|
||||||
|
expect(client.connect).toHaveBeenCalledOnce();
|
||||||
|
expect(client.send).not.toHaveBeenCalled();
|
||||||
|
expect(client.end).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("executes one command and always closes the connection", async () => {
|
||||||
|
const client = transport();
|
||||||
|
await expect(executeRcon({ host: "season4", port: 25575, password: "secret" }, "list", () => client)).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
response: "20 players online",
|
||||||
|
});
|
||||||
|
expect(client.send).toHaveBeenCalledWith("list");
|
||||||
|
expect(client.end).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns safe categorized failures and closes failed clients", async () => {
|
||||||
|
const client = transport({ connect: vi.fn().mockRejectedValue(new Error("password secret rejected")) });
|
||||||
|
await expect(testRconConnection({ host: "season4", port: 25575, password: "secret" }, () => client)).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "unavailable",
|
||||||
|
});
|
||||||
|
expect(client.end).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects concurrent work for the same connection", async () => {
|
||||||
|
let release!: () => void;
|
||||||
|
const pending = new Promise<string>((resolve) => { release = () => resolve("done"); });
|
||||||
|
const firstClient = transport({ send: vi.fn().mockReturnValue(pending) });
|
||||||
|
const first = executeRcon({ id: "server-one", host: "season4", port: 25575, password: "secret" }, "list", () => firstClient);
|
||||||
|
await vi.waitFor(() => expect(firstClient.send).toHaveBeenCalled());
|
||||||
|
|
||||||
|
await expect(executeRcon({ id: "server-one", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "busy",
|
||||||
|
});
|
||||||
|
release();
|
||||||
|
await first;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds total concurrent work", async () => {
|
||||||
|
let release!: () => void;
|
||||||
|
const pendingResponse = new Promise<string>((resolve) => { release = () => resolve("done"); });
|
||||||
|
const clients = Array.from({ length: 8 }, () => transport({ send: vi.fn().mockReturnValue(pendingResponse) }));
|
||||||
|
const active = clients.map((client, index) => executeRcon({
|
||||||
|
id: `server-${index}`,
|
||||||
|
host: `season-${index}`,
|
||||||
|
port: 25575,
|
||||||
|
password: "secret",
|
||||||
|
}, "list", () => client));
|
||||||
|
await vi.waitFor(() => expect(clients.every((client) => vi.mocked(client.send).mock.calls.length === 1)).toBe(true));
|
||||||
|
|
||||||
|
await expect(executeRcon({ id: "server-ninth", host: "season-9", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||||
|
ok: false,
|
||||||
|
reason: "busy",
|
||||||
|
});
|
||||||
|
release();
|
||||||
|
await Promise.all(active);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("times out the complete operation, aborts the socket, and releases the connection", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const client = transport({
|
||||||
|
send: vi.fn().mockReturnValue(new Promise(() => undefined)),
|
||||||
|
destroy: vi.fn(),
|
||||||
|
});
|
||||||
|
const pending = executeRcon({ id: "server-timeout", host: "season4", port: 25575, password: "secret" }, "list", () => client);
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
await expect(pending).resolves.toEqual({ ok: false, reason: "timeout" });
|
||||||
|
expect(client.destroy).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
await expect(executeRcon({ id: "server-timeout", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
response: "20 players online",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let stalled cleanup retain a connection lock", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const client = transport({ end: vi.fn().mockReturnValue(new Promise(() => undefined)) });
|
||||||
|
const pending = executeRcon({ id: "server-cleanup", host: "season4", port: 25575, password: "secret" }, "list", () => client);
|
||||||
|
await vi.advanceTimersByTimeAsync(1_000);
|
||||||
|
await expect(pending).resolves.toEqual({ ok: true, response: "20 players online" });
|
||||||
|
|
||||||
|
await expect(executeRcon({ id: "server-cleanup", host: "season4", port: 25575, password: "secret" }, "list", () => transport())).resolves.toEqual({
|
||||||
|
ok: true,
|
||||||
|
response: "20 players online",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Rcon } from "rcon-client";
|
||||||
|
import { sanitizeRconOutput } from "./rcon-validation";
|
||||||
|
|
||||||
|
const TIMEOUT_MS = 5_000;
|
||||||
|
const CLEANUP_TIMEOUT_MS = 1_000;
|
||||||
|
const MAX_ACTIVE_CONNECTIONS = 8;
|
||||||
|
const activeConnections = new Set<string>();
|
||||||
|
|
||||||
|
type Connection = { id?: string; host: string; port: number; password: string };
|
||||||
|
type FailureReason = "busy" | "timeout" | "unavailable";
|
||||||
|
|
||||||
|
export interface RconTransport {
|
||||||
|
connect(): Promise<unknown>;
|
||||||
|
send(command: string): Promise<string>;
|
||||||
|
end(): Promise<unknown>;
|
||||||
|
destroy?(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransportFactory = (connection: Connection) => RconTransport;
|
||||||
|
|
||||||
|
class RconDeadlineError extends Error {}
|
||||||
|
|
||||||
|
async function deadline<T>(operation: Promise<T>, timeout: () => void, timeoutMs = TIMEOUT_MS) {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
try {
|
||||||
|
return await Promise.race([
|
||||||
|
operation,
|
||||||
|
new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timeout();
|
||||||
|
reject(new RconDeadlineError("RCON operation timed out"));
|
||||||
|
}, timeoutMs);
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultTransport(connection: Connection): RconTransport {
|
||||||
|
const client = new Rcon({
|
||||||
|
host: connection.host,
|
||||||
|
port: connection.port,
|
||||||
|
password: connection.password,
|
||||||
|
timeout: TIMEOUT_MS,
|
||||||
|
maxPending: 1,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
connect: () => client.connect(),
|
||||||
|
send: (command) => client.send(command),
|
||||||
|
end: async () => {
|
||||||
|
if (!client.socket) return;
|
||||||
|
if (client.socket.connecting || !client.socket.writable) {
|
||||||
|
client.socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await client.end();
|
||||||
|
},
|
||||||
|
destroy: () => client.socket?.destroy(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function failure(error: unknown): { ok: false; reason: FailureReason } {
|
||||||
|
return { ok: false, reason: error instanceof RconDeadlineError ? "timeout" : "unavailable" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withTransport<T>(
|
||||||
|
connection: Connection,
|
||||||
|
operation: (transport: RconTransport) => Promise<T>,
|
||||||
|
factory: TransportFactory,
|
||||||
|
): Promise<T | { ok: false; reason: FailureReason }> {
|
||||||
|
const key = connection.id ?? `${connection.host}:${connection.port}`;
|
||||||
|
if (activeConnections.has(key) || activeConnections.size >= MAX_ACTIVE_CONNECTIONS) {
|
||||||
|
return { ok: false, reason: "busy" };
|
||||||
|
}
|
||||||
|
activeConnections.add(key);
|
||||||
|
let transport: RconTransport | null = null;
|
||||||
|
try {
|
||||||
|
transport = factory(connection);
|
||||||
|
return await deadline(operation(transport), () => transport?.destroy?.());
|
||||||
|
} catch (error) {
|
||||||
|
return failure(error);
|
||||||
|
} finally {
|
||||||
|
if (transport) {
|
||||||
|
await deadline(transport.end(), () => transport?.destroy?.(), CLEANUP_TIMEOUT_MS).catch(() => undefined);
|
||||||
|
}
|
||||||
|
activeConnections.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testRconConnection(connection: Connection, factory: TransportFactory = defaultTransport) {
|
||||||
|
return withTransport(connection, async (transport) => {
|
||||||
|
await transport.connect();
|
||||||
|
return { ok: true as const };
|
||||||
|
}, factory);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeRcon(connection: Connection, command: string, factory: TransportFactory = defaultTransport) {
|
||||||
|
return withTransport(connection, async (transport) => {
|
||||||
|
await transport.connect();
|
||||||
|
const response = await transport.send(command);
|
||||||
|
return { ok: true as const, response: sanitizeRconOutput(response) };
|
||||||
|
}, factory);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { sanitizeRconOutput, validateRconCommand, validateRconConnection } from "./rcon-validation";
|
||||||
|
|
||||||
|
describe("RCON validation", () => {
|
||||||
|
it("normalizes any valid DNS hostname and port without deployment configuration", () => {
|
||||||
|
expect(validateRconConnection({
|
||||||
|
name: " Season 4 ",
|
||||||
|
host: "SEASON4.SOMC.SVC.CLUSTER.LOCAL",
|
||||||
|
port: "25575",
|
||||||
|
password: "correct horse battery staple",
|
||||||
|
}, { passwordRequired: true })).toEqual({
|
||||||
|
name: "Season 4",
|
||||||
|
host: "season4.somc.svc.cluster.local",
|
||||||
|
port: 25575,
|
||||||
|
password: "correct horse battery staple",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(validateRconConnection({
|
||||||
|
name: "Creative",
|
||||||
|
host: "creative.example.net",
|
||||||
|
port: "43210",
|
||||||
|
password: "secret",
|
||||||
|
}, { passwordRequired: true })).toEqual({
|
||||||
|
name: "Creative",
|
||||||
|
host: "creative.example.net",
|
||||||
|
port: 43210,
|
||||||
|
password: "secret",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects IP literals and malformed DNS hostnames", () => {
|
||||||
|
for (const host of ["10.0.0.1", "2001:db8::1", "season4.", "-season4.example", "season4..example"]) {
|
||||||
|
expect(validateRconConnection({ name: "Server", host, port: "25575", password: "secret" }, {
|
||||||
|
passwordRequired: true,
|
||||||
|
})).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("allows a blank replacement password only while editing", () => {
|
||||||
|
expect(validateRconConnection({ name: "Server", host: "season4.somc.svc.cluster.local", port: "25575", password: "" }, {
|
||||||
|
passwordRequired: false,
|
||||||
|
})?.password).toBeNull();
|
||||||
|
expect(validateRconConnection({ name: "Server", host: "season4.somc.svc.cluster.local", port: "25575", password: "" }, {
|
||||||
|
passwordRequired: true,
|
||||||
|
})).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds commands by UTF-8 bytes and rejects control characters", () => {
|
||||||
|
expect(validateRconCommand(" list ")).toBe("list");
|
||||||
|
expect(validateRconCommand("say first\nsay second")).toBeNull();
|
||||||
|
expect(validateRconCommand("say \u001b[31mred")).toBeNull();
|
||||||
|
expect(validateRconCommand(`say ${"😀".repeat(300)}`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips output controls and bounds output by UTF-8 bytes", () => {
|
||||||
|
expect(sanitizeRconOutput("ok\u001b[31mred\u0000done")).toBe("ok[31mreddone");
|
||||||
|
expect(Buffer.byteLength(sanitizeRconOutput("😀".repeat(20_000)), "utf8")).toBeLessThanOrEqual(65_536);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { isIP } from "node:net";
|
||||||
|
|
||||||
|
const HOST_PATTERN = /^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||||
|
const CONTROL_PATTERN = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u;
|
||||||
|
const MAX_COMMAND_BYTES = 1_024;
|
||||||
|
const MAX_OUTPUT_BYTES = 65_536;
|
||||||
|
|
||||||
|
export type ValidRconConnection = {
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
password: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function validateRconConnection(
|
||||||
|
input: { name: unknown; host: unknown; port: unknown; password: unknown },
|
||||||
|
options: { passwordRequired: boolean },
|
||||||
|
): ValidRconConnection | null {
|
||||||
|
const name = typeof input.name === "string" ? input.name.trim() : "";
|
||||||
|
const host = typeof input.host === "string" ? input.host.trim().toLowerCase() : "";
|
||||||
|
const portText = typeof input.port === "string" || typeof input.port === "number" ? String(input.port).trim() : "";
|
||||||
|
const passwordText = typeof input.password === "string" ? input.password : "";
|
||||||
|
const port = Number(portText);
|
||||||
|
|
||||||
|
if (!name || name.length > 100 || CONTROL_PATTERN.test(name)) return null;
|
||||||
|
if (!host || host.endsWith(".") || isIP(host) !== 0 || !HOST_PATTERN.test(host)) return null;
|
||||||
|
if (!Number.isInteger(port) || port < 1 || port > 65_535) return null;
|
||||||
|
if (passwordText.length > 512 || CONTROL_PATTERN.test(passwordText)) return null;
|
||||||
|
if (options.passwordRequired && !passwordText) return null;
|
||||||
|
|
||||||
|
return { name, host, port, password: passwordText || null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateRconCommand(value: unknown) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const command = value.trim();
|
||||||
|
if (!command || CONTROL_PATTERN.test(command) || Buffer.byteLength(command, "utf8") > MAX_COMMAND_BYTES) return null;
|
||||||
|
return command;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeRconOutput(value: string) {
|
||||||
|
const safe = value.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/gu, "");
|
||||||
|
if (Buffer.byteLength(safe, "utf8") <= MAX_OUTPUT_BYTES) return safe;
|
||||||
|
let result = "";
|
||||||
|
let bytes = 0;
|
||||||
|
for (const character of safe) {
|
||||||
|
const size = Buffer.byteLength(character, "utf8");
|
||||||
|
if (bytes + size > MAX_OUTPUT_BYTES) break;
|
||||||
|
result += character;
|
||||||
|
bytes += size;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
MAP_LOCATION_CLASSIFICATIONS,
|
||||||
|
groupMapLocations,
|
||||||
|
parseUserLocation,
|
||||||
|
parseUserNetwork,
|
||||||
|
projectWorldPoint,
|
||||||
|
} from "./user-location-map";
|
||||||
|
|
||||||
|
describe("user location map", () => {
|
||||||
|
it("allows only clear and hosting observations as map locations", () => {
|
||||||
|
expect(MAP_LOCATION_CLASSIFICATIONS).toEqual(["clear", "hosting"]);
|
||||||
|
expect(MAP_LOCATION_CLASSIFICATIONS).not.toContain("vpn");
|
||||||
|
expect(MAP_LOCATION_CLASSIFICATIONS).not.toContain("proxy");
|
||||||
|
expect(MAP_LOCATION_CLASSIFICATIONS).not.toContain("tor");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts a valid approximate location from cached IP intelligence", () => {
|
||||||
|
expect(parseUserLocation({
|
||||||
|
classification: "clear",
|
||||||
|
location: {
|
||||||
|
city: "Mountain View",
|
||||||
|
region: "California",
|
||||||
|
countryCode: "US",
|
||||||
|
latitude: 37.4056,
|
||||||
|
longitude: -122.0775,
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
latitude: 37.4056,
|
||||||
|
longitude: -122.0775,
|
||||||
|
label: "Mountain View, California, US",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts enriched network fields from existing ProxyCheck cache entries", () => {
|
||||||
|
expect(parseUserNetwork({
|
||||||
|
network: { asn: "AS7922", provider: "Comcast Cable Communications, LLC" },
|
||||||
|
rawResponse: {
|
||||||
|
status: "ok",
|
||||||
|
"203.0.113.10": { type: "Residential", proxy: "no" },
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
asn: "AS7922",
|
||||||
|
provider: "Comcast Cable Communications, LLC",
|
||||||
|
connectionType: "Residential",
|
||||||
|
proxy: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers normalized network fields and preserves unavailable values", () => {
|
||||||
|
expect(parseUserNetwork({
|
||||||
|
network: { asn: "AS62371", provider: "Proton AG", connectionType: "VPN", proxy: true },
|
||||||
|
rawResponse: { "198.51.100.5": { type: "Residential", proxy: "no" } },
|
||||||
|
})).toEqual({ asn: "AS62371", provider: "Proton AG", connectionType: "VPN", proxy: true });
|
||||||
|
expect(parseUserNetwork({ network: {} })).toEqual({ asn: null, provider: null, connectionType: null, proxy: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing and out-of-range coordinates", () => {
|
||||||
|
expect(parseUserLocation({ location: { latitude: 91, longitude: 0 } })).toBeNull();
|
||||||
|
expect(parseUserLocation({ location: { city: "Unknown" } })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups users sharing approximate coordinates without hiding their identities", () => {
|
||||||
|
const groups = groupMapLocations([
|
||||||
|
{ userId: "one", nickname: "Dani (Steve)", latitude: 37.4056, longitude: -122.0775 },
|
||||||
|
{ userId: "two", nickname: "Alex (AlexMC)", latitude: 37.4057, longitude: -122.0774 },
|
||||||
|
{ userId: "three", nickname: "Sam (Notch)", latitude: 51.5, longitude: -0.12 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(groups).toHaveLength(2);
|
||||||
|
expect(groups[0]).toMatchObject({ count: 2, nicknames: ["Alex (AlexMC)", "Dani (Steve)"] });
|
||||||
|
expect(groups[0]?.locations.map((location) => location.userId)).toEqual(["one", "two"]);
|
||||||
|
expect(groups[1]).toMatchObject({ count: 1, nicknames: ["Sam (Notch)"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes signed zero and the antimeridian before grouping", () => {
|
||||||
|
const groups = groupMapLocations([
|
||||||
|
{ nickname: "West", latitude: -0.004, longitude: 180 },
|
||||||
|
{ nickname: "East", latitude: 0.004, longitude: -180 },
|
||||||
|
]);
|
||||||
|
expect(groups).toHaveLength(1);
|
||||||
|
expect(groups[0]).toMatchObject({ count: 2, key: "0:-180", latitude: 0, longitude: -180 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("projects longitude and latitude into an equirectangular SVG", () => {
|
||||||
|
expect(projectWorldPoint(0, 0, 800, 400)).toEqual({ x: 400, y: 200 });
|
||||||
|
expect(projectWorldPoint(90, 180, 800, 400)).toEqual({ x: 800, y: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
type UnknownMap = Record<string, unknown>;
|
||||||
|
|
||||||
|
export const MAP_LOCATION_CLASSIFICATIONS = ["clear", "hosting"] as const;
|
||||||
|
|
||||||
|
function objectValue(value: unknown): UnknownMap | null {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? value as UnknownMap
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringValue(value: unknown) {
|
||||||
|
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function proxyValue(value: unknown) {
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (typeof value === "string" && value.toLowerCase() === "yes") return true;
|
||||||
|
if (typeof value === "string" && value.toLowerCase() === "no") return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coordinate(value: unknown) {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedUserLocation {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedUserNetwork {
|
||||||
|
asn: string | null;
|
||||||
|
provider: string | null;
|
||||||
|
connectionType: string | null;
|
||||||
|
proxy: boolean | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUserNetwork(value: unknown): ParsedUserNetwork {
|
||||||
|
const intelligence = objectValue(value);
|
||||||
|
const network = objectValue(intelligence?.network);
|
||||||
|
const providerResponse = objectValue(intelligence?.rawResponse);
|
||||||
|
const legacyDetails = Object.values(providerResponse ?? {})
|
||||||
|
.map(objectValue)
|
||||||
|
.find((details) => details && ("type" in details || "proxy" in details));
|
||||||
|
return {
|
||||||
|
asn: stringValue(network?.asn),
|
||||||
|
provider: stringValue(network?.provider),
|
||||||
|
connectionType: stringValue(network?.connectionType) ?? stringValue(legacyDetails?.type),
|
||||||
|
proxy: proxyValue(network?.proxy) ?? proxyValue(legacyDetails?.proxy),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUserLocation(value: unknown): ParsedUserLocation | null {
|
||||||
|
const intelligence = objectValue(value);
|
||||||
|
const location = objectValue(intelligence?.location);
|
||||||
|
if (!location) return null;
|
||||||
|
const latitude = coordinate(location.latitude);
|
||||||
|
const longitude = coordinate(location.longitude);
|
||||||
|
if (latitude === null || longitude === null || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const label = [location.city, location.region, location.countryCode ?? location.country]
|
||||||
|
.filter((part): part is string => typeof part === "string" && part.trim().length > 0)
|
||||||
|
.join(", ");
|
||||||
|
return { latitude, longitude, label: label || "Approximate location unavailable" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupMapLocations<T extends {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
nickname: string;
|
||||||
|
}>(locations: T[]) {
|
||||||
|
const grouped = new Map<string, { latitude: number; longitude: number; locations: T[] }>();
|
||||||
|
for (const location of locations) {
|
||||||
|
const roundedLatitude = Number(location.latitude.toFixed(2));
|
||||||
|
const latitude = roundedLatitude === 0 ? 0 : roundedLatitude;
|
||||||
|
const roundedLongitude = Number(location.longitude.toFixed(2));
|
||||||
|
const longitude = Math.abs(roundedLongitude) === 180 ? -180 : roundedLongitude;
|
||||||
|
const key = `${latitude}:${longitude}`;
|
||||||
|
const group = grouped.get(key);
|
||||||
|
if (group) group.locations.push(location);
|
||||||
|
else grouped.set(key, { latitude, longitude, locations: [location] });
|
||||||
|
}
|
||||||
|
return [...grouped.entries()].map(([key, group]) => ({
|
||||||
|
key,
|
||||||
|
latitude: group.latitude,
|
||||||
|
longitude: group.longitude,
|
||||||
|
count: group.locations.length,
|
||||||
|
nicknames: [...group.locations.map((location) => location.nickname)].sort((left, right) => left.localeCompare(right)),
|
||||||
|
locations: group.locations,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectWorldPoint(latitude: number, longitude: number, width: number, height: number) {
|
||||||
|
return {
|
||||||
|
x: ((longitude + 180) / 360) * width,
|
||||||
|
y: ((90 - latitude) / 180) * height,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
okf_version: "0.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Minecraft Account Manager User Stories
|
|
||||||
|
|
||||||
This OKF bundle is the product record for implemented and proposed behavior. Story status and acceptance criteria are maintained alongside code changes.
|
|
||||||
|
|
||||||
## Player Experience
|
|
||||||
|
|
||||||
* [US-001 — Enter through Discord](us-001-discord-entry.md) - Direct portal visitors are guided to the configured Discord server.
|
|
||||||
* [US-002 — Authenticate with a Discord magic link](us-002-discord-magic-link.md) - Discord users receive secure, private, single-use portal links.
|
|
||||||
* [US-003 — Complete first-time onboarding](us-003-onboarding.md) - New users provide a name, connect a Java account, and confirm Discord identity.
|
|
||||||
* [US-004 — Validate Minecraft accounts](us-004-minecraft-validation.md) - Java usernames resolve through Mojang with explicit unverified overrides.
|
|
||||||
* [US-005 — Manage linked accounts](us-005-user-dashboard.md) - Users manage names, accounts, primaries, and security history.
|
|
||||||
* [US-006 — Keep Discord nicknames synchronized](us-006-discord-nickname.md) - Names and primary accounts determine the guild nickname.
|
|
||||||
|
|
||||||
## Network and Game Access
|
|
||||||
|
|
||||||
* [US-007 — Enrich login IPs](us-007-ip-intelligence.md) - Portal and game login events include cached ProxyCheck location and network data.
|
|
||||||
* [US-008 — Block anonymized account additions](us-008-vpn-blocking.md) - VPN, proxy, Tor, and unknown networks cannot add accounts.
|
|
||||||
* [US-009 — Enforce registration at Velocity](us-009-velocity-admission.md) - The proxy admits positively identified registered Java accounts only.
|
|
||||||
|
|
||||||
## Administration and Governance
|
|
||||||
|
|
||||||
* [US-010 — Preserve an audit trail](us-010-audit-events.md) - Security and account activity is stored as CloudEvents-style events.
|
|
||||||
* [US-011 — Authenticate administrators with SSO](us-011-admin-sso.md) - Keycloak and a required role protect the operator console.
|
|
||||||
* [US-012 — Operate settings and audit views](us-012-admin-operations.md) - Administrators configure denial messaging and inspect events.
|
|
||||||
* [US-013 — Manage users as an administrator](us-013-admin-user-management.md) - Administrators search users and manage names and Minecraft accounts.
|
|
||||||
* [US-014 — Receive standardized API errors](us-014-problem-details.md) - Application APIs return RFC 9457 Problem Details.
|
|
||||||
* [US-015 — Deploy and operate securely](us-015-platform-operations.md) - Operators have reproducible builds, migrations, credentials, and security controls.
|
|
||||||
* [US-016 — Build and publish versioned releases](us-016-automated-releases.md) - Gitea Actions publish the Velocity JAR and web and migration images.
|
|
||||||
* [US-017 — Control admission with groups](us-017-group-access.md) - Each user has one effective group that explicitly controls Minecraft access.
|
|
||||||
* [US-018 — Monitor community account activity](us-018-admin-dashboard.md) - Administrators review registrations, monthly activity, denials, and risky networks.
|
|
||||||
|
|
||||||
# Tracking
|
|
||||||
|
|
||||||
See the [design update log](log.md) for high-level changes. New work starts by creating or updating a story and its acceptance criteria.
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Design Update Log
|
|
||||||
|
|
||||||
## 2026-08-01
|
|
||||||
|
|
||||||
* **Refine**: Make group assignment exclusive with default fallback, add group deletion, automatically synchronize Discord nicknames with status notices, expose filterable event details, add an SSR operations dashboard, and improve accessibility.
|
|
||||||
* **Extend**: Add SoMC Portal branding, live Discord identity details, admin guild configuration visibility, and fail-closed group-based Minecraft admission.
|
|
||||||
* **Refine**: Group repeated access networks, confirm linked Discord nickname changes before mutation, and add DMG Games sponsorship attribution.
|
|
||||||
* **Extend**: Add shared Pino logging with credential redaction and actionable web and Discord runtime diagnostics.
|
|
||||||
* **Fix**: Build magic-link redirects from the configured public portal URL instead of the reverse proxy's internal request origin.
|
|
||||||
* **Verify**: Confirmed `v1.1.1` left all pre-existing `latest` digests unchanged while publishing versioned artifacts.
|
|
||||||
* **Refine**: Removed mutable `latest` publication so all deployable artifacts use explicit semantic versions.
|
|
||||||
* **Verify**: Confirmed the `v1.1.0` Discord bot image and matching web, migration, and Velocity artifacts.
|
|
||||||
* **Extend**: Added a releasable Discord bot image and a dependency-free web health endpoint for Kubernetes deployment.
|
|
||||||
* **Verify**: Confirmed the initial `v1.0.0` release, public Velocity JAR, and versioned and `latest` web and migration image manifests.
|
|
||||||
* **Create**: Added Gitea CI and semantic-release pipelines for downloadable Velocity JARs and versioned web and migration images.
|
|
||||||
* **Document**: Added container deployment order, artifact names, and required repository secrets.
|
|
||||||
* **Refine**: Corrected the Velocity Java and Gradle namespace to the repository owner's `games.dmg` reverse domain.
|
|
||||||
* **Create**: Established the OKF v0.1 [user-story index](index.md).
|
|
||||||
* **Document**: Captured the implemented player portal, Discord authentication, onboarding, account management, network intelligence, Velocity admission, auditing, administration, API error, and operational stories.
|
|
||||||
* **Governance**: Added repository agent guidance and automated OKF validation for story-driven development.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Enter the account portal through Discord
|
|
||||||
description: Direct visitors are guided to the configured Discord community and its account commands.
|
|
||||||
tags: [player, portal, discord, onboarding]
|
|
||||||
timestamp: 2026-08-01T22:34:31Z
|
|
||||||
story_id: US-001
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As a prospective player, I want the portal to direct me to the community Discord, so that I can begin registration through the trusted entry point.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] Given an unauthenticated visitor, when they open the portal, then they are told to run `/register` or `/account` in Discord.
|
|
||||||
- [x] Given a configured invite URL, when the visitor selects the join action, then the Discord invite opens in a new browser context.
|
|
||||||
- [x] Given a configured guild ID, when the visitor selects the app action, then a `discord://` guild link is opened.
|
|
||||||
- [x] Given an unauthenticated protected-page request, when authorization fails, then the visitor returns to the portal with prominent Discord instructions.
|
|
||||||
- [x] Every portal page credits Social Minecraft sponsorship by DMG Games and links to `https://dmg.games`.
|
|
||||||
- [x] Portal branding uses the SoMC Portal name and a dedicated favicon.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`apps/web/src/app/page.tsx`](../apps/web/src/app/page.tsx)
|
|
||||||
- [`apps/web/src/lib/auth/user-session.ts`](../apps/web/src/lib/auth/user-session.ts)
|
|
||||||
- [`apps/web/src/components/site-footer.tsx`](../apps/web/src/components/site-footer.tsx)
|
|
||||||
- [`apps/web/src/app/icon.svg`](../apps/web/src/app/icon.svg)
|
|
||||||
- Configuration: `DISCORD_GUILD_ID`, `DISCORD_INVITE_URL`
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
Covered by the Next.js production build and protected-route session checks.
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Discord magic-link authentication](us-002-discord-magic-link.md)
|
|
||||||
- [First-time onboarding](us-003-onboarding.md)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Authenticate with a Discord magic link
|
|
||||||
description: Discord users receive private single-use links that establish secure portal sessions.
|
|
||||||
tags: [player, discord, authentication, security]
|
|
||||||
timestamp: 2026-08-01T20:43:46Z
|
|
||||||
story_id: US-002
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As a Discord community member, I want `/register` and `/account` to issue a private sign-in link, so that I can access the portal without creating another password.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] Given the configured guild, when a user runs `/register` or `/account`, then the bot responds ephemerally with a private link.
|
|
||||||
- [x] Given a generated link, then the raw login token is never stored in PostgreSQL.
|
|
||||||
- [x] Given a login token, then it expires after ten minutes and can be consumed only once.
|
|
||||||
- [x] Given repeated link requests, then requests are rate limited per Discord user and older active links are invalidated.
|
|
||||||
- [x] Given a valid link, when it is consumed, then the Discord user is created or refreshed and a secure seven-day session is established.
|
|
||||||
- [x] Given a magic-link result behind a reverse proxy, then the browser is redirected through the configured public application URL rather than an internal container address.
|
|
||||||
- [x] Given an invalid, expired, or consumed link, then the user sees a safe recovery page instructing them to request another link.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`apps/discord-bot/src/index.ts`](../apps/discord-bot/src/index.ts)
|
|
||||||
- [`packages/auth/src/index.ts`](../packages/auth/src/index.ts)
|
|
||||||
- [`packages/database/src/auth-repository.ts`](../packages/database/src/auth-repository.ts)
|
|
||||||
- [`apps/web/src/app/auth/discord/route.ts`](../apps/web/src/app/auth/discord/route.ts)
|
|
||||||
- [`apps/web/src/lib/application-url.ts`](../apps/web/src/lib/application-url.ts)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
- [`packages/auth/test/magic-link.test.ts`](../packages/auth/test/magic-link.test.ts)
|
|
||||||
- [`apps/web/src/lib/application-url.test.ts`](../apps/web/src/lib/application-url.test.ts)
|
|
||||||
- Discord command and authentication workspaces pass TypeScript validation.
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Enter through Discord](us-001-discord-entry.md)
|
|
||||||
- [Preserve an audit trail](us-010-audit-events.md)
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Complete first-time onboarding
|
|
||||||
description: New users establish their preferred identity and first Minecraft account.
|
|
||||||
tags: [player, onboarding, minecraft, discord]
|
|
||||||
timestamp: 2026-08-01T18:43:58Z
|
|
||||||
story_id: US-003
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As a newly authenticated player, I want a guided setup flow, so that my preferred name, Minecraft identity, and Discord nickname are configured correctly.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] Given a new Discord user, when they enter the portal, then they receive a personalized welcome.
|
|
||||||
- [x] Given the first onboarding step, when the user enters a valid preferred name, then it is stored for their profile.
|
|
||||||
- [x] Given the Minecraft step, when a valid Java username is submitted from an allowed network, then it is verified and added as primary.
|
|
||||||
- [x] Given an unverifiable but syntactically valid username, then the user must explicitly confirm before continuing.
|
|
||||||
- [x] Given a name and primary account, then the expected Discord nickname is previewed before any guild update.
|
|
||||||
- [x] Given confirmation and a successful Discord update, then onboarding is marked complete and the dashboard opens.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`apps/web/src/app/welcome/page.tsx`](../apps/web/src/app/welcome/page.tsx)
|
|
||||||
- [`apps/web/src/app/welcome/minecraft/page.tsx`](../apps/web/src/app/welcome/minecraft/page.tsx)
|
|
||||||
- [`apps/web/src/app/welcome/discord/page.tsx`](../apps/web/src/app/welcome/discord/page.tsx)
|
|
||||||
- [`apps/web/src/app/welcome/actions.ts`](../apps/web/src/app/welcome/actions.ts)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
Onboarding routes are protected by database-backed sessions and included in production route generation.
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Validate Minecraft accounts](us-004-minecraft-validation.md)
|
|
||||||
- [Synchronize Discord nicknames](us-006-discord-nickname.md)
|
|
||||||
- [Block anonymized account additions](us-008-vpn-blocking.md)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Validate Minecraft accounts
|
|
||||||
description: Java Edition usernames resolve to canonical Mojang identities with controlled override behavior.
|
|
||||||
tags: [player, minecraft, mojang, identity]
|
|
||||||
timestamp: 2026-08-01T18:43:58Z
|
|
||||||
story_id: US-004
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As a player, I want submitted Minecraft usernames checked against Mojang, so that the server can identify my online-mode Java account reliably.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] Given a syntactically valid username, when it is submitted, then validation occurs server-side against the fixed Mojang endpoint.
|
|
||||||
- [x] Given a Mojang match, then the canonical username and compact UUID are stored.
|
|
||||||
- [x] Given no Mojang match, then the user or administrator must explicitly confirm an unverified override.
|
|
||||||
- [x] Given malformed input, then it cannot be stored even through an override.
|
|
||||||
- [x] Given an active UUID or case-insensitive username already registered, then another active registration is rejected.
|
|
||||||
- [x] Given a later online-mode game login for an unverified account, then its UUID can be safely backfilled after username matching.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`packages/minecraft/src/index.ts`](../packages/minecraft/src/index.ts)
|
|
||||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
|
||||||
- User and administrator account actions under [`apps/web/src/app`](../apps/web/src/app)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
- [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts)
|
|
||||||
- Database partial unique indexes preserve active identity invariants.
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [First-time onboarding](us-003-onboarding.md)
|
|
||||||
- [Velocity game admission](us-009-velocity-admission.md)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Manage linked accounts from the dashboard
|
|
||||||
description: Authenticated users maintain their profile and active Java Edition accounts.
|
|
||||||
tags: [player, dashboard, minecraft, profile]
|
|
||||||
timestamp: 2026-08-01T23:10:59Z
|
|
||||||
story_id: US-005
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As a registered player, I want to manage my profile and linked Minecraft accounts, so that my whitelist identity remains current.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] Given an authenticated user, then only their own profile, accounts, and IP observations are visible and mutable.
|
|
||||||
- [x] The user can update their preferred name.
|
|
||||||
- [x] The user can add Mojang-verified or explicitly confirmed accounts from an allowed network.
|
|
||||||
- [x] The user can soft-remove an active account.
|
|
||||||
- [x] The user can choose exactly one active primary account.
|
|
||||||
- [x] Removing a primary account promotes another active account when one exists.
|
|
||||||
- [x] Name, primary, and account-removal changes automatically synchronize the expected Discord nickname and report the result.
|
|
||||||
- [x] The dashboard shows recent portal and game IP observations with classification and available location.
|
|
||||||
- [x] The dashboard shows the user's Discord display name, username, guild nickname, and immutable Discord ID.
|
|
||||||
- [x] The dashboard shows the single effective access group and whether it grants Minecraft access.
|
|
||||||
- [x] The user can revoke the current session by signing out.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`apps/web/src/app/account/page.tsx`](../apps/web/src/app/account/page.tsx)
|
|
||||||
- [`apps/web/src/app/account/actions.ts`](../apps/web/src/app/account/actions.ts)
|
|
||||||
- [`apps/web/src/app/auth/actions.ts`](../apps/web/src/app/auth/actions.ts)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
Server actions verify the current session and constrain every account lookup by the authenticated user ID. Nickname result announcements are covered by [`apps/web/src/components/nickname-notice.test.tsx`](../apps/web/src/components/nickname-notice.test.tsx).
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Synchronize Discord nicknames](us-006-discord-nickname.md)
|
|
||||||
- [Enrich login IPs](us-007-ip-intelligence.md)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Keep Discord nicknames synchronized
|
|
||||||
description: Preferred names and primary Minecraft usernames determine community guild nicknames.
|
|
||||||
tags: [player, admin, discord, identity]
|
|
||||||
timestamp: 2026-08-01T23:10:59Z
|
|
||||||
story_id: US-006
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As a community member, I want my Discord nickname to reflect my preferred name and primary Minecraft account, so that other players can identify me consistently.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] Given a preferred name and primary account, then the nickname format is `First name (MinecraftUsername)`.
|
|
||||||
- [x] Given Discord's 32-character limit, then the preferred-name portion is shortened while preserving the Minecraft username.
|
|
||||||
- [x] Given no remaining Minecraft account, then synchronization uses `First name (TBD)`.
|
|
||||||
- [x] User name, first-account, primary, and account-removal changes synchronize the nickname automatically without a second confirmation step.
|
|
||||||
- [x] Successful synchronization shows the exact new nickname in a dismissible status notice.
|
|
||||||
- [x] Discord failures show an assertive error notice without falsely claiming synchronization completed.
|
|
||||||
- [x] Administrator name, primary, and primary-removal operations synchronize the nickname automatically.
|
|
||||||
- [x] A protected administrative retry action can synchronize the current desired nickname.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`packages/minecraft/src/index.ts`](../packages/minecraft/src/index.ts)
|
|
||||||
- [`apps/web/src/app/account/actions.ts`](../apps/web/src/app/account/actions.ts)
|
|
||||||
- [`apps/web/src/app/admin/(console)/users/actions.ts`](../apps/web/src/app/admin/%28console%29/users/actions.ts)
|
|
||||||
- [`apps/web/src/components/nickname-notice.tsx`](../apps/web/src/components/nickname-notice.tsx)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
- [`packages/minecraft/test/discord.test.ts`](../packages/minecraft/test/discord.test.ts)
|
|
||||||
- Nickname length and fallback behavior are covered in [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts).
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Manage linked accounts](us-005-user-dashboard.md)
|
|
||||||
- [Administer users](us-013-admin-user-management.md)
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Enrich portal and game login IPs
|
|
||||||
description: Login audit events include cached approximate location and network intelligence from ProxyCheck.io.
|
|
||||||
tags: [security, network, audit, proxycheck]
|
|
||||||
timestamp: 2026-08-01T22:04:17Z
|
|
||||||
story_id: US-007
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As an operator, I want portal and registered game logins enriched with network context, so that suspicious access can be investigated.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] Given a public login IP, then ProxyCheck can provide city, region, country, coordinates, timezone, ASN, provider, risk, and anonymity classification.
|
|
||||||
- [x] Results are cached in PostgreSQL for 48 hours by default.
|
|
||||||
- [x] Provider failures are cached briefly and do not deny portal or registered game login.
|
|
||||||
- [x] Private, loopback, reserved, documentation, and mapped-private addresses are never sent to ProxyCheck.
|
|
||||||
- [x] Forwarded web IP headers are ignored unless trusted-proxy handling is explicitly enabled.
|
|
||||||
- [x] Unknown game accounts do not trigger paid ProxyCheck lookups.
|
|
||||||
- [x] Login events and IP observations retain the available classification and approximate location.
|
|
||||||
- [x] Users and administrators can see available location and classification in audit views.
|
|
||||||
- [x] Repeated access observations are summarized by IPv4 /24 or IPv6 /64 network with counts, sources, and latest activity.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`packages/network/src/index.ts`](../packages/network/src/index.ts)
|
|
||||||
- [`apps/web/src/lib/ip-intelligence.ts`](../apps/web/src/lib/ip-intelligence.ts)
|
|
||||||
- [`apps/web/src/app/auth/discord/route.ts`](../apps/web/src/app/auth/discord/route.ts)
|
|
||||||
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
- [`packages/network/test/proxycheck.test.ts`](../packages/network/test/proxycheck.test.ts)
|
|
||||||
- [`packages/network/test/client-ip.test.ts`](../packages/network/test/client-ip.test.ts)
|
|
||||||
- [`packages/network/test/address-groups.test.ts`](../packages/network/test/address-groups.test.ts)
|
|
||||||
- [`apps/web/src/lib/access-address-groups.test.ts`](../apps/web/src/lib/access-address-groups.test.ts)
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Block anonymized additions](us-008-vpn-blocking.md)
|
|
||||||
- [Preserve an audit trail](us-010-audit-events.md)
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Block account additions from anonymized networks
|
|
||||||
description: User Minecraft-account additions fail closed for VPN, proxy, Tor, or unknown IP classifications.
|
|
||||||
tags: [security, vpn, proxy, minecraft]
|
|
||||||
timestamp: 2026-08-01T18:43:58Z
|
|
||||||
story_id: US-008
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As an operator, I want account additions blocked from anonymized networks, so that whitelist identities are established from attributable connections.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] VPN, proxy, and Tor classifications block user account additions.
|
|
||||||
- [x] Unknown or unavailable classification blocks additions rather than failing open.
|
|
||||||
- [x] Hosting-provider ranges can be blocked through deployment configuration.
|
|
||||||
- [x] Normal portal use and game login are not denied solely because intelligence is unavailable.
|
|
||||||
- [x] Blocked users receive a clear recovery message without provider internals.
|
|
||||||
- [x] Blocked and classification-unavailable attempts create distinct audit events with safe intelligence details.
|
|
||||||
- [x] Administrative account additions remain available as an authorized recovery path.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`apps/web/src/lib/ip-intelligence.ts`](../apps/web/src/lib/ip-intelligence.ts)
|
|
||||||
- [`apps/web/src/app/welcome/actions.ts`](../apps/web/src/app/welcome/actions.ts)
|
|
||||||
- [`apps/web/src/app/account/actions.ts`](../apps/web/src/app/account/actions.ts)
|
|
||||||
- Configuration: `PROXYCHECK_API_KEY`, `BLOCK_HOSTING_IPS`, `TRUST_PROXY`
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
The fail-closed classification policy and provider mappings are covered by [`packages/network/test/proxycheck.test.ts`](../packages/network/test/proxycheck.test.ts).
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Enrich login IPs](us-007-ip-intelligence.md)
|
|
||||||
- [Validate Minecraft accounts](us-004-minecraft-validation.md)
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Enforce registration at the Velocity proxy
|
|
||||||
description: Online-mode Java connections are admitted only after a fail-closed account-manager decision.
|
|
||||||
tags: [minecraft, velocity, whitelist, security]
|
|
||||||
timestamp: 2026-08-01T23:10:59Z
|
|
||||||
story_id: US-009
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As a registered player, I want the Velocity proxy to recognize my approved Java account, so that I can join while unknown identities are rejected.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] The plugin sends request ID, server ID, online-mode UUID, username, IP, and occurrence time.
|
|
||||||
- [x] Every request uses a high-entropy per-server bearer credential stored only as a hash by the service.
|
|
||||||
- [x] Requests outside the 45-second clock window are rejected.
|
|
||||||
- [x] Database-unique request IDs reject cross-instance replay attempts.
|
|
||||||
- [x] UUID matching is attempted before username fallback.
|
|
||||||
- [x] Username fallback applies only when the stored account has no UUID.
|
|
||||||
- [x] Successful fallback backfills UUID and canonical username.
|
|
||||||
- [x] Changed usernames are persisted and audited.
|
|
||||||
- [x] Registered players are allowed only when their single effective group has access enabled; explicit assignments override the default group.
|
|
||||||
- [x] Unknown players, group-disabled players, API failures, malformed responses, and unauthorized requests fail closed with registration guidance.
|
|
||||||
- [x] The plugin records the real Velocity connection IP and supports Java Edition online mode only.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`plugins/velocity`](../plugins/velocity)
|
|
||||||
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
|
||||||
- [`packages/contracts/src/index.ts`](../packages/contracts/src/index.ts)
|
|
||||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
- [`plugins/velocity/src/test/java/games/dmg/accountmanager/AccountManagerClientTest.java`](../plugins/velocity/src/test/java/games/dmg/accountmanager/AccountManagerClientTest.java)
|
|
||||||
- Shared request and response contracts are covered by [`packages/contracts/test/contracts.test.ts`](../packages/contracts/test/contracts.test.ts).
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Validate Minecraft accounts](us-004-minecraft-validation.md)
|
|
||||||
- [Standardize API errors](us-014-problem-details.md)
|
|
||||||
- [Control Minecraft admission with groups](us-017-group-access.md)
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Preserve a CloudEvents-style audit trail
|
|
||||||
description: Authentication, UI, account, Discord, network, and game actions create searchable immutable-style events.
|
|
||||||
tags: [audit, cloudevents, security, events]
|
|
||||||
timestamp: 2026-08-01T23:10:59Z
|
|
||||||
story_id: US-010
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As an operator, I want security and identity activity recorded consistently, so that incidents and account changes can be reconstructed and later published to Kafka.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] Events preserve CloudEvents-style ID, specification version, source, type, subject, time, content type, and JSON data.
|
|
||||||
- [x] Events can include user actor, IP address, and correlation ID.
|
|
||||||
- [x] Portal access, magic-link creation and consumption, account changes, nickname changes, VPN blocks, and game decisions are recorded.
|
|
||||||
- [x] Username changes learned from Velocity create their own event.
|
|
||||||
- [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] Every listed event links to a detail page showing its complete CloudEvents envelope and formatted JSON data.
|
|
||||||
- [x] `published_at` reserves an outbox path for future Kafka publishing.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`packages/database/src/events.ts`](../packages/database/src/events.ts)
|
|
||||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
|
||||||
- [`apps/web/src/lib/audit.ts`](../apps/web/src/lib/audit.ts)
|
|
||||||
- [`apps/web/src/app/admin/(console)/events/page.tsx`](../apps/web/src/app/admin/%28console%29/events/page.tsx)
|
|
||||||
- [`apps/web/src/app/admin/(console)/events/[eventId]/page.tsx`](../apps/web/src/app/admin/%28console%29/events/%5BeventId%5D/page.tsx)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
The shared CloudEvent contract is covered by [`packages/contracts/test/contracts.test.ts`](../packages/contracts/test/contracts.test.ts), and event-producing routes pass full type and production-build validation.
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Enrich login IPs](us-007-ip-intelligence.md)
|
|
||||||
- [Administer users](us-013-admin-user-management.md)
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Authenticate administrators with Keycloak SSO
|
|
||||||
description: The operator console requires a Keycloak identity with the configured administrator role.
|
|
||||||
tags: [admin, keycloak, oidc, authentication]
|
|
||||||
timestamp: 2026-08-01T18:43:58Z
|
|
||||||
story_id: US-011
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As an administrator, I want to authenticate through organizational SSO, so that privileged operations use centrally managed identities and roles.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] Admin authentication uses Keycloak OpenID Connect authorization code flow.
|
|
||||||
- [x] Sign-in is denied when the configured required role is absent.
|
|
||||||
- [x] Realm and configured-client roles are extracted from fresh Keycloak tokens.
|
|
||||||
- [x] Admin console layouts redirect unauthenticated or unauthorized users to the admin login page.
|
|
||||||
- [x] Every privileged server action independently rechecks the admin session and role.
|
|
||||||
- [x] Admin sessions use signed JWT behavior managed by NextAuth.
|
|
||||||
- [x] Administrators can sign out and return to the restricted login page.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`apps/web/src/lib/auth/admin-auth.ts`](../apps/web/src/lib/auth/admin-auth.ts)
|
|
||||||
- [`apps/web/src/lib/auth/require-admin.ts`](../apps/web/src/lib/auth/require-admin.ts)
|
|
||||||
- [`apps/web/src/app/admin`](../apps/web/src/app/admin)
|
|
||||||
- [`docs/admin-oidc-keycloak-setup.md`](../docs/admin-oidc-keycloak-setup.md)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
OIDC role extraction is covered by [`packages/auth/test/oidc-roles.test.ts`](../packages/auth/test/oidc-roles.test.ts).
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Operate settings and audit views](us-012-admin-operations.md)
|
|
||||||
- [Administer users](us-013-admin-user-management.md)
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Operate settings and audit views
|
|
||||||
description: Authorized administrators control server messaging and investigate recent platform events.
|
|
||||||
tags: [admin, settings, audit, operations]
|
|
||||||
timestamp: 2026-08-01T22:34:31Z
|
|
||||||
story_id: US-012
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As an administrator, I want operational settings and audit visibility, so that I can manage player guidance and investigate activity.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] The admin console shows the deployment-managed Discord guild ID and linked invite URL.
|
|
||||||
- [x] An authorized administrator can update the denied-player registration message.
|
|
||||||
- [x] Settings actions validate message length server-side.
|
|
||||||
- [x] Administrators can browse the latest 100 events.
|
|
||||||
- [x] Event views show type, subject, IP, classification, and approximate location when available.
|
|
||||||
- [x] Admin console access itself creates an audit event with the SSO identity.
|
|
||||||
- [x] Settings, users, and events are linked from the shared admin navigation.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`apps/web/src/app/admin/(console)/page.tsx`](../apps/web/src/app/admin/%28console%29/page.tsx)
|
|
||||||
- [`apps/web/src/app/admin/(console)/actions.ts`](../apps/web/src/app/admin/%28console%29/actions.ts)
|
|
||||||
- [`apps/web/src/app/admin/(console)/events/page.tsx`](../apps/web/src/app/admin/%28console%29/events/page.tsx)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
Admin routes are dynamic, role-protected, linted, and included in every production build.
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Administrator SSO](us-011-admin-sso.md)
|
|
||||||
- [Preserve an audit trail](us-010-audit-events.md)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Manage users as an administrator
|
|
||||||
description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames.
|
|
||||||
tags: [admin, users, minecraft, discord]
|
|
||||||
timestamp: 2026-08-01T22:34:31Z
|
|
||||||
story_id: US-013
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As an administrator, I want to manage a user's identity and Minecraft accounts, so that support issues can be resolved without direct database access.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [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] A user detail view shows Discord display name, username, guild nickname, immutable ID, active accounts, groups, recent events, and recent IP observations.
|
|
||||||
- [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 remove an account only after a visible confirmation step.
|
|
||||||
- [x] Removing a primary account selects a replacement or falls back to the preferred-name nickname.
|
|
||||||
- [x] Administrators can set a new primary account and automatically update Discord.
|
|
||||||
- [x] Every action rechecks role and account ownership and records the acting administrator.
|
|
||||||
- [x] Discord failures do not falsely persist the requested name, primary, or removal change.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`apps/web/src/app/admin/(console)/users/page.tsx`](../apps/web/src/app/admin/%28console%29/users/page.tsx)
|
|
||||||
- [`apps/web/src/app/admin/(console)/users/[userId]/page.tsx`](../apps/web/src/app/admin/%28console%29/users/%5BuserId%5D/page.tsx)
|
|
||||||
- [`apps/web/src/app/admin/(console)/users/actions.ts`](../apps/web/src/app/admin/%28console%29/users/actions.ts)
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Administrator SSO](us-011-admin-sso.md)
|
|
||||||
- [Synchronize Discord nicknames](us-006-discord-nickname.md)
|
|
||||||
- [Control Minecraft admission with groups](us-017-group-access.md)
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Receive standardized API errors
|
|
||||||
description: Application-owned HTTP APIs expose RFC 9457 Problem Details matching game-ingest-server conventions.
|
|
||||||
tags: [api, errors, rfc9457, contracts]
|
|
||||||
timestamp: 2026-08-01T18:43:58Z
|
|
||||||
story_id: US-014
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As an API consumer, I want errors returned as standardized Problem Details, so that failures can be handled consistently across game services.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] Error responses use `application/problem+json`.
|
|
||||||
- [x] Responses require `type`, `title`, and `status` and optionally include `detail`, `instance`, and `extensions`.
|
|
||||||
- [x] Invalid Velocity payloads include machine-readable Zod issues under `extensions.issues`.
|
|
||||||
- [x] Missing credentials, expired requests, replays, unsupported media, unsupported methods, unknown routes, and service failures have stable `urn:error:*` types.
|
|
||||||
- [x] Unexpected application-owned Velocity errors are converted to safe `503` problems without internal details.
|
|
||||||
- [x] A normal whitelist denial remains a successful `200` authorization decision.
|
|
||||||
- [x] Browser form redirects remain accessible HTML flows and OAuth protocol responses remain owned by NextAuth.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`packages/contracts/src/index.ts`](../packages/contracts/src/index.ts)
|
|
||||||
- [`apps/web/src/lib/problem-response.ts`](../apps/web/src/lib/problem-response.ts)
|
|
||||||
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
|
||||||
- [`docs/api-errors.md`](../docs/api-errors.md)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
- [`packages/contracts/test/problem-details.test.ts`](../packages/contracts/test/problem-details.test.ts)
|
|
||||||
- [`apps/web/src/app/api/velocity/access/route.test.ts`](../apps/web/src/app/api/velocity/access/route.test.ts)
|
|
||||||
- Unknown-route and response-helper tests in the web workspace.
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Velocity game admission](us-009-velocity-admission.md)
|
|
||||||
- [Deploy and operate securely](us-015-platform-operations.md)
|
|
||||||
|
|
||||||
# Citations
|
|
||||||
|
|
||||||
[1] [RFC 9457 — Problem Details for HTTP APIs](https://www.rfc-editor.org/rfc/rfc9457)
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Deploy and operate the platform securely
|
|
||||||
description: Operators have repeatable builds, migrations, credential provisioning, configuration, and security checks.
|
|
||||||
tags: [operations, security, database, deployment]
|
|
||||||
timestamp: 2026-08-01T23:10:59Z
|
|
||||||
story_id: US-015
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As a platform operator, I want reproducible deployment and security controls, so that the portal, bot, database, and proxy can be operated safely.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] The repository is an npm TypeScript workspace with separate web, bot, contract, database, network, and Minecraft modules.
|
|
||||||
- [x] PostgreSQL is available through Docker Compose for local use.
|
|
||||||
- [x] Drizzle changes use generated, versioned migrations rather than schema push.
|
|
||||||
- [x] Velocity credentials can be provisioned or rotated with a one-time-displayed token stored only as a hash.
|
|
||||||
- [x] The Velocity Gradle wrapper produces a tested shaded JAR.
|
|
||||||
- [x] Environment examples document database, Keycloak, Discord, trusted proxy, and ProxyCheck settings without secrets.
|
|
||||||
- [x] The web application sets CSP, framing, MIME, referrer, and permissions headers.
|
|
||||||
- [x] Database-backed user and administrator pages render as dynamic React Server Components with server-side data access.
|
|
||||||
- [x] Core pages provide keyboard focus indication, a skip link, labelled controls, table semantics, live status messaging, sufficient text contrast, and reduced-motion support.
|
|
||||||
- [x] The web runtime provides a dependency-free health endpoint for orchestration probes.
|
|
||||||
- [x] Web and Discord bot runtimes emit structured Pino logs with credential-field redaction and safe operational context.
|
|
||||||
- [x] npm dependency audit and Semgrep security review complete without findings at the last verified change.
|
|
||||||
- [x] Architecture, Keycloak, API error, security, bot, and Velocity operating documentation is available.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`package.json`](../package.json)
|
|
||||||
- [`compose.yml`](../compose.yml)
|
|
||||||
- [`packages/database/drizzle`](../packages/database/drizzle)
|
|
||||||
- [`packages/database/scripts/create-plugin-credential.ts`](../packages/database/scripts/create-plugin-credential.ts)
|
|
||||||
- [`plugins/velocity/build.gradle.kts`](../plugins/velocity/build.gradle.kts)
|
|
||||||
- [`apps/web/next.config.ts`](../apps/web/next.config.ts)
|
|
||||||
- [`packages/logging/src/index.ts`](../packages/logging/src/index.ts)
|
|
||||||
- [`docs/accessibility.md`](../docs/accessibility.md)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
Use `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, `npm run velocity:build`, `npm audit`, and `npm run design:validate`. Structured logging redaction is covered by [`packages/logging/test/logger.test.ts`](../packages/logging/test/logger.test.ts).
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Administrator SSO](us-011-admin-sso.md)
|
|
||||||
- [Standardize API errors](us-014-problem-details.md)
|
|
||||||
- [Build and publish versioned releases](us-016-automated-releases.md)
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Build and publish versioned releases
|
|
||||||
description: Gitea Actions validate every change and publish semantically versioned Velocity and container artifacts.
|
|
||||||
tags: [operations, ci, release, velocity, docker]
|
|
||||||
timestamp: 2026-08-01T20:05:49Z
|
|
||||||
story_id: US-016
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As a platform operator, I want automated validation and semantic releases, so that deployable web, migration, and Velocity artifacts are reproducible and downloadable.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] Pushes and pull requests run OKF validation, linting, type checks, tests, the web build, and the Velocity build.
|
|
||||||
- [x] Pull requests validate conventional commit messages.
|
|
||||||
- [x] CI uploads the development Velocity JAR as a workflow artifact.
|
|
||||||
- [x] Main-branch conventional commits determine the next semantic version and create a `vMAJOR.MINOR.PATCH` tag.
|
|
||||||
- [x] A release build embeds the semantic version in the Velocity plugin and JAR filename.
|
|
||||||
- [x] A public Gitea release exposes the versioned Velocity JAR as a downloadable asset.
|
|
||||||
- [x] Releases publish semantically versioned web runtime images to the Gitea registry.
|
|
||||||
- [x] Releases publish semantically versioned Discord bot images to the Gitea registry.
|
|
||||||
- [x] Releases publish semantically versioned migration images that run versioned Drizzle migrations.
|
|
||||||
- [x] Releases do not publish mutable container tags such as `latest`.
|
|
||||||
- [x] Runtime containers use unprivileged users and exclude development source and secrets where practical.
|
|
||||||
- [x] Operators are told which repository secrets must be configured before the first push.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [CI workflow](../.gitea/workflows/ci.yml)
|
|
||||||
- [Release workflow](../.gitea/workflows/release.yml)
|
|
||||||
- [Semantic Release configuration](../.releaserc)
|
|
||||||
- [Web and migration Docker targets](../Dockerfile)
|
|
||||||
- [Velocity Gradle build](../plugins/velocity/build.gradle.kts)
|
|
||||||
- [Release and deployment guide](../docs/releases.md)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
Local OKF, lint, typecheck, test, Next.js build, and versioned Velocity JAR checks pass. Initial Gitea CI and release runs succeeded. Release `v1.0.0` provides a publicly downloadable JAR whose Velocity metadata reports `1.0.0`. Registry manifests were resolved for the published semantic-version tags. Release `v1.1.0` also publishes resolvable versioned web, Discord bot, and migration manifests and a public Velocity JAR whose metadata reports `1.1.0`. Release `v1.1.1` published immutable semantic-version tags only; prior `latest` digests remained unchanged. Pull-request commitlint configuration is present; its conditional execution will be exercised by the first pull request.
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Deploy and operate securely](us-015-platform-operations.md)
|
|
||||||
- [Velocity game admission](us-009-velocity-admission.md)
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Control Minecraft admission with groups
|
|
||||||
description: Administrators assign users to groups and enable Minecraft access through explicit group policy.
|
|
||||||
tags: [admin, groups, authorization, velocity, security]
|
|
||||||
timestamp: 2026-08-01T23:10:59Z
|
|
||||||
story_id: US-017
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As an administrator, I want to organize registered users into access groups, so that server admission can be enabled for selected communities while remaining off by default.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] A registered user can have at most one explicit group assignment.
|
|
||||||
- [x] Users without an explicit assignment fall back to the protected `everyone` group.
|
|
||||||
- [x] The `everyone` group remains created with Minecraft access disabled.
|
|
||||||
- [x] Administrators can create groups with access disabled by default and move users between groups.
|
|
||||||
- [x] Administrators can enable or disable Minecraft admission for each group.
|
|
||||||
- [x] Admission follows only the user's effective group; default and explicit-group access are never combined.
|
|
||||||
- [x] Administrators can delete non-default groups, returning affected users to `everyone`.
|
|
||||||
- [x] The protected default group cannot be deleted.
|
|
||||||
- [x] Group creation, membership, and access-policy changes are audited.
|
|
||||||
- [x] Users and administrators can inspect the user's single effective group assignment.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
|
||||||
- [`packages/database/drizzle/0002_simple_queen_noir.sql`](../packages/database/drizzle/0002_simple_queen_noir.sql)
|
|
||||||
- [`packages/database/drizzle/0003_smiling_silver_samurai.sql`](../packages/database/drizzle/0003_smiling_silver_samurai.sql)
|
|
||||||
- [`apps/web/src/app/admin/(console)/groups/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/page.tsx)
|
|
||||||
- [`apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx`](../apps/web/src/app/admin/%28console%29/groups/%5BgroupId%5D/page.tsx)
|
|
||||||
- [`apps/web/src/app/api/velocity/access/route.ts`](../apps/web/src/app/api/velocity/access/route.ts)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
- [`packages/auth/test/group-access.test.ts`](../packages/auth/test/group-access.test.ts)
|
|
||||||
- Drizzle migration generation, TypeScript validation, tests, lint, and the production build must pass.
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Enforce registration at Velocity](us-009-velocity-admission.md)
|
|
||||||
- [Manage users as an administrator](us-013-admin-user-management.md)
|
|
||||||
- [Preserve an audit trail](us-010-audit-events.md)
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
---
|
|
||||||
type: User Story
|
|
||||||
title: Monitor community account activity
|
|
||||||
description: Administrators use a server-rendered dashboard to review registrations, monthly activity, denials, and risky networks.
|
|
||||||
tags: [admin, dashboard, metrics, security, ssr]
|
|
||||||
timestamp: 2026-08-01T23:10:59Z
|
|
||||||
story_id: US-018
|
|
||||||
status: verified
|
|
||||||
---
|
|
||||||
|
|
||||||
# User Story
|
|
||||||
|
|
||||||
As an administrator, I want an operational dashboard of account and game activity, so that I can understand community growth and quickly investigate access risks.
|
|
||||||
|
|
||||||
# Acceptance Criteria
|
|
||||||
|
|
||||||
- [x] The administrator landing page is a dashboard rather than a settings form.
|
|
||||||
- [x] The dashboard graphs new registered users by UTC day for the previous 14 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] The dashboard shows login denials from the previous 24 hours.
|
|
||||||
- [x] Recent VPN, proxy, and Tor observations link to affected user records.
|
|
||||||
- [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] Deployment-managed guild settings and denial messaging remain available on a dedicated settings page.
|
|
||||||
|
|
||||||
# Implementation
|
|
||||||
|
|
||||||
- [`apps/web/src/app/admin/(console)/page.tsx`](../apps/web/src/app/admin/%28console%29/page.tsx)
|
|
||||||
- [`apps/web/src/app/admin/(console)/settings/page.tsx`](../apps/web/src/app/admin/%28console%29/settings/page.tsx)
|
|
||||||
- [`apps/web/src/lib/admin-metrics.ts`](../apps/web/src/lib/admin-metrics.ts)
|
|
||||||
|
|
||||||
# Validation
|
|
||||||
|
|
||||||
- Missing-day chart behavior is covered by [`apps/web/src/lib/admin-metrics.test.ts`](../apps/web/src/lib/admin-metrics.test.ts).
|
|
||||||
- The Next.js production build reports the dashboard and database-backed console pages as dynamic server-rendered routes.
|
|
||||||
|
|
||||||
# Related Stories
|
|
||||||
|
|
||||||
- [Preserve a CloudEvents-style audit trail](us-010-audit-events.md)
|
|
||||||
- [Deploy and operate the platform securely](us-015-platform-operations.md)
|
|
||||||
- [Block anonymized account additions](us-008-vpn-blocking.md)
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Accessibility review
|
# Accessibility review
|
||||||
|
|
||||||
Review date: 2026-08-01
|
Review date: 2026-08-02
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -12,12 +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.
|
||||||
|
- 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.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user