Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9116107917 | ||
|
|
b7c0083647 | ||
|
|
b88097c15a | ||
|
|
19a5d04178 | ||
|
|
86c87153b4 | ||
|
|
5e693e2cdd | ||
|
|
9440c651b6 | ||
|
|
ccb44fa253 | ||
|
|
cee0378f8f |
@@ -24,3 +24,6 @@ IP_INTELLIGENCE_PROVIDER=proxycheck
|
|||||||
PROXYCHECK_API_KEY=
|
PROXYCHECK_API_KEY=
|
||||||
IP_INTELLIGENCE_CACHE_HOURS=48
|
IP_INTELLIGENCE_CACHE_HOURS=48
|
||||||
BLOCK_HOSTING_IPS=false
|
BLOCK_HOSTING_IPS=false
|
||||||
|
|
||||||
|
# Structured Pino logging
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|||||||
@@ -130,10 +130,8 @@ jobs:
|
|||||||
--target runner \
|
--target runner \
|
||||||
--build-arg VERSION="$VERSION" \
|
--build-arg VERSION="$VERSION" \
|
||||||
-t "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}" \
|
-t "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}" \
|
||||||
-t git.garvis.dev/dmg/minecraft-account-manager:latest \
|
|
||||||
.
|
.
|
||||||
docker push "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}"
|
docker push "git.garvis.dev/dmg/minecraft-account-manager:${VERSION}"
|
||||||
docker push git.garvis.dev/dmg/minecraft-account-manager:latest
|
|
||||||
|
|
||||||
- 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'
|
||||||
@@ -145,10 +143,8 @@ jobs:
|
|||||||
--target bot \
|
--target bot \
|
||||||
--build-arg VERSION="$VERSION" \
|
--build-arg VERSION="$VERSION" \
|
||||||
-t "git.garvis.dev/dmg/minecraft-account-manager-bot:${VERSION}" \
|
-t "git.garvis.dev/dmg/minecraft-account-manager-bot:${VERSION}" \
|
||||||
-t git.garvis.dev/dmg/minecraft-account-manager-bot:latest \
|
|
||||||
.
|
.
|
||||||
docker push "git.garvis.dev/dmg/minecraft-account-manager-bot:${VERSION}"
|
docker push "git.garvis.dev/dmg/minecraft-account-manager-bot:${VERSION}"
|
||||||
docker push git.garvis.dev/dmg/minecraft-account-manager-bot:latest
|
|
||||||
|
|
||||||
- name: Build and push migration image
|
- name: Build and push migration image
|
||||||
if: steps.release.outputs.created == 'true'
|
if: steps.release.outputs.created == 'true'
|
||||||
@@ -160,10 +156,8 @@ jobs:
|
|||||||
--target migrate \
|
--target migrate \
|
||||||
--build-arg VERSION="$VERSION" \
|
--build-arg VERSION="$VERSION" \
|
||||||
-t "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}" \
|
-t "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}" \
|
||||||
-t git.garvis.dev/dmg/minecraft-account-manager-migrate:latest \
|
|
||||||
.
|
.
|
||||||
docker push "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}"
|
docker push "git.garvis.dev/dmg/minecraft-account-manager-migrate:${VERSION}"
|
||||||
docker push git.garvis.dev/dmg/minecraft-account-manager-migrate:latest
|
|
||||||
|
|
||||||
- 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'
|
||||||
|
|||||||
+4
-1
@@ -10,6 +10,7 @@ COPY apps/discord-bot/package.json ./apps/discord-bot/package.json
|
|||||||
COPY packages/auth/package.json ./packages/auth/package.json
|
COPY packages/auth/package.json ./packages/auth/package.json
|
||||||
COPY packages/contracts/package.json ./packages/contracts/package.json
|
COPY packages/contracts/package.json ./packages/contracts/package.json
|
||||||
COPY packages/database/package.json ./packages/database/package.json
|
COPY packages/database/package.json ./packages/database/package.json
|
||||||
|
COPY packages/logging/package.json ./packages/logging/package.json
|
||||||
COPY packages/minecraft/package.json ./packages/minecraft/package.json
|
COPY packages/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
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
@@ -25,6 +26,7 @@ LABEL org.opencontainers.image.title="Minecraft Account Manager" \
|
|||||||
org.opencontainers.image.source="https://git.garvis.dev/dmg/minecraft-account-manager"
|
org.opencontainers.image.source="https://git.garvis.dev/dmg/minecraft-account-manager"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
|
APP_VERSION=${VERSION} \
|
||||||
HOSTNAME=0.0.0.0 \
|
HOSTNAME=0.0.0.0 \
|
||||||
PORT=3000
|
PORT=3000
|
||||||
RUN addgroup --system app && adduser --system --ingroup app app
|
RUN addgroup --system app && adduser --system --ingroup app app
|
||||||
@@ -40,7 +42,8 @@ LABEL org.opencontainers.image.title="Minecraft Account Manager Discord Bot" \
|
|||||||
org.opencontainers.image.version="${VERSION}" \
|
org.opencontainers.image.version="${VERSION}" \
|
||||||
org.opencontainers.image.source="https://git.garvis.dev/dmg/minecraft-account-manager"
|
org.opencontainers.image.source="https://git.garvis.dev/dmg/minecraft-account-manager"
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production \
|
||||||
|
APP_VERSION=${VERSION}
|
||||||
RUN addgroup --system app && adduser --system --ingroup app app
|
RUN addgroup --system app && adduser --system --ingroup app app
|
||||||
COPY --chown=app:app package.json package-lock.json tsconfig.base.json ./
|
COPY --chown=app:app package.json package-lock.json tsconfig.base.json ./
|
||||||
COPY --chown=app:app apps/discord-bot ./apps/discord-bot
|
COPY --chown=app:app apps/discord-bot ./apps/discord-bot
|
||||||
|
|||||||
@@ -76,11 +76,12 @@ 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, primary-account changes, and Discord nickname synchronization
|
- Admin user search, account management, event exploration, operational metrics, an open-data user-location world map, and automatic Discord nickname synchronization
|
||||||
|
- Exclusive group admission: unassigned users fall back to protected `everyone`, and only the effective group's access setting applies
|
||||||
- Deployment-managed Discord guild ID and invite URL
|
- 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
|
||||||
- ProxyCheck.io geolocation and VPN/proxy/Tor detection with a 48-hour PostgreSQL cache
|
- ProxyCheck.io geolocation and VPN/proxy/Tor detection with a 48-hour PostgreSQL cache
|
||||||
|
|
||||||
See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities, [`docs/api-errors.md`](docs/api-errors.md) for the RFC 9457 API error contract, and [`docs/security-review.md`](docs/security-review.md) for implemented controls and production requirements.
|
See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities, [`docs/api-errors.md`](docs/api-errors.md) for the RFC 9457 API error contract, and [`docs/security-review.md`](docs/security-review.md) for implemented controls and production requirements, and [`docs/accessibility.md`](docs/accessibility.md) for the WCAG-oriented interface review.
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ APP_URL=http://localhost:3000
|
|||||||
DISCORD_BOT_TOKEN=
|
DISCORD_BOT_TOKEN=
|
||||||
DISCORD_APPLICATION_ID=
|
DISCORD_APPLICATION_ID=
|
||||||
DISCORD_GUILD_ID=
|
DISCORD_GUILD_ID=
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@minecraft-account-manager/auth": "*",
|
"@minecraft-account-manager/auth": "*",
|
||||||
"@minecraft-account-manager/contracts": "*",
|
"@minecraft-account-manager/contracts": "*",
|
||||||
"@minecraft-account-manager/database": "*",
|
"@minecraft-account-manager/database": "*",
|
||||||
|
"@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"
|
||||||
|
|||||||
@@ -2,10 +2,22 @@ import "dotenv/config";
|
|||||||
import { REST, Routes } from "discord.js";
|
import { REST, Routes } from "discord.js";
|
||||||
import { commands } from "./commands";
|
import { commands } from "./commands";
|
||||||
import { requiredEnvironment } from "./config";
|
import { requiredEnvironment } from "./config";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
|
||||||
const token = requiredEnvironment("DISCORD_BOT_TOKEN");
|
const token = requiredEnvironment("DISCORD_BOT_TOKEN");
|
||||||
const applicationId = requiredEnvironment("DISCORD_APPLICATION_ID");
|
const applicationId = requiredEnvironment("DISCORD_APPLICATION_ID");
|
||||||
const rest = new REST({ version: "10" }).setToken(token);
|
const rest = new REST({ version: "10" }).setToken(token);
|
||||||
|
|
||||||
await rest.put(Routes.applicationCommands(applicationId), { body: commands });
|
try {
|
||||||
console.log(`Deployed ${commands.length} global Discord commands.`);
|
await rest.put(Routes.applicationCommands(applicationId), { body: commands });
|
||||||
|
logger.info(
|
||||||
|
{ event: "discord.commands_deployed", commandCount: commands.length },
|
||||||
|
"Deployed global Discord commands",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.fatal(
|
||||||
|
{ err: error, event: "discord.commands_deploy_failed" },
|
||||||
|
"Failed to deploy global Discord commands",
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
GatewayIntentBits,
|
GatewayIntentBits,
|
||||||
} from "discord.js";
|
} from "discord.js";
|
||||||
import { commandNames, requiredEnvironment } from "./config";
|
import { commandNames, requiredEnvironment } from "./config";
|
||||||
|
import { logger } from "./logger";
|
||||||
|
|
||||||
const token = requiredEnvironment("DISCORD_BOT_TOKEN");
|
const token = requiredEnvironment("DISCORD_BOT_TOKEN");
|
||||||
const appUrl = requiredEnvironment("APP_URL");
|
const appUrl = requiredEnvironment("APP_URL");
|
||||||
@@ -24,7 +25,10 @@ const authRepository = createAuthRepository(db);
|
|||||||
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
|
||||||
|
|
||||||
client.once(Events.ClientReady, (readyClient) => {
|
client.once(Events.ClientReady, (readyClient) => {
|
||||||
console.log(`Discord bot ready as ${readyClient.user.tag}`);
|
logger.info(
|
||||||
|
{ event: "discord.ready", botUserId: readyClient.user.id, botUsername: readyClient.user.username },
|
||||||
|
"Discord bot is ready",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
client.on(Events.InteractionCreate, async (interaction) => {
|
client.on(Events.InteractionCreate, async (interaction) => {
|
||||||
@@ -71,9 +75,17 @@ client.on(Events.InteractionCreate, async (interaction) => {
|
|||||||
await interaction.editReply("Please wait 30 seconds before requesting another private account link.");
|
await interaction.editReply("Please wait 30 seconds before requesting another private account link.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
console.error("Failed to create Discord account link", error);
|
logger.error(
|
||||||
|
{ err: error, event: "discord.magic_link_failed", command: interaction.commandName },
|
||||||
|
"Failed to create Discord account link",
|
||||||
|
);
|
||||||
await interaction.editReply("I could not create an account link. Please try again shortly.");
|
await interaction.editReply("I could not create an account link. Please try again shortly.");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await client.login(token);
|
try {
|
||||||
|
await client.login(token);
|
||||||
|
} catch (error) {
|
||||||
|
logger.fatal({ err: error, event: "discord.login_failed" }, "Discord bot login failed");
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { createLogger } from "@minecraft-account-manager/logging";
|
||||||
|
|
||||||
|
export const logger = createLogger("minecraft-account-manager-discord-bot");
|
||||||
@@ -34,6 +34,7 @@ const nextConfig: NextConfig = {
|
|||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
"@minecraft-account-manager/contracts",
|
"@minecraft-account-manager/contracts",
|
||||||
"@minecraft-account-manager/database",
|
"@minecraft-account-manager/database",
|
||||||
|
"@minecraft-account-manager/logging",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -14,19 +14,25 @@
|
|||||||
"@minecraft-account-manager/auth": "*",
|
"@minecraft-account-manager/auth": "*",
|
||||||
"@minecraft-account-manager/contracts": "*",
|
"@minecraft-account-manager/contracts": "*",
|
||||||
"@minecraft-account-manager/database": "*",
|
"@minecraft-account-manager/database": "*",
|
||||||
|
"@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",
|
||||||
"next": "^16.2.1",
|
"next": "^16.2.1",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"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",
|
||||||
|
"@types/d3-geo": "^3.1.1",
|
||||||
"@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",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
|
|||||||
@@ -1,25 +1,75 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { formatDiscordNickname, lookupJavaProfile, updateGuildNickname } from "@minecraft-account-manager/minecraft";
|
|
||||||
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
|
import { minecraftAccounts, users } from "@minecraft-account-manager/database";
|
||||||
|
import { formatManagedDiscordNickname, lookupJavaProfile, updateGuildNickname } from "@minecraft-account-manager/minecraft";
|
||||||
import { and, eq, isNull, ne } from "drizzle-orm";
|
import { and, eq, isNull, ne } from "drizzle-orm";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { recordUserEvent } from "@/lib/audit";
|
import { recordUserEvent } from "@/lib/audit";
|
||||||
import { db } from "@/lib/database";
|
|
||||||
import { requireCurrentUser } from "@/lib/auth/user-session";
|
import { requireCurrentUser } from "@/lib/auth/user-session";
|
||||||
|
import { db } from "@/lib/database";
|
||||||
import { checkAccountAdditionNetwork, toAuditIpData } from "@/lib/ip-intelligence";
|
import { checkAccountAdditionNetwork, toAuditIpData } from "@/lib/ip-intelligence";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
|
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/;
|
||||||
|
|
||||||
|
type CurrentUser = Awaited<ReturnType<typeof requireCurrentUser>>;
|
||||||
|
|
||||||
|
type NicknameSyncResult = "updated" | "not-configured" | "failed";
|
||||||
|
|
||||||
|
async function synchronizeNickname(user: CurrentUser, nickname: string, operation: string): Promise<NicknameSyncResult> {
|
||||||
|
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
||||||
|
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
|
||||||
|
if (!guildId || !botToken) return "not-configured";
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateGuildNickname({ guildId, discordUserId: user.discordUserId, nickname, botToken });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ err: error, event: "account.discord_nickname_update_failed", operation },
|
||||||
|
"Failed to update the Discord guild nickname",
|
||||||
|
);
|
||||||
|
return "failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
|
||||||
|
nickname,
|
||||||
|
operation,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ err: error, event: "account.discord_nickname_audit_failed", operation },
|
||||||
|
"Discord nickname updated but its audit event could not be recorded",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return "updated";
|
||||||
|
}
|
||||||
|
|
||||||
|
function nicknameResultUrl(nickname: string, synchronization: NicknameSyncResult, additionalQuery?: string) {
|
||||||
|
const result = synchronization === "updated"
|
||||||
|
? `nicknameUpdated=${encodeURIComponent(nickname)}`
|
||||||
|
: `error=${synchronization === "not-configured" ? "nickname-not-configured" : "nickname-update-failed"}&nicknameExpected=${encodeURIComponent(nickname)}`;
|
||||||
|
return `/account?${additionalQuery ? `${additionalQuery}&` : ""}${result}`;
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateFirstName(formData: FormData) {
|
export async function updateFirstName(formData: FormData) {
|
||||||
const user = await requireCurrentUser();
|
const user = await requireCurrentUser();
|
||||||
const firstName = String(formData.get("firstName") ?? "").trim();
|
const firstName = String(formData.get("firstName") ?? "").trim();
|
||||||
if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) {
|
if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) {
|
||||||
redirect("/account?error=invalid-name");
|
redirect("/account?error=invalid-name");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [primary] = await db
|
||||||
|
.select({ username: minecraftAccounts.username })
|
||||||
|
.from(minecraftAccounts)
|
||||||
|
.where(and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
const nickname = formatManagedDiscordNickname(firstName, primary?.username ?? null);
|
||||||
|
|
||||||
await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id));
|
await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id));
|
||||||
|
const synchronization = await synchronizeNickname(user, nickname, "update-first-name");
|
||||||
await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName });
|
await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName });
|
||||||
redirect("/account?confirmNickname=1");
|
redirect(nicknameResultUrl(nickname, synchronization));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addMinecraftAccount(formData: FormData) {
|
export async function addMinecraftAccount(formData: FormData) {
|
||||||
@@ -46,40 +96,60 @@ export async function addMinecraftAccount(formData: FormData) {
|
|||||||
const profile = await lookupJavaProfile(requestedUsername);
|
const profile = await lookupJavaProfile(requestedUsername);
|
||||||
if (!profile && !confirmed) redirect(`/account?unverified=${encodeURIComponent(requestedUsername)}`);
|
if (!profile && !confirmed) redirect(`/account?unverified=${encodeURIComponent(requestedUsername)}`);
|
||||||
|
|
||||||
const [existing] = await db.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
|
const [existing] = await db
|
||||||
and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
|
.select({ id: minecraftAccounts.id })
|
||||||
).limit(1);
|
.from(minecraftAccounts)
|
||||||
|
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
let failed = false;
|
const username = profile?.username ?? requestedUsername;
|
||||||
try {
|
try {
|
||||||
await db.insert(minecraftAccounts).values({
|
await db.insert(minecraftAccounts).values({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
minecraftUuid: profile?.uuid ?? null,
|
minecraftUuid: profile?.uuid ?? null,
|
||||||
username: profile?.username ?? requestedUsername,
|
username,
|
||||||
validationStatus: profile ? "verified" : "user_confirmed",
|
validationStatus: profile ? "verified" : "user_confirmed",
|
||||||
lastVerifiedAt: profile ? new Date() : null,
|
lastVerifiedAt: profile ? new Date() : null,
|
||||||
isPrimary: !existing,
|
isPrimary: !existing,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
failed = true;
|
redirect("/account?error=already-registered");
|
||||||
}
|
}
|
||||||
if (failed) redirect("/account?error=already-registered");
|
const nickname = !existing && user.firstName
|
||||||
|
? formatManagedDiscordNickname(user.firstName, username)
|
||||||
|
: null;
|
||||||
|
const synchronization = nickname
|
||||||
|
? await synchronizeNickname(user, nickname, "add-first-account")
|
||||||
|
: null;
|
||||||
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.added", {
|
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.added", {
|
||||||
username: profile?.username ?? requestedUsername,
|
username,
|
||||||
minecraftUuid: profile?.uuid ?? null,
|
minecraftUuid: profile?.uuid ?? null,
|
||||||
validationStatus: profile ? "verified" : "user_confirmed",
|
validationStatus: profile ? "verified" : "user_confirmed",
|
||||||
});
|
});
|
||||||
redirect(existing ? "/account?added=1" : "/account?confirmNickname=1");
|
|
||||||
|
redirect(nickname && synchronization
|
||||||
|
? nicknameResultUrl(nickname, synchronization, "added=1")
|
||||||
|
: "/account?added=1");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setPrimaryAccount(formData: FormData) {
|
export async function setPrimaryAccount(formData: FormData) {
|
||||||
const user = await requireCurrentUser();
|
const user = await requireCurrentUser();
|
||||||
const accountId = String(formData.get("accountId") ?? "");
|
const accountId = String(formData.get("accountId") ?? "");
|
||||||
|
const [requestedAccount] = await db
|
||||||
|
.select({ id: minecraftAccounts.id, username: minecraftAccounts.username })
|
||||||
|
.from(minecraftAccounts)
|
||||||
|
.where(and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!requestedAccount) redirect("/account?error=unknown-account");
|
||||||
|
if (!user.firstName) redirect("/account?error=nickname-not-configured");
|
||||||
|
|
||||||
const changed = await db.transaction(async (tx) => {
|
const changed = await db.transaction(async (tx) => {
|
||||||
const [account] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
|
const [account] = await tx
|
||||||
and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
|
.select({ id: minecraftAccounts.id })
|
||||||
).limit(1);
|
.from(minecraftAccounts)
|
||||||
|
.where(and(eq(minecraftAccounts.id, requestedAccount.id), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
if (!account) return false;
|
if (!account) return false;
|
||||||
|
|
||||||
await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where(
|
await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where(
|
||||||
@@ -88,61 +158,62 @@ export async function setPrimaryAccount(formData: FormData) {
|
|||||||
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
|
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!changed) redirect("/account?error=unknown-account");
|
if (!changed) redirect("/account?error=unknown-account");
|
||||||
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", { accountId });
|
|
||||||
redirect("/account?confirmNickname=1");
|
const nickname = formatManagedDiscordNickname(user.firstName, requestedAccount.username);
|
||||||
|
const synchronization = await synchronizeNickname(user, nickname, "set-primary-account");
|
||||||
|
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", {
|
||||||
|
accountId: requestedAccount.id,
|
||||||
|
nickname,
|
||||||
|
});
|
||||||
|
redirect(nicknameResultUrl(nickname, synchronization));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeMinecraftAccount(formData: FormData) {
|
export async function removeMinecraftAccount(formData: FormData) {
|
||||||
const user = await requireCurrentUser();
|
const user = await requireCurrentUser();
|
||||||
const accountId = String(formData.get("accountId") ?? "");
|
const accountId = String(formData.get("accountId") ?? "");
|
||||||
|
|
||||||
const removed = await db.transaction(async (tx) => {
|
const result = await db.transaction(async (tx) => {
|
||||||
const [account] = await tx.select({ id: minecraftAccounts.id, isPrimary: minecraftAccounts.isPrimary }).from(minecraftAccounts).where(
|
const [account] = await tx
|
||||||
and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)),
|
.select({ id: minecraftAccounts.id, isPrimary: minecraftAccounts.isPrimary })
|
||||||
).limit(1);
|
.from(minecraftAccounts)
|
||||||
if (!account) return false;
|
.where(and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
if (!account) return null;
|
||||||
|
|
||||||
await tx.update(minecraftAccounts).set({ deletedAt: new Date(), isPrimary: false, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
|
await tx.update(minecraftAccounts).set({ deletedAt: new Date(), isPrimary: false, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id));
|
||||||
|
let replacementUsername: string | null = null;
|
||||||
if (account.isPrimary) {
|
if (account.isPrimary) {
|
||||||
const [replacement] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where(
|
const [replacement] = await tx
|
||||||
and(eq(minecraftAccounts.userId, user.id), ne(minecraftAccounts.id, account.id), isNull(minecraftAccounts.deletedAt)),
|
.select({ id: minecraftAccounts.id, username: minecraftAccounts.username })
|
||||||
).limit(1);
|
.from(minecraftAccounts)
|
||||||
|
.where(and(eq(minecraftAccounts.userId, user.id), ne(minecraftAccounts.id, account.id), isNull(minecraftAccounts.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
if (replacement) {
|
if (replacement) {
|
||||||
|
replacementUsername = replacement.username;
|
||||||
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, replacement.id));
|
await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, replacement.id));
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const [primary] = await tx
|
||||||
|
.select({ username: minecraftAccounts.username })
|
||||||
|
.from(minecraftAccounts)
|
||||||
|
.where(and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
replacementUsername = primary?.username ?? null;
|
||||||
}
|
}
|
||||||
return true;
|
return { replacementUsername };
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!removed) redirect("/account?error=unknown-account");
|
if (!result) redirect("/account?error=unknown-account");
|
||||||
|
const nickname = user.firstName
|
||||||
|
? formatManagedDiscordNickname(user.firstName, result.replacementUsername)
|
||||||
|
: null;
|
||||||
|
const synchronization = nickname
|
||||||
|
? await synchronizeNickname(user, nickname, "remove-account")
|
||||||
|
: null;
|
||||||
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.removed", { accountId });
|
await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.removed", { accountId });
|
||||||
redirect("/account?removed=1&confirmNickname=1");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function confirmDashboardNickname() {
|
redirect(nickname && synchronization
|
||||||
const user = await requireCurrentUser();
|
? nicknameResultUrl(nickname, synchronization, "removed=1")
|
||||||
const [account] = await db.select({ username: minecraftAccounts.username }).from(minecraftAccounts).where(
|
: "/account?removed=1");
|
||||||
and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)),
|
|
||||||
).limit(1);
|
|
||||||
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
|
||||||
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
|
|
||||||
|
|
||||||
if (!user.firstName || !account || !guildId || !botToken) redirect("/account?error=nickname-not-configured");
|
|
||||||
|
|
||||||
try {
|
|
||||||
await updateGuildNickname({
|
|
||||||
guildId,
|
|
||||||
discordUserId: user.discordUserId,
|
|
||||||
nickname: formatDiscordNickname(user.firstName, account.username),
|
|
||||||
botToken,
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
redirect("/account?error=nickname-update-failed&confirmNickname=1");
|
|
||||||
}
|
|
||||||
await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", {
|
|
||||||
nickname: formatDiscordNickname(user.firstName, account.username),
|
|
||||||
});
|
|
||||||
redirect("/account?nicknameUpdated=1");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import { formatDiscordNickname } from "@minecraft-account-manager/minecraft";
|
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
|
||||||
import { ipIntelligence, ipObservations, minecraftAccounts } from "@minecraft-account-manager/database";
|
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
import { groups, ipIntelligence, ipObservations, minecraftAccounts, userGroupMemberships } from "@minecraft-account-manager/database";
|
||||||
|
import { and, desc, eq, isNull, or } from "drizzle-orm";
|
||||||
import { logout } from "@/app/auth/actions";
|
import { logout } from "@/app/auth/actions";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
import { requireCurrentUser } from "@/lib/auth/user-session";
|
import { requireCurrentUser } from "@/lib/auth/user-session";
|
||||||
|
import { groupAccessAddresses } from "@/lib/access-address-groups";
|
||||||
|
import { discordIdentity } from "@/lib/discord-identity";
|
||||||
import { intelligenceSummary } from "@/lib/event-ip-summary";
|
import { intelligenceSummary } from "@/lib/event-ip-summary";
|
||||||
|
import { NicknameNotice } from "@/components/nickname-notice";
|
||||||
import {
|
import {
|
||||||
addMinecraftAccount,
|
addMinecraftAccount,
|
||||||
confirmDashboardNickname,
|
|
||||||
removeMinecraftAccount,
|
removeMinecraftAccount,
|
||||||
setPrimaryAccount,
|
setPrimaryAccount,
|
||||||
updateFirstName,
|
updateFirstName,
|
||||||
} from "./actions";
|
} from "./actions";
|
||||||
|
|
||||||
|
function queryValue(value: string | string[] | undefined) {
|
||||||
|
return Array.isArray(value) ? value[0] : value;
|
||||||
|
}
|
||||||
|
|
||||||
const errorMessages: Record<string, string> = {
|
const errorMessages: Record<string, string> = {
|
||||||
"invalid-name": "Enter a valid name between 1 and 50 characters.",
|
"invalid-name": "Enter a valid name between 1 and 50 characters.",
|
||||||
"invalid-username": "Java usernames use 3–16 letters, numbers, or underscores.",
|
"invalid-username": "Java usernames use 3–16 letters, numbers, or underscores.",
|
||||||
@@ -24,14 +31,18 @@ const errorMessages: Record<string, string> = {
|
|||||||
"ip-check-unavailable": "We could not verify your network, so account addition is temporarily blocked.",
|
"ip-check-unavailable": "We could not verify your network, so account addition is temporarily blocked.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function AccountPage({
|
export default async function AccountPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<Record<string, string | undefined>>;
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}) {
|
}) {
|
||||||
const user = await requireCurrentUser("/account");
|
const user = await requireCurrentUser("/account");
|
||||||
const query = await searchParams;
|
const query = await searchParams;
|
||||||
const [accounts, observations] = await Promise.all([
|
const error = queryValue(query.error);
|
||||||
|
const unverified = queryValue(query.unverified);
|
||||||
|
const [accounts, observations, discord, availableGroups] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(minecraftAccounts)
|
.from(minecraftAccounts)
|
||||||
@@ -50,12 +61,25 @@ export default async function AccountPage({
|
|||||||
.leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
.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(20),
|
.limit(100),
|
||||||
|
discordIdentity(user),
|
||||||
|
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, isDefault: groups.isDefault })
|
||||||
|
.from(groups)
|
||||||
|
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
|
||||||
|
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
|
||||||
|
.orderBy(desc(groups.isDefault), groups.name),
|
||||||
]);
|
]);
|
||||||
|
const addressGroups = groupAccessAddresses(observations);
|
||||||
const primary = accounts.find((account) => account.isPrimary);
|
const primary = accounts.find((account) => account.isPrimary);
|
||||||
const desiredNickname = user.firstName && primary
|
const desiredNickname = user.firstName
|
||||||
? formatDiscordNickname(user.firstName, primary.username)
|
? formatManagedDiscordNickname(user.firstName, primary?.username ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null;
|
||||||
|
const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null;
|
||||||
|
const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup);
|
||||||
|
const nicknameUpdated = queryValue(query.nicknameUpdated);
|
||||||
|
const nicknameExpected = queryValue(query.nicknameExpected);
|
||||||
|
const nicknameError = error === "nickname-update-failed" || error === "nickname-not-configured" ? error : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-canvas px-6 py-10 text-ink sm:py-16">
|
<main className="min-h-screen bg-canvas px-6 py-10 text-ink sm:py-16">
|
||||||
@@ -68,25 +92,17 @@ export default async function AccountPage({
|
|||||||
<form action={logout}><button className="font-mono text-xs font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Sign out</button></form>
|
<form action={logout}><button className="font-mono text-xs font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Sign out</button></form>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{query.error && (
|
{error && !nicknameError && (
|
||||||
<p className="mt-8 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">
|
<p className="mt-8 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">
|
||||||
{errorMessages[query.error] ?? "The requested change could not be completed."}
|
{errorMessages[error] ?? "The requested change could not be completed."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{query.nicknameUpdated && <p className="mt-8 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider">Discord nickname updated</p>}
|
<NicknameNotice
|
||||||
|
error={nicknameError
|
||||||
{query.confirmNickname && desiredNickname && (
|
? `${errorMessages[nicknameError]}${nicknameExpected ? ` Your intended nickname is ${nicknameExpected}.` : ""}`
|
||||||
<section className="mt-8 border border-accent bg-panel p-6 shadow-[6px_6px_0_var(--color-accent)] sm:flex sm:items-center sm:justify-between sm:gap-8">
|
: undefined}
|
||||||
<div>
|
nickname={nicknameUpdated}
|
||||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Confirm Discord change</p>
|
/>
|
||||||
<p className="mt-2 text-sm text-muted">Your community nickname will become</p>
|
|
||||||
<p className="mt-1 font-display text-2xl font-black">{desiredNickname}</p>
|
|
||||||
</div>
|
|
||||||
<form action={confirmDashboardNickname} className="mt-5 sm:mt-0">
|
|
||||||
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Confirm update</button>
|
|
||||||
</form>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-12 grid gap-10 lg:grid-cols-[1.35fr_0.65fr]">
|
<div className="mt-12 grid gap-10 lg:grid-cols-[1.35fr_0.65fr]">
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
@@ -109,24 +125,24 @@ export default async function AccountPage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{!account.isPrimary && <form action={setPrimaryAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Make primary</button></form>}
|
{!account.isPrimary && <form action={setPrimaryAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider underline underline-offset-4" type="submit">Make primary</button></form>}
|
||||||
<form action={removeMinecraftAccount}><input name="accountId" type="hidden" value={account.id} /><button className="font-mono text-[10px] font-bold uppercase tracking-wider text-accent underline underline-offset-4" type="submit">Remove</button></form>
|
<details className="relative"><summary className="cursor-pointer list-none font-mono text-[10px] font-bold uppercase tracking-wider text-accent underline underline-offset-4">Remove</summary><form action={removeMinecraftAccount} className="absolute right-0 z-10 mt-2 w-60 border border-accent bg-panel p-4 shadow-[5px_5px_0_var(--color-accent)]"><input name="accountId" type="hidden" value={account.id} /><p className="text-xs leading-5">Remove {account.username}? Your Discord nickname will update automatically.</p><button className="mt-3 bg-accent px-3 py-2 font-mono text-[9px] font-bold uppercase text-canvas" type="submit">Confirm removal</button></form></details>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
{accounts.length === 0 && <p className="py-8 text-muted">No active Minecraft accounts. Add one before joining the server.</p>}
|
{accounts.length === 0 && <p className="py-8 text-muted">No active Minecraft accounts. Add one before joining the server.</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{query.unverified ? (
|
{unverified ? (
|
||||||
<form action={addMinecraftAccount} className="border-l-2 border-accent bg-panel p-6">
|
<form action={addMinecraftAccount} className="border-l-2 border-accent bg-panel p-6">
|
||||||
<h3 className="font-display text-xl font-black uppercase">Mojang couldn’t verify “{query.unverified}”</h3>
|
<h3 className="font-display text-xl font-black uppercase">Mojang couldn’t verify “{unverified}”</h3>
|
||||||
<p className="mt-2 text-sm leading-6 text-muted">Continue only if you are certain the spelling is correct.</p>
|
<p className="mt-2 text-sm leading-6 text-muted">Continue only if you are certain the spelling is correct.</p>
|
||||||
<input name="username" type="hidden" value={query.unverified} /><input name="confirmUnverified" type="hidden" value="yes" />
|
<input name="username" type="hidden" value={unverified} /><input name="confirmUnverified" type="hidden" value="yes" />
|
||||||
<button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Add anyway</button>
|
<button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Add anyway</button>
|
||||||
<a className="ml-5 font-mono text-[10px] font-bold uppercase underline" href="/account">Cancel</a>
|
<a className="ml-5 font-mono text-[10px] font-bold uppercase underline" href="/account">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<form action={addMinecraftAccount} className="flex flex-col gap-3 border-t border-line pt-6 sm:flex-row">
|
<form action={addMinecraftAccount} className="flex flex-col gap-3 border-t border-line pt-6 sm:flex-row">
|
||||||
<input className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-sm outline-none focus:border-accent" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" placeholder="Minecraft username" required />
|
<input aria-label="Minecraft username" className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-sm outline-none focus:border-accent" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" placeholder="Minecraft username" required />
|
||||||
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Add account</button>
|
<button className="border border-ink bg-ink px-5 py-3 font-mono text-xs font-bold uppercase tracking-wider text-canvas" type="submit">Add account</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -135,11 +151,24 @@ export default async function AccountPage({
|
|||||||
<section>
|
<section>
|
||||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Recent security activity</p>
|
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Recent security activity</p>
|
||||||
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Access addresses</h2>
|
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Access addresses</h2>
|
||||||
{observations.length ? (
|
{addressGroups.length ? (
|
||||||
<div className="divide-y divide-line font-mono text-xs">
|
<div className="divide-y divide-line font-mono text-xs">
|
||||||
{observations.map((observation) => {
|
<p className="py-3 text-[10px] leading-5 text-muted">Similar IPv4 /24 and IPv6 /64 networks are grouped. Counts cover your 100 most recent observations.</p>
|
||||||
const summary = intelligenceSummary(observation.intelligence);
|
{addressGroups.map((group) => {
|
||||||
return <div className="grid grid-cols-[1fr_auto] gap-4 py-4" key={observation.id}><div><p>{observation.ipAddress}</p><p className="mt-1 text-[10px] text-muted">{summary.location ?? "Location unavailable"} · {summary.classification ?? observation.classification}</p></div><span className="text-muted">{observation.source} · {observation.observedAt.toISOString()}</span></div>;
|
const summary = intelligenceSummary(group.intelligence);
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 py-4 sm:grid-cols-[1fr_auto] sm:items-start" key={group.network}>
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<p className="font-bold">{group.network}</p>
|
||||||
|
<span className="border border-line px-2 py-1 text-[9px] uppercase tracking-wider text-muted">{group.count} {group.count === 1 ? "observation" : "observations"}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[10px] text-muted">Latest address {group.latestAddress}</p>
|
||||||
|
<p className="mt-1 text-[10px] text-muted">{summary.location ?? "Location unavailable"} · {summary.classification ?? group.classification}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-muted sm:text-right">{group.sources.join(" + ")}<br />Last seen {group.latestObservedAt.toISOString()}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : <p className="py-6 text-sm text-muted">No web or game access addresses have been recorded yet.</p>}
|
) : <p className="py-6 text-sm text-muted">No web or game access addresses have been recorded yet.</p>}
|
||||||
@@ -149,11 +178,23 @@ export default async function AccountPage({
|
|||||||
<aside>
|
<aside>
|
||||||
<form action={updateFirstName} className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
<form action={updateFirstName} className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||||
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Profile</p>
|
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Profile</p>
|
||||||
|
<dl className="mt-5 space-y-3 border-b border-line pb-5 font-mono text-[10px]">
|
||||||
|
<div><dt className="uppercase tracking-wider text-muted">Discord name</dt><dd className="mt-1 break-all font-bold text-ink">{discord.globalName ?? discord.username}</dd></div>
|
||||||
|
<div><dt className="uppercase tracking-wider text-muted">Discord username</dt><dd className="mt-1 break-all text-ink">@{discord.username}</dd></div>
|
||||||
|
<div><dt className="uppercase tracking-wider text-muted">Guild nickname</dt><dd className="mt-1 break-all text-ink">{discord.nickname ?? "No guild nickname"}</dd></div>
|
||||||
|
<div><dt className="uppercase tracking-wider text-muted">Discord ID</dt><dd className="mt-1 break-all text-ink">{discord.id}</dd></div>
|
||||||
|
</dl>
|
||||||
<label className="mt-5 block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="firstName">What we call you</label>
|
<label className="mt-5 block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="firstName">What we call you</label>
|
||||||
<input className="mt-3 w-full border border-line bg-canvas px-4 py-3 outline-none focus:border-accent" defaultValue={user.firstName ?? ""} id="firstName" maxLength={50} name="firstName" required />
|
<input className="mt-3 w-full border border-line bg-canvas px-4 py-3 outline-none focus:border-accent" defaultValue={user.firstName ?? ""} id="firstName" maxLength={50} name="firstName" required />
|
||||||
{desiredNickname && <p className="mt-4 text-xs leading-5 text-muted">Discord preview: <strong className="text-ink">{desiredNickname}</strong></p>}
|
{desiredNickname && <p className="mt-4 text-xs leading-5 text-muted">Managed Discord nickname: <strong className="text-ink">{desiredNickname}</strong></p>}
|
||||||
|
<p className="mt-3 text-xs leading-5 text-muted">Saving automatically synchronizes this Discord guild nickname.</p>
|
||||||
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider" type="submit">Save name</button>
|
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider" type="submit">Save name</button>
|
||||||
</form>
|
</form>
|
||||||
|
<section className="mt-8 border border-line bg-panel p-6">
|
||||||
|
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted">Access groups</p>
|
||||||
|
{effectiveGroup ? <div className="mt-4 flex items-center justify-between gap-3"><span className="font-mono text-xs font-bold">{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}</span><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${effectiveGroup.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{effectiveGroup.accessEnabled ? "Access on" : "Access off"}</span></div> : <p className="mt-4 text-sm text-accent">No default access group is configured.</p>}
|
||||||
|
<p className="mt-4 text-xs leading-5 text-muted">Your effective group alone determines Minecraft access.</p>
|
||||||
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export async function saveDiscordSettings(formData: FormData) {
|
|||||||
const registrationMessage = String(formData.get("registrationMessage") ?? "").trim();
|
const registrationMessage = String(formData.get("registrationMessage") ?? "").trim();
|
||||||
|
|
||||||
if (registrationMessage.length < 10 || registrationMessage.length > 500) {
|
if (registrationMessage.length < 10 || registrationMessage.length > 500) {
|
||||||
redirect("/admin?error=invalid-message");
|
redirect("/admin/settings?error=invalid-message");
|
||||||
}
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
@@ -28,5 +28,5 @@ export async function saveDiscordSettings(formData: FormData) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
redirect("/admin?saved=1");
|
redirect("/admin/settings?saved=1");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { events } from "@minecraft-account-manager/database";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { db } from "@/lib/database";
|
||||||
|
import { eventIpSummary } from "@/lib/event-ip-summary";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
export default async function EventDetailPage({ params }: { params: Promise<{ eventId: string }> }) {
|
||||||
|
const { eventId } = await params;
|
||||||
|
if (!UUID_PATTERN.test(eventId)) notFound();
|
||||||
|
const [event] = await db.select().from(events).where(eq(events.id, eventId)).limit(1);
|
||||||
|
if (!event) notFound();
|
||||||
|
const network = eventIpSummary(event.data);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-5xl px-6 py-12">
|
||||||
|
<Link className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted underline underline-offset-4" href="/admin/events">← Event explorer</Link>
|
||||||
|
<header className="mt-7 border-b border-line pb-8">
|
||||||
|
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">CloudEvent detail</p>
|
||||||
|
<h1 className="mt-4 break-words font-display text-3xl font-black uppercase sm:text-5xl">{event.type}</h1>
|
||||||
|
<p className="mt-4 break-all font-mono text-xs text-muted">{event.id}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="mt-8 border border-line bg-panel p-6 shadow-[7px_7px_0_var(--color-shadow)]">
|
||||||
|
<h2 className="font-display text-2xl font-black uppercase">Envelope</h2>
|
||||||
|
<dl className="mt-5 grid gap-x-8 gap-y-5 sm:grid-cols-2">
|
||||||
|
<Detail label="Time"><time dateTime={event.time.toISOString()}>{event.time.toISOString()}</time></Detail>
|
||||||
|
<Detail label="Spec version">{event.specVersion}</Detail>
|
||||||
|
<Detail label="Source">{event.source}</Detail>
|
||||||
|
<Detail label="Subject">{event.subject ?? "Not provided"}</Detail>
|
||||||
|
<Detail label="Content type">{event.dataContentType}</Detail>
|
||||||
|
<Detail label="Data schema">{event.dataSchema ?? "Not provided"}</Detail>
|
||||||
|
<Detail label="Actor user">{event.actorUserId ? <Link className="underline underline-offset-4" href={`/admin/users/${event.actorUserId}`}>{event.actorUserId}</Link> : "Not provided"}</Detail>
|
||||||
|
<Detail label="Correlation ID">{event.correlationId ?? "Not provided"}</Detail>
|
||||||
|
<Detail label="IP address">{event.ipAddress ?? "Not provided"}</Detail>
|
||||||
|
<Detail label="Network">{network.classification || network.location ? `${network.classification ?? "unknown"} · ${network.location ?? "location unavailable"}` : "Not provided"}</Detail>
|
||||||
|
<Detail label="Published">{event.publishedAt ? event.publishedAt.toISOString() : "Pending publication"}</Detail>
|
||||||
|
<Detail label="Recorded">{event.createdAt.toISOString()}</Detail>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-10">
|
||||||
|
<h2 className="font-display text-2xl font-black uppercase">Event data</h2>
|
||||||
|
<pre className="mt-4 overflow-x-auto border border-line bg-ink p-5 font-mono text-xs leading-6 text-canvas" tabIndex={0}>{JSON.stringify(event.data, null, 2)}</pre>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Detail({ label, children }: { label: string; children: React.ReactNode }) {
|
||||||
|
return <div><dt className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">{label}</dt><dd className="mt-1 break-all text-sm">{children}</dd></div>;
|
||||||
|
}
|
||||||
@@ -1,34 +1,92 @@
|
|||||||
import { events } from "@minecraft-account-manager/database";
|
import { events } from "@minecraft-account-manager/database";
|
||||||
import { desc } from "drizzle-orm";
|
import { desc, inArray } from "drizzle-orm";
|
||||||
|
import Link from "next/link";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
|
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
|
||||||
import { eventIpSummary } from "@/lib/event-ip-summary";
|
import { eventIpSummary } from "@/lib/event-ip-summary";
|
||||||
|
|
||||||
export default async function EventsPage() {
|
export const dynamic = "force-dynamic";
|
||||||
const recentEvents = await db.select().from(events).orderBy(desc(events.time)).limit(100);
|
|
||||||
|
function values(value: string | string[] | undefined) {
|
||||||
|
return Array.isArray(value) ? value : value ? [value] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function EventsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
|
}) {
|
||||||
|
const query = await searchParams;
|
||||||
|
const typeRows = await db.select({ type: events.type }).from(events).groupBy(events.type).orderBy(events.type);
|
||||||
|
const availableTypes = typeRows.map((row) => row.type);
|
||||||
|
const category = normalizeEventCategory(values(query.category)[0]);
|
||||||
|
const selectedTypes = normalizeSelectedEventTypes(query.type, availableTypes);
|
||||||
|
const categoryTypes = category === "all"
|
||||||
|
? availableTypes
|
||||||
|
: availableTypes.filter((type) => eventCategory(type) === category);
|
||||||
|
const filteredTypes = selectedTypes.length
|
||||||
|
? selectedTypes.filter((type) => categoryTypes.includes(type))
|
||||||
|
: categoryTypes;
|
||||||
|
const recentEvents = filteredTypes.length
|
||||||
|
? await db.select().from(events).where(inArray(events.type, filteredTypes)).orderBy(desc(events.time)).limit(100)
|
||||||
|
: [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">CloudEvents ledger</p>
|
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">CloudEvents ledger</p>
|
||||||
<h1 className="mt-4 font-display text-5xl font-black uppercase">Recent events</h1>
|
<h1 className="mt-4 font-display text-5xl font-black uppercase">Event explorer</h1>
|
||||||
<div className="mt-10 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
<p className="mt-4 max-w-2xl text-sm leading-6 text-muted">Filter the immutable audit ledger, then open an event to inspect its complete CloudEvents envelope and data.</p>
|
||||||
|
|
||||||
|
<form className="mt-8 border border-line bg-panel p-6" method="get">
|
||||||
|
<div className="grid gap-6 md:grid-cols-[0.45fr_1.55fr]">
|
||||||
|
<label className="font-mono text-xs font-bold uppercase tracking-wider" htmlFor="event-category">
|
||||||
|
View
|
||||||
|
<select className="mt-3 block w-full border border-line bg-canvas px-3 py-3 font-sans text-sm font-normal normal-case" defaultValue={category} id="event-category" name="category">
|
||||||
|
{eventCategoryValues.map((value) => <option key={value} value={value}>{value === "all" ? "All activity" : value}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<fieldset>
|
||||||
|
<legend className="font-mono text-xs font-bold uppercase tracking-wider">Event types</legend>
|
||||||
|
<details className="mt-3 border border-line bg-canvas p-4" open={selectedTypes.length > 0}>
|
||||||
|
<summary className="cursor-pointer font-mono text-[10px] font-bold uppercase underline underline-offset-4">{selectedTypes.length ? `${selectedTypes.length} selected` : "All types in this view"}</summary>
|
||||||
|
<div className="mt-4 grid max-h-64 gap-3 overflow-y-auto sm:grid-cols-2">
|
||||||
|
{availableTypes.map((type) => (
|
||||||
|
<label className="flex items-start gap-2 font-mono text-[10px] leading-4" key={type}>
|
||||||
|
<input className="mt-0.5 size-4 accent-[var(--accent)]" defaultChecked={selectedTypes.includes(type)} name="type" type="checkbox" value={type} />
|
||||||
|
<span className="break-all">{type}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</fieldset>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 flex flex-wrap gap-4">
|
||||||
|
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Apply filters</button>
|
||||||
|
<Link className="self-center font-mono text-[10px] font-bold uppercase underline underline-offset-4" href="/admin/events">Clear filters</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-8 font-mono text-[10px] uppercase tracking-widest text-muted" role="status">Showing {recentEvents.length} most recent matching events</p>
|
||||||
|
<div className="mt-3 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||||
<table className="w-full min-w-[760px] border-collapse text-left">
|
<table className="w-full min-w-[760px] border-collapse text-left">
|
||||||
|
<caption className="sr-only">Filtered account manager events</caption>
|
||||||
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
|
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
|
||||||
<tr><th className="p-4">Time</th><th className="p-4">Type</th><th className="p-4">Subject</th><th className="p-4">IP</th><th className="p-4">Network</th></tr>
|
<tr><th className="p-4" scope="col">Time</th><th className="p-4" scope="col">Type</th><th className="p-4" scope="col">Subject</th><th className="p-4" scope="col">IP</th><th className="p-4" scope="col">Network</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-line text-xs">
|
<tbody className="divide-y divide-line text-xs">
|
||||||
{recentEvents.map((event) => {
|
{recentEvents.map((event) => {
|
||||||
const ip = eventIpSummary(event.data);
|
const ip = eventIpSummary(event.data);
|
||||||
return (
|
return (
|
||||||
<tr key={event.id}>
|
<tr className="hover:bg-canvas/60" key={event.id}>
|
||||||
<td className="whitespace-nowrap p-4 font-mono text-muted">{event.time.toISOString()}</td>
|
<td className="whitespace-nowrap p-4 font-mono text-muted"><time dateTime={event.time.toISOString()}>{event.time.toISOString()}</time></td>
|
||||||
<td className="p-4 font-mono font-bold">{event.type}</td>
|
<th className="p-4 text-left font-mono font-bold" scope="row"><Link className="underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/events/${event.id}`}>{event.type}</Link></th>
|
||||||
<td className="p-4 font-mono text-muted">{event.subject ?? "—"}</td>
|
<td className="p-4 font-mono text-muted">{event.subject ?? "—"}</td>
|
||||||
<td className="p-4 font-mono text-muted">{event.ipAddress ?? "—"}</td>
|
<td className="p-4 font-mono text-muted">{event.ipAddress ?? "—"}</td>
|
||||||
<td className="p-4"><div className="font-mono text-[10px] font-bold uppercase">{ip.classification ?? "—"}</div><div className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"}</div></td>
|
<td className="p-4"><div className="font-mono text-[10px] font-bold uppercase">{ip.classification ?? "—"}</div><div className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"}</div></td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{!recentEvents.length && <tr><td className="p-8 text-muted" colSpan={5}>No events have been recorded.</td></tr>}
|
{!recentEvents.length && <tr><td className="p-8 text-muted" colSpan={5}>No events match these filters.</td></tr>}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||||
|
import { asc, eq } from "drizzle-orm";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { db } from "@/lib/database";
|
||||||
|
import { addGroupMember, assignDefaultGroup, deleteGroup, removeGroupMember, setGroupAccess } from "../actions";
|
||||||
|
|
||||||
|
const savedMessages: Record<string, string> = {
|
||||||
|
created: "Group created with access disabled.",
|
||||||
|
access: "Group access policy updated.",
|
||||||
|
"member-added": "User assigned to the group.",
|
||||||
|
"member-removed": "User returned to the default group.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function GroupPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ groupId: string }>;
|
||||||
|
searchParams: Promise<{ saved?: string }>;
|
||||||
|
}) {
|
||||||
|
const { groupId } = await params;
|
||||||
|
const query = await searchParams;
|
||||||
|
const [group] = await db.select().from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||||
|
if (!group) notFound();
|
||||||
|
|
||||||
|
const [allUsers, memberships] = await Promise.all([
|
||||||
|
db.select({
|
||||||
|
id: users.id,
|
||||||
|
firstName: users.firstName,
|
||||||
|
discordUsername: users.discordUsername,
|
||||||
|
discordGlobalName: users.discordGlobalName,
|
||||||
|
discordUserId: users.discordUserId,
|
||||||
|
}).from(users).orderBy(asc(users.discordUsername)),
|
||||||
|
db.select({
|
||||||
|
userId: userGroupMemberships.userId,
|
||||||
|
groupId: userGroupMemberships.groupId,
|
||||||
|
groupName: groups.name,
|
||||||
|
}).from(userGroupMemberships).innerJoin(groups, eq(groups.id, userGroupMemberships.groupId)),
|
||||||
|
]);
|
||||||
|
const assignmentByUser = new Map(memberships.map((membership) => [membership.userId, membership]));
|
||||||
|
const memberCount = group.isDefault
|
||||||
|
? allUsers.length - assignmentByUser.size
|
||||||
|
: memberships.filter((membership) => membership.groupId === group.id).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-6xl px-6 py-12">
|
||||||
|
<Link className="font-mono text-[10px] font-bold uppercase tracking-widest text-muted underline underline-offset-4" href="/admin/groups">← All groups</Link>
|
||||||
|
<header className="mt-7 flex flex-col gap-6 border-b border-line pb-8 lg:flex-row lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Access group</p>
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-3"><h1 className="font-display text-5xl font-black uppercase sm:text-7xl">{group.name}</h1>{group.isDefault && <span className="bg-ink px-3 py-2 font-mono text-[9px] font-bold uppercase text-canvas">Default</span>}</div>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
|
||||||
|
</div>
|
||||||
|
<form action={setGroupAccess} className="border-l-2 border-accent pl-5">
|
||||||
|
<input name="groupId" type="hidden" value={group.id} />
|
||||||
|
<input name="accessEnabled" type="hidden" value={group.accessEnabled ? "no" : "yes"} />
|
||||||
|
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Minecraft admission</p>
|
||||||
|
<p className="mt-2 font-display text-2xl font-black uppercase">{group.accessEnabled ? "Allowed" : "Denied"}</p>
|
||||||
|
<button className="mt-3 font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn access {group.accessEnabled ? "off" : "on"}</button>
|
||||||
|
</form>
|
||||||
|
</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>}
|
||||||
|
|
||||||
|
<section className="mt-10">
|
||||||
|
<div className="flex items-end justify-between border-b border-line pb-4">
|
||||||
|
<div><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Membership</p><h2 className="mt-2 font-display text-3xl font-black uppercase">Registered users</h2></div>
|
||||||
|
<span className="font-mono text-xs text-muted">{memberCount} members</span>
|
||||||
|
</div>
|
||||||
|
{group.isDefault && <p className="border-b border-line bg-panel px-5 py-4 text-sm text-muted">Users belong to <strong className="text-ink">everyone</strong> only while they have no explicit group assignment.</p>}
|
||||||
|
<div className="divide-y divide-line">
|
||||||
|
{allUsers.map((user) => {
|
||||||
|
const assignment = assignmentByUser.get(user.id);
|
||||||
|
const isMember = group.isDefault ? !assignment : assignment?.groupId === group.id;
|
||||||
|
return (
|
||||||
|
<article className="grid gap-4 py-5 sm:grid-cols-[1fr_auto] sm:items-center" key={user.id}>
|
||||||
|
<div>
|
||||||
|
<Link className="font-mono text-sm font-bold underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? user.discordGlobalName ?? user.discordUsername}</Link>
|
||||||
|
<p className="mt-1 font-mono text-[10px] text-muted">@{user.discordUsername} · {user.discordUserId}</p>
|
||||||
|
{!isMember && assignment && <p className="mt-1 text-xs text-muted">Currently assigned to {assignment.groupName}</p>}
|
||||||
|
</div>
|
||||||
|
{isMember ? (
|
||||||
|
group.isDefault ? <span className="font-mono text-[9px] font-bold uppercase text-muted">Default assignment</span> : (
|
||||||
|
<form action={removeGroupMember}>
|
||||||
|
<input name="groupId" type="hidden" value={group.id} />
|
||||||
|
<input name="userId" type="hidden" value={user.id} />
|
||||||
|
<button className="font-mono text-[9px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Return to everyone</button>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<form action={group.isDefault ? assignDefaultGroup : addGroupMember}>
|
||||||
|
<input name="groupId" type="hidden" value={group.id} />
|
||||||
|
<input name="userId" type="hidden" value={user.id} />
|
||||||
|
<button className="font-mono text-[9px] font-bold uppercase text-ink underline underline-offset-4" type="submit">Move to {group.name}</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!allUsers.length && <p className="py-8 text-sm text-muted">No registered users yet.</p>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{!group.isDefault && (
|
||||||
|
<section className="mt-12 border border-accent bg-panel p-6">
|
||||||
|
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Danger zone</p>
|
||||||
|
<h2 className="mt-3 font-display text-2xl font-black uppercase">Delete {group.name}</h2>
|
||||||
|
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted">Deleting this group returns its {memberCount} {memberCount === 1 ? "member" : "members"} to the protected default group. This cannot be undone.</p>
|
||||||
|
<details className="mt-5">
|
||||||
|
<summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4">Review deletion</summary>
|
||||||
|
<form action={deleteGroup} className="mt-4 flex flex-wrap items-center gap-4">
|
||||||
|
<input name="groupId" type="hidden" value={group.id} />
|
||||||
|
<input name="confirmDelete" type="hidden" value="yes" />
|
||||||
|
<button className="bg-accent px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Delete group permanently</button>
|
||||||
|
<span className="text-xs text-muted">Members will use everyone immediately.</span>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { events, groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||||
|
import { getClientIp } from "@minecraft-account-manager/network";
|
||||||
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import { headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { recordAdminSubjectEvent } from "@/lib/audit";
|
||||||
|
import { requireAdminSession } from "@/lib/auth/require-admin";
|
||||||
|
import { db } from "@/lib/database";
|
||||||
|
|
||||||
|
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]+)*$/;
|
||||||
|
|
||||||
|
function groupPath(groupId: string, query?: string) {
|
||||||
|
return `/admin/groups/${encodeURIComponent(groupId)}${query ? `?${query}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGroup(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const name = String(formData.get("name") ?? "").trim();
|
||||||
|
const slug = String(formData.get("slug") ?? "").trim().toLowerCase();
|
||||||
|
const description = String(formData.get("description") ?? "").trim();
|
||||||
|
if (name.length < 1 || name.length > 50 || !SLUG_PATTERN.test(slug) || slug.length > 50 || description.length > 500) {
|
||||||
|
redirect("/admin/groups?error=invalid-group");
|
||||||
|
}
|
||||||
|
|
||||||
|
let group: { id: string } | undefined;
|
||||||
|
try {
|
||||||
|
[group] = await db.insert(groups).values({
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description: description || null,
|
||||||
|
accessEnabled: false,
|
||||||
|
isDefault: false,
|
||||||
|
}).returning({ id: groups.id });
|
||||||
|
} catch {
|
||||||
|
redirect("/admin/groups?error=duplicate-group");
|
||||||
|
}
|
||||||
|
if (!group) redirect("/admin/groups?error=create-failed");
|
||||||
|
|
||||||
|
await recordAdminSubjectEvent(admin, `group/${group.id}`, "games.minecraft.account-manager.group.created", {
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
accessEnabled: false,
|
||||||
|
});
|
||||||
|
redirect(groupPath(group.id, "saved=created"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setGroupAccess(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const groupId = String(formData.get("groupId") ?? "");
|
||||||
|
const accessEnabled = formData.get("accessEnabled") === "yes";
|
||||||
|
if (!UUID_PATTERN.test(groupId)) redirect("/admin/groups?error=unknown-group");
|
||||||
|
|
||||||
|
const [group] = await db.update(groups).set({ accessEnabled, updatedAt: new Date() })
|
||||||
|
.where(eq(groups.id, groupId)).returning({ id: groups.id, name: groups.name });
|
||||||
|
if (!group) redirect("/admin/groups?error=unknown-group");
|
||||||
|
|
||||||
|
await recordAdminSubjectEvent(admin, `group/${group.id}`, "games.minecraft.account-manager.group.access-updated", {
|
||||||
|
name: group.name,
|
||||||
|
accessEnabled,
|
||||||
|
});
|
||||||
|
redirect(groupPath(group.id, "saved=access"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addGroupMember(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const groupId = String(formData.get("groupId") ?? "");
|
||||||
|
const userId = String(formData.get("userId") ?? "");
|
||||||
|
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
|
||||||
|
|
||||||
|
const [[group], [user]] = await Promise.all([
|
||||||
|
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
|
||||||
|
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
|
||||||
|
]);
|
||||||
|
if (!group || !user || group.isDefault) redirect("/admin/groups?error=invalid-membership");
|
||||||
|
|
||||||
|
const previousGroup = await db.transaction(async (tx) => {
|
||||||
|
const [previous] = await tx
|
||||||
|
.select({ id: groups.id, name: groups.name })
|
||||||
|
.from(userGroupMemberships)
|
||||||
|
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
||||||
|
.where(eq(userGroupMemberships.userId, user.id))
|
||||||
|
.limit(1);
|
||||||
|
await tx.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
|
||||||
|
await tx.insert(userGroupMemberships).values({ groupId: group.id, userId: user.id });
|
||||||
|
return previous ?? null;
|
||||||
|
});
|
||||||
|
await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.assignment-updated", {
|
||||||
|
groupId: group.id,
|
||||||
|
groupName: group.name,
|
||||||
|
previousGroupId: previousGroup?.id ?? null,
|
||||||
|
previousGroupName: previousGroup?.name ?? "everyone",
|
||||||
|
});
|
||||||
|
redirect(groupPath(group.id, "saved=member-added"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeGroupMember(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const groupId = String(formData.get("groupId") ?? "");
|
||||||
|
const userId = String(formData.get("userId") ?? "");
|
||||||
|
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
|
||||||
|
|
||||||
|
const [group] = await db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault })
|
||||||
|
.from(groups).where(eq(groups.id, groupId)).limit(1);
|
||||||
|
if (!group || group.isDefault) redirect("/admin/groups?error=invalid-membership");
|
||||||
|
|
||||||
|
await db.delete(userGroupMemberships).where(and(
|
||||||
|
eq(userGroupMemberships.groupId, group.id),
|
||||||
|
eq(userGroupMemberships.userId, userId),
|
||||||
|
));
|
||||||
|
await recordAdminSubjectEvent(admin, `user/${userId}`, "games.minecraft.account-manager.group.assignment-removed", {
|
||||||
|
groupId: group.id,
|
||||||
|
groupName: group.name,
|
||||||
|
fallbackGroup: "everyone",
|
||||||
|
});
|
||||||
|
redirect(groupPath(group.id, "saved=member-removed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignDefaultGroup(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const groupId = String(formData.get("groupId") ?? "");
|
||||||
|
const userId = String(formData.get("userId") ?? "");
|
||||||
|
if (!UUID_PATTERN.test(groupId) || !UUID_PATTERN.test(userId)) redirect("/admin/groups?error=invalid-membership");
|
||||||
|
|
||||||
|
const [[defaultGroup], [user]] = await Promise.all([
|
||||||
|
db.select({ id: groups.id, name: groups.name, isDefault: groups.isDefault }).from(groups).where(eq(groups.id, groupId)).limit(1),
|
||||||
|
db.select({ id: users.id }).from(users).where(eq(users.id, userId)).limit(1),
|
||||||
|
]);
|
||||||
|
if (!defaultGroup?.isDefault || !user) redirect("/admin/groups?error=invalid-membership");
|
||||||
|
|
||||||
|
const [previous] = await db
|
||||||
|
.select({ id: groups.id, name: groups.name })
|
||||||
|
.from(userGroupMemberships)
|
||||||
|
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
||||||
|
.where(eq(userGroupMemberships.userId, user.id))
|
||||||
|
.limit(1);
|
||||||
|
await db.delete(userGroupMemberships).where(eq(userGroupMemberships.userId, user.id));
|
||||||
|
await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.assignment-updated", {
|
||||||
|
groupId: defaultGroup.id,
|
||||||
|
groupName: defaultGroup.name,
|
||||||
|
previousGroupId: previous?.id ?? null,
|
||||||
|
previousGroupName: previous?.name ?? null,
|
||||||
|
});
|
||||||
|
redirect(groupPath(defaultGroup.id, "saved=member-added"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGroup(formData: FormData) {
|
||||||
|
const admin = await requireAdminSession();
|
||||||
|
const groupId = String(formData.get("groupId") ?? "");
|
||||||
|
const confirmed = formData.get("confirmDelete") === "yes";
|
||||||
|
if (!UUID_PATTERN.test(groupId) || !confirmed) redirect("/admin/groups?error=invalid-delete");
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true");
|
||||||
|
|
||||||
|
const deleted = await db.transaction(async (tx) => {
|
||||||
|
const [group] = await tx
|
||||||
|
.select({ id: groups.id, name: groups.name, slug: groups.slug, isDefault: groups.isDefault })
|
||||||
|
.from(groups)
|
||||||
|
.where(eq(groups.id, groupId))
|
||||||
|
.limit(1);
|
||||||
|
if (!group || group.isDefault) return null;
|
||||||
|
const members = await tx
|
||||||
|
.select({ userId: userGroupMemberships.userId })
|
||||||
|
.from(userGroupMemberships)
|
||||||
|
.where(eq(userGroupMemberships.groupId, group.id));
|
||||||
|
await tx.insert(events).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
source: "/web/admin",
|
||||||
|
type: "games.minecraft.account-manager.group.deleted",
|
||||||
|
subject: `group/${group.id}`,
|
||||||
|
time: new Date(),
|
||||||
|
data: {
|
||||||
|
name: group.name,
|
||||||
|
slug: group.slug,
|
||||||
|
affectedUsers: members.length,
|
||||||
|
fallbackGroup: "everyone",
|
||||||
|
adminEmail: admin.email,
|
||||||
|
adminName: admin.name,
|
||||||
|
},
|
||||||
|
ipAddress: ipAddress ?? null,
|
||||||
|
});
|
||||||
|
await tx.delete(groups).where(eq(groups.id, group.id));
|
||||||
|
return group;
|
||||||
|
});
|
||||||
|
if (!deleted) redirect("/admin/groups?error=protected-group");
|
||||||
|
|
||||||
|
redirect("/admin/groups?saved=deleted");
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||||
|
import { asc, desc } from "drizzle-orm";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { db } from "@/lib/database";
|
||||||
|
import { createGroup, setGroupAccess } from "./actions";
|
||||||
|
|
||||||
|
const errors: Record<string, string> = {
|
||||||
|
"invalid-group": "Enter a name and a lowercase slug containing letters, numbers, or hyphens.",
|
||||||
|
"duplicate-group": "That group slug already exists.",
|
||||||
|
"create-failed": "The group could not be created.",
|
||||||
|
"unknown-group": "That group no longer exists.",
|
||||||
|
"invalid-membership": "That membership change was invalid.",
|
||||||
|
"invalid-delete": "Confirm the group deletion before continuing.",
|
||||||
|
"protected-group": "The protected default group cannot be deleted.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function GroupsPage({ searchParams }: { searchParams: Promise<{ error?: string; saved?: string }> }) {
|
||||||
|
const query = await searchParams;
|
||||||
|
const [allGroups, memberships, registeredUsers] = await Promise.all([
|
||||||
|
db.select().from(groups).orderBy(desc(groups.isDefault), asc(groups.name)),
|
||||||
|
db.select({ groupId: userGroupMemberships.groupId }).from(userGroupMemberships),
|
||||||
|
db.select({ id: users.id }).from(users),
|
||||||
|
]);
|
||||||
|
const membershipCounts = new Map<string, number>();
|
||||||
|
for (const membership of memberships) {
|
||||||
|
membershipCounts.set(membership.groupId, (membershipCounts.get(membership.groupId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const explicitlyAssignedUsers = memberships.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||||
|
<header className="border-b border-line pb-8">
|
||||||
|
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Admission policy</p>
|
||||||
|
<h1 className="mt-4 font-display text-5xl font-black uppercase">Access groups</h1>
|
||||||
|
<p className="mt-5 max-w-2xl leading-7 text-muted">Each user has one effective group. Users without an explicit assignment fall back to <strong className="text-ink">everyone</strong>; Minecraft admission follows only that group’s access setting.</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errors[query.error] ?? "The group operation failed."}</p>}
|
||||||
|
{query.saved === "deleted" && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 text-sm" role="status">Group deleted. Its former members now use the default group.</p>}
|
||||||
|
|
||||||
|
<section className="mt-10 grid gap-5 md:grid-cols-2">
|
||||||
|
{allGroups.map((group) => {
|
||||||
|
const memberCount = group.isDefault ? registeredUsers.length - explicitlyAssignedUsers : membershipCounts.get(group.id) ?? 0;
|
||||||
|
return (
|
||||||
|
<article className="border border-line bg-panel p-6 shadow-[5px_5px_0_var(--color-shadow)]" key={group.id}>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<h2 className="font-display text-2xl font-black uppercase">{group.name}</h2>
|
||||||
|
{group.isDefault && <span className="bg-ink px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">Default</span>}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 font-mono text-[10px] text-muted">{group.slug} · {memberCount} members</p>
|
||||||
|
</div>
|
||||||
|
<span className={`px-3 py-2 font-mono text-[9px] font-bold uppercase ${group.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{group.accessEnabled ? "Access on" : "Access off"}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 min-h-12 text-sm leading-6 text-muted">{group.description ?? "No description."}</p>
|
||||||
|
<div className="mt-5 flex items-center justify-between gap-4 border-t border-line pt-4">
|
||||||
|
<Link className="font-mono text-[10px] font-bold uppercase underline underline-offset-4" href={`/admin/groups/${group.id}`}>Manage members</Link>
|
||||||
|
<form action={setGroupAccess}>
|
||||||
|
<input name="groupId" type="hidden" value={group.id} />
|
||||||
|
<input name="accessEnabled" type="hidden" value={group.accessEnabled ? "no" : "yes"} />
|
||||||
|
<button className="font-mono text-[10px] font-bold uppercase text-accent underline underline-offset-4" type="submit">Turn access {group.accessEnabled ? "off" : "on"}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form action={createGroup} className="mt-12 border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)]">
|
||||||
|
<p className="font-mono text-[10px] font-bold uppercase tracking-widest text-accent">Create a group</p>
|
||||||
|
<div className="mt-5 grid gap-5 sm:grid-cols-2">
|
||||||
|
<label className="text-sm font-bold">Name<input className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={50} name="name" required /></label>
|
||||||
|
<label className="text-sm font-bold">Slug<input className="mt-2 w-full border border-line bg-canvas px-4 py-3 font-mono font-normal outline-none focus:border-accent" maxLength={50} name="slug" pattern="[a-z0-9]+(?:-[a-z0-9]+)*" placeholder="ops" required /></label>
|
||||||
|
</div>
|
||||||
|
<label className="mt-5 block text-sm font-bold">Description<textarea className="mt-2 min-h-24 w-full border border-line bg-canvas px-4 py-3 font-normal outline-none focus:border-accent" maxLength={500} name="description" /></label>
|
||||||
|
<p className="mt-4 text-xs text-muted">New groups start with access disabled.</p>
|
||||||
|
<button className="mt-6 border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Create group</button>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import { AdminSignOutButton } from "@/components/admin-sign-out-button";
|
|||||||
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
|
import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function AdminConsoleLayout({ children }: { children: ReactNode }) {
|
export default async function AdminConsoleLayout({ children }: { children: ReactNode }) {
|
||||||
const session = await getServerSession(adminAuthOptions);
|
const session = await getServerSession(adminAuthOptions);
|
||||||
if (!session) redirect("/admin/login");
|
if (!session) redirect("/admin/login");
|
||||||
@@ -29,11 +31,13 @@ export default async function AdminConsoleLayout({ children }: { children: React
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-canvas text-ink">
|
<div className="min-h-screen bg-canvas text-ink">
|
||||||
<header className="border-b border-line bg-panel">
|
<header className="border-b border-line bg-panel">
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-6 py-5">
|
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-5 px-6 py-5">
|
||||||
<Link className="font-display text-sm font-black uppercase tracking-[0.2em]" href="/admin">Blocklist / Ops</Link>
|
<Link className="font-display text-sm font-black uppercase tracking-[0.2em]" href="/admin">SoMC Portal / Ops</Link>
|
||||||
<nav className="ml-auto mr-8 flex gap-5 font-mono text-[10px] font-bold uppercase tracking-widest">
|
<nav aria-label="Administrator" className="ml-auto flex flex-wrap gap-5 font-mono text-[10px] font-bold uppercase tracking-widest">
|
||||||
<Link className="hover:text-accent" href="/admin">Settings</Link>
|
<Link className="hover:text-accent" href="/admin">Dashboard</Link>
|
||||||
|
<Link className="hover:text-accent" href="/admin/settings">Settings</Link>
|
||||||
<Link className="hover:text-accent" href="/admin/users">Users</Link>
|
<Link className="hover:text-accent" href="/admin/users">Users</Link>
|
||||||
|
<Link className="hover:text-accent" href="/admin/groups">Groups</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,48 +1,180 @@
|
|||||||
import { eq } from "drizzle-orm";
|
import { events, ipIntelligence, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
|
||||||
import { appSettings } from "@minecraft-account-manager/database";
|
import { and, count, countDistinct, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm";
|
||||||
|
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 { saveDiscordSettings } from "./actions";
|
import { fillDailySeries, type DailyCount } from "@/lib/admin-metrics";
|
||||||
|
import { parseUserLocation } from "@/lib/user-location-map";
|
||||||
|
|
||||||
export default async function AdminPage({
|
export const dynamic = "force-dynamic";
|
||||||
searchParams,
|
|
||||||
}: {
|
export default async function AdminDashboardPage() {
|
||||||
searchParams: Promise<{ saved?: string; error?: string }>;
|
const now = new Date();
|
||||||
}) {
|
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1_000);
|
||||||
const query = await searchParams;
|
const fourteenDaysAgo = new Date(now.getTime() - 13 * 24 * 60 * 60 * 1_000);
|
||||||
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
fourteenDaysAgo.setUTCHours(0, 0, 0, 0);
|
||||||
const message = settings?.registrationMessage ?? "Please register your Minecraft account before joining.";
|
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1_000);
|
||||||
|
|
||||||
|
const [registrationRows, [totals], [monthlyActive], locationRows, riskyActivity, [recentDenials]] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
day: sql<string>`to_char(date_trunc('day', ${users.createdAt} at time zone 'UTC'), 'YYYY-MM-DD')`,
|
||||||
|
count: count(),
|
||||||
|
})
|
||||||
|
.from(users)
|
||||||
|
.where(gte(users.createdAt, fourteenDaysAgo))
|
||||||
|
.groupBy(sql`date_trunc('day', ${users.createdAt} at time zone 'UTC')`)
|
||||||
|
.orderBy(sql`date_trunc('day', ${users.createdAt} at time zone 'UTC')`),
|
||||||
|
db.select({ users: count(users.id) }).from(users),
|
||||||
|
db.select({
|
||||||
|
users: countDistinct(ipObservations.userId),
|
||||||
|
accounts: countDistinct(ipObservations.minecraftAccountId),
|
||||||
|
}).from(ipObservations).where(and(
|
||||||
|
gte(ipObservations.observedAt, thirtyDaysAgo),
|
||||||
|
isNotNull(ipObservations.userId),
|
||||||
|
)),
|
||||||
|
db
|
||||||
|
.selectDistinctOn([ipObservations.userId], {
|
||||||
|
userId: ipObservations.userId,
|
||||||
|
name: users.firstName,
|
||||||
|
discordUsername: users.discordUsername,
|
||||||
|
classification: ipObservations.classification,
|
||||||
|
source: ipObservations.source,
|
||||||
|
observedAt: ipObservations.observedAt,
|
||||||
|
intelligence: ipIntelligence.rawResponse,
|
||||||
|
})
|
||||||
|
.from(ipObservations)
|
||||||
|
.innerJoin(users, eq(users.id, ipObservations.userId))
|
||||||
|
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||||
|
.where(and(
|
||||||
|
isNotNull(ipObservations.userId),
|
||||||
|
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'latitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'latitude')::double precision between -90 and 90 else false end`,
|
||||||
|
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'longitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'longitude')::double precision between -180 and 180 else false end`,
|
||||||
|
))
|
||||||
|
.orderBy(ipObservations.userId, desc(ipObservations.observedAt), desc(ipObservations.id)),
|
||||||
|
db
|
||||||
|
.select({
|
||||||
|
id: ipObservations.id,
|
||||||
|
classification: ipObservations.classification,
|
||||||
|
observedAt: ipObservations.observedAt,
|
||||||
|
source: ipObservations.source,
|
||||||
|
userId: users.id,
|
||||||
|
firstName: users.firstName,
|
||||||
|
discordUsername: users.discordUsername,
|
||||||
|
accountUsername: minecraftAccounts.username,
|
||||||
|
})
|
||||||
|
.from(ipObservations)
|
||||||
|
.leftJoin(users, eq(users.id, ipObservations.userId))
|
||||||
|
.leftJoin(minecraftAccounts, eq(minecraftAccounts.id, ipObservations.minecraftAccountId))
|
||||||
|
.where(inArray(ipObservations.classification, ["vpn", "proxy", "tor"]))
|
||||||
|
.orderBy(desc(ipObservations.observedAt))
|
||||||
|
.limit(10),
|
||||||
|
db.select({ count: count() }).from(events).where(and(
|
||||||
|
eq(events.type, "games.minecraft.account-manager.game.login.denied"),
|
||||||
|
gte(events.time, oneDayAgo),
|
||||||
|
)),
|
||||||
|
]);
|
||||||
|
const registrations = fillDailySeries(registrationRows as DailyCount[], now, 14);
|
||||||
|
const locations = locationRows.flatMap((row): UserMapLocation[] => {
|
||||||
|
const parsed = parseUserLocation(row.intelligence);
|
||||||
|
if (!parsed || !row.userId) return [];
|
||||||
|
return [{
|
||||||
|
userId: row.userId,
|
||||||
|
name: row.name ?? row.discordUsername,
|
||||||
|
discordUsername: row.discordUsername,
|
||||||
|
latitude: parsed.latitude,
|
||||||
|
longitude: parsed.longitude,
|
||||||
|
location: parsed.label,
|
||||||
|
classification: row.classification,
|
||||||
|
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">
|
||||||
<div className="grid gap-10 lg:grid-cols-[0.7fr_1.3fr]">
|
<header className="border-b border-line pb-8">
|
||||||
<section>
|
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">Operations overview</p>
|
||||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">System settings</p>
|
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">Dashboard</h1>
|
||||||
<h1 className="mt-4 font-display text-5xl font-black uppercase leading-none tracking-tight">Server gate</h1>
|
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Live, server-rendered registration, activity, and network-risk signals from the account registry.</p>
|
||||||
<p className="mt-6 max-w-sm leading-7 text-muted">The Discord guild and invite are deployment environment settings. Operators can adjust the message shown to denied Minecraft players here.</p>
|
</header>
|
||||||
<dl className="mt-7 space-y-2 font-mono text-[10px] uppercase tracking-wider text-muted">
|
|
||||||
<div><dt className="inline font-bold text-ink">Guild:</dt> <dd className="inline">{process.env.DISCORD_GUILD_ID ? "configured" : "missing"}</dd></div>
|
<UserWorldMap locations={locations} unavailableCount={Math.max(0, (totals?.users ?? 0) - locations.length)} />
|
||||||
<div><dt className="inline font-bold text-ink">Invite:</dt> <dd className="inline">{process.env.DISCORD_INVITE_URL ? "configured" : "missing"}</dd></div>
|
|
||||||
</dl>
|
<section aria-label="Key metrics" className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Metric label="Registered users" value={totals?.users ?? 0} detail="All time" />
|
||||||
|
<Metric label="Monthly active users" value={monthlyActive?.users ?? 0} detail="Distinct users · 30 days" />
|
||||||
|
<Metric label="Active Minecraft accounts" value={monthlyActive?.accounts ?? 0} detail="Distinct accounts · 30 days" />
|
||||||
|
<Metric label="Login denials" value={recentDenials?.count ?? 0} detail="Past 24 hours" accent />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="mt-10 grid gap-8 lg:grid-cols-[1.3fr_0.7fr]">
|
||||||
|
<RegistrationChart data={registrations} />
|
||||||
|
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Network review</p><h2 className="mt-2 font-display text-2xl font-black uppercase">Recent VPN activity</h2></div>
|
||||||
|
<Link className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" href="/admin/events?category=security">All security events</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mt-5 divide-y divide-line">
|
||||||
|
{riskyActivity.map((activity) => (
|
||||||
|
<article className="py-4" key={activity.id}>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
{activity.userId ? <Link className="font-mono text-xs font-bold underline decoration-line underline-offset-4" href={`/admin/users/${activity.userId}`}>{activity.firstName ?? activity.discordUsername ?? "Unknown user"}</Link> : <span className="font-mono text-xs font-bold">Unknown user</span>}
|
||||||
|
<p className="mt-1 text-xs text-muted">{activity.accountUsername ?? "No Minecraft account"} · {activity.source}</p>
|
||||||
|
</div>
|
||||||
|
<span className="bg-accent px-2 py-1 font-mono text-[9px] font-bold uppercase text-canvas">{activity.classification}</span>
|
||||||
|
</div>
|
||||||
|
<time className="mt-2 block font-mono text-[9px] text-muted" dateTime={activity.observedAt.toISOString()}>{activity.observedAt.toISOString()}</time>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
{!riskyActivity.length && <p className="py-6 text-sm text-muted">No recent VPN, proxy, or Tor observations.</p>}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<form action={saveDiscordSettings} className="border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)] sm:p-9">
|
|
||||||
{query.saved && <p className="mb-6 border-l-2 border-signal pl-4 font-mono text-xs font-bold uppercase tracking-wider">Settings saved</p>}
|
|
||||||
{query.error && <p className="mb-6 border-l-2 border-accent pl-4 text-sm">Check the highlighted configuration values and try again.</p>}
|
|
||||||
|
|
||||||
<label className="block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="registrationMessage">Denied-player message</label>
|
|
||||||
<textarea
|
|
||||||
className="mt-3 min-h-32 w-full resize-y border border-line bg-canvas px-4 py-3 text-sm leading-6 outline-none focus:border-accent"
|
|
||||||
defaultValue={message}
|
|
||||||
id="registrationMessage"
|
|
||||||
maxLength={500}
|
|
||||||
minLength={10}
|
|
||||||
name="registrationMessage"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button className="mt-8 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-[0.16em] text-canvas hover:bg-accent" type="submit">Save configuration</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Metric({ label, value, detail, accent = false }: { label: string; value: number; detail: string; accent?: boolean }) {
|
||||||
|
return (
|
||||||
|
<article className={`border p-5 ${accent ? "border-accent bg-ink text-canvas" : "border-line bg-panel"}`}>
|
||||||
|
<p className={`font-mono text-[9px] font-bold uppercase tracking-widest ${accent ? "text-signal" : "text-muted"}`}>{label}</p>
|
||||||
|
<p className="mt-3 font-display text-5xl font-black">{value}</p>
|
||||||
|
<p className={`mt-2 text-xs ${accent ? "text-canvas" : "text-muted"}`}>{detail}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RegistrationChart({ data }: { data: DailyCount[] }) {
|
||||||
|
const width = 720;
|
||||||
|
const height = 260;
|
||||||
|
const padding = 32;
|
||||||
|
const maximum = Math.max(1, ...data.map((entry) => entry.count));
|
||||||
|
const points = data.map((entry, index) => {
|
||||||
|
const x = padding + index * ((width - padding * 2) / Math.max(1, data.length - 1));
|
||||||
|
const y = height - padding - (entry.count / maximum) * (height - padding * 2);
|
||||||
|
return `${x},${y}`;
|
||||||
|
}).join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="border border-line bg-panel p-6 shadow-[6px_6px_0_var(--color-shadow)]">
|
||||||
|
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Growth signal</p>
|
||||||
|
<h2 className="mt-2 font-display text-2xl font-black uppercase">New users by day</h2>
|
||||||
|
<svg aria-labelledby="registration-chart-title registration-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>
|
||||||
|
<desc id="registration-chart-description">Daily registrations range from zero to {maximum}. A text summary follows the chart.</desc>
|
||||||
|
<line stroke="var(--line)" strokeWidth="1" x1={padding} x2={width - padding} y1={height - padding} y2={height - padding} />
|
||||||
|
<polyline fill="none" points={points} stroke="var(--accent)" strokeLinecap="square" strokeLinejoin="miter" strokeWidth="4" />
|
||||||
|
{data.map((entry, index) => {
|
||||||
|
const [x, y] = points.split(" ")[index]!.split(",");
|
||||||
|
return <circle cx={x} cy={y} fill="var(--panel)" key={entry.day} r="5" stroke="var(--ink)" strokeWidth="3"><title>{entry.day}: {entry.count} new users</title></circle>;
|
||||||
|
})}
|
||||||
|
</svg>
|
||||||
|
<dl className="mt-4 grid grid-cols-7 gap-2 border-t border-line pt-4 text-center">
|
||||||
|
{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>)}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { appSettings } from "@minecraft-account-manager/database";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/lib/database";
|
||||||
|
import { saveDiscordSettings } from "../actions";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function SettingsPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ saved?: string; error?: string }>;
|
||||||
|
}) {
|
||||||
|
const query = await searchParams;
|
||||||
|
const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1);
|
||||||
|
const message = settings?.registrationMessage ?? "Please register your Minecraft account before joining.";
|
||||||
|
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
||||||
|
const inviteUrl = process.env.DISCORD_INVITE_URL?.trim();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||||
|
<div className="grid gap-10 lg:grid-cols-[0.7fr_1.3fr]">
|
||||||
|
<section>
|
||||||
|
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">System settings</p>
|
||||||
|
<h1 className="mt-4 font-display text-5xl font-black uppercase leading-none tracking-tight">Server gate</h1>
|
||||||
|
<p className="mt-6 max-w-sm leading-7 text-muted">The Discord guild and invite are deployment environment settings. Operators can adjust the message shown to denied Minecraft players here.</p>
|
||||||
|
<dl className="mt-7 space-y-4 font-mono text-[10px] uppercase tracking-wider text-muted">
|
||||||
|
<div><dt className="font-bold text-ink">Guild ID</dt><dd className="mt-1 break-all normal-case">{guildId ?? "Missing"}</dd></div>
|
||||||
|
<div><dt className="font-bold text-ink">Invite URL</dt><dd className="mt-1 break-all normal-case">{inviteUrl ? <a className="text-ink underline decoration-accent underline-offset-4" href={inviteUrl} rel="noreferrer" target="_blank">{inviteUrl}<span className="sr-only"> (opens in a new tab)</span></a> : "Missing"}</dd></div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form action={saveDiscordSettings} className="border border-line bg-panel p-7 shadow-[8px_8px_0_var(--color-shadow)] sm:p-9">
|
||||||
|
{query.saved && <p className="mb-6 border-l-2 border-signal pl-4 font-mono text-xs font-bold uppercase tracking-wider" role="status">Settings saved</p>}
|
||||||
|
{query.error && <p className="mb-6 border-l-2 border-accent pl-4 text-sm" role="alert">Check the configuration value and try again.</p>}
|
||||||
|
|
||||||
|
<label className="block font-mono text-xs font-bold uppercase tracking-wider" htmlFor="registrationMessage">Denied-player message</label>
|
||||||
|
<textarea
|
||||||
|
className="mt-3 min-h-32 w-full resize-y border border-line bg-canvas px-4 py-3 text-sm leading-6 outline-none focus:border-accent"
|
||||||
|
defaultValue={message}
|
||||||
|
id="registrationMessage"
|
||||||
|
maxLength={500}
|
||||||
|
minLength={10}
|
||||||
|
name="registrationMessage"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button className="mt-8 border border-ink bg-ink px-6 py-3 font-mono text-xs font-bold uppercase tracking-[0.16em] text-canvas hover:bg-accent" type="submit">Save configuration</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
|
||||||
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
|
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft";
|
||||||
import { events, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
|
import { events, groups, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database";
|
||||||
import { and, desc, eq, isNull, or } from "drizzle-orm";
|
import { and, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
|
import { groupAccessAddresses } from "@/lib/access-address-groups";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
|
import { discordIdentity } from "@/lib/discord-identity";
|
||||||
|
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
|
||||||
import { eventIpSummary } from "@/lib/event-ip-summary";
|
import { eventIpSummary } from "@/lib/event-ip-summary";
|
||||||
import {
|
import {
|
||||||
addUserMinecraftAccount,
|
addUserMinecraftAccount,
|
||||||
@@ -31,37 +35,69 @@ const savedMessages: Record<string, string> = {
|
|||||||
nickname: "Discord nickname synchronized.",
|
nickname: "Discord nickname synchronized.",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function queryValues(value: string | string[] | undefined) {
|
||||||
|
return Array.isArray(value) ? value : value ? [value] : [];
|
||||||
|
}
|
||||||
|
|
||||||
export default async function AdminUserPage({
|
export default async function AdminUserPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ userId: string }>;
|
params: Promise<{ userId: string }>;
|
||||||
searchParams: Promise<{ error?: string; saved?: string; unverified?: string }>;
|
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||||
}) {
|
}) {
|
||||||
const { userId } = await params;
|
const { userId } = await params;
|
||||||
const query = await searchParams;
|
const query = await searchParams;
|
||||||
|
const error = queryValues(query.error)[0];
|
||||||
|
const saved = queryValues(query.saved)[0];
|
||||||
|
const unverified = queryValues(query.unverified)[0];
|
||||||
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
|
||||||
if (!user) notFound();
|
if (!user) notFound();
|
||||||
|
|
||||||
const [accounts, recentEvents, observations] = await Promise.all([
|
const userEventCondition = or(eq(events.subject, `user/${user.id}`), eq(events.actorUserId, user.id));
|
||||||
|
const eventTypeRows = await db.select({ type: events.type }).from(events).where(userEventCondition).groupBy(events.type).orderBy(events.type);
|
||||||
|
const availableEventTypes = eventTypeRows.map((row) => row.type);
|
||||||
|
const selectedCategory = normalizeEventCategory(queryValues(query.eventCategory)[0]);
|
||||||
|
const selectedEventTypes = normalizeSelectedEventTypes(query.eventType, availableEventTypes);
|
||||||
|
const categoryTypes = selectedCategory === "all"
|
||||||
|
? availableEventTypes
|
||||||
|
: availableEventTypes.filter((type) => eventCategory(type) === selectedCategory);
|
||||||
|
const filteredEventTypes = selectedEventTypes.length
|
||||||
|
? selectedEventTypes.filter((type) => categoryTypes.includes(type))
|
||||||
|
: categoryTypes;
|
||||||
|
|
||||||
|
const recentEventsQuery = filteredEventTypes.length
|
||||||
|
? db.select().from(events).where(and(userEventCondition, inArray(events.type, filteredEventTypes))).orderBy(desc(events.time)).limit(30)
|
||||||
|
: Promise.resolve([]);
|
||||||
|
|
||||||
|
const [accounts, recentEvents, observations, discord, availableGroups] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(minecraftAccounts)
|
.from(minecraftAccounts)
|
||||||
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
|
.where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)))
|
||||||
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
|
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
|
||||||
db
|
recentEventsQuery,
|
||||||
.select()
|
|
||||||
.from(events)
|
|
||||||
.where(or(eq(events.subject, `user/${user.id}`), eq(events.actorUserId, user.id)))
|
|
||||||
.orderBy(desc(events.time))
|
|
||||||
.limit(30),
|
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(ipObservations)
|
.from(ipObservations)
|
||||||
.where(eq(ipObservations.userId, user.id))
|
.where(eq(ipObservations.userId, user.id))
|
||||||
.orderBy(desc(ipObservations.observedAt))
|
.orderBy(desc(ipObservations.observedAt))
|
||||||
.limit(20),
|
.limit(100),
|
||||||
|
discordIdentity(user),
|
||||||
|
db.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled, isDefault: groups.isDefault })
|
||||||
|
.from(groups)
|
||||||
|
.leftJoin(userGroupMemberships, eq(userGroupMemberships.groupId, groups.id))
|
||||||
|
.where(or(eq(groups.isDefault, true), eq(userGroupMemberships.userId, user.id)))
|
||||||
|
.orderBy(desc(groups.isDefault), groups.name),
|
||||||
]);
|
]);
|
||||||
|
const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null;
|
||||||
|
const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null;
|
||||||
|
const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup);
|
||||||
|
const addressGroups = groupAccessAddresses(
|
||||||
|
observations.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)
|
||||||
@@ -74,7 +110,12 @@ export default async function AdminUserPage({
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">User record</p>
|
<p className="font-mono text-xs font-bold uppercase tracking-[0.25em] text-accent">User record</p>
|
||||||
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">{user.firstName ?? "Name needed"}</h1>
|
<h1 className="mt-4 font-display text-5xl font-black uppercase sm:text-7xl">{user.firstName ?? "Name needed"}</h1>
|
||||||
<p className="mt-3 font-mono text-xs text-muted">@{user.discordUsername} · {user.discordUserId}</p>
|
<dl className="mt-4 grid gap-x-8 gap-y-2 font-mono text-[10px] text-muted sm:grid-cols-2">
|
||||||
|
<div><dt className="uppercase tracking-wider">Discord name</dt><dd className="mt-1 text-xs text-ink">{discord.globalName ?? discord.username}</dd></div>
|
||||||
|
<div><dt className="uppercase tracking-wider">Discord username</dt><dd className="mt-1 text-xs text-ink">@{discord.username}</dd></div>
|
||||||
|
<div><dt className="uppercase tracking-wider">Guild nickname</dt><dd className="mt-1 text-xs text-ink">{discord.nickname ?? "No guild nickname"}</dd></div>
|
||||||
|
<div><dt className="uppercase tracking-wider">Discord ID</dt><dd className="mt-1 break-all text-xs text-ink">{discord.id}</dd></div>
|
||||||
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
<div className="border-l-2 border-accent pl-5">
|
<div className="border-l-2 border-accent pl-5">
|
||||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Expected Discord nickname</p>
|
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Expected Discord nickname</p>
|
||||||
@@ -83,8 +124,8 @@ export default async function AdminUserPage({
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent">{errorMessages[query.error] ?? "The requested operation failed."}</p>}
|
{error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">{errorMessages[error] ?? "The requested operation failed."}</p>}
|
||||||
{query.saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider">{savedMessages[query.saved] ?? "Changes saved."}</p>}
|
{saved && <p className="mt-7 border-l-2 border-signal bg-panel px-5 py-4 font-mono text-xs font-bold uppercase tracking-wider" role="status">{savedMessages[saved] ?? "Changes saved."}</p>}
|
||||||
|
|
||||||
<div className="mt-10 grid gap-10 lg:grid-cols-[1.3fr_0.7fr]">
|
<div className="mt-10 grid gap-10 lg:grid-cols-[1.3fr_0.7fr]">
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
@@ -120,10 +161,10 @@ export default async function AdminUserPage({
|
|||||||
{!accounts.length && <p className="py-7 text-sm text-muted">No active Minecraft accounts.</p>}
|
{!accounts.length && <p className="py-7 text-sm text-muted">No active Minecraft accounts.</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{query.unverified ? (
|
{unverified ? (
|
||||||
<form action={addUserMinecraftAccount} className="border-l-2 border-accent bg-panel p-6">
|
<form action={addUserMinecraftAccount} className="border-l-2 border-accent bg-panel p-6">
|
||||||
<input name="userId" type="hidden" value={user.id} /><input name="username" type="hidden" value={query.unverified} /><input name="forceUnverified" type="hidden" value="yes" />
|
<input name="userId" type="hidden" value={user.id} /><input name="username" type="hidden" value={unverified} /><input name="forceUnverified" type="hidden" value="yes" />
|
||||||
<h3 className="font-display text-xl font-black uppercase">Mojang couldn’t verify “{query.unverified}”</h3>
|
<h3 className="font-display text-xl font-black uppercase">Mojang couldn’t verify “{unverified}”</h3>
|
||||||
<p className="mt-2 text-sm leading-6 text-muted">Only override this when you have independently confirmed the spelling.</p>
|
<p className="mt-2 text-sm leading-6 text-muted">Only override this when you have independently confirmed the spelling.</p>
|
||||||
<button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas" type="submit">Add unverified account</button>
|
<button className="mt-5 border border-ink bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider text-canvas" type="submit">Add unverified account</button>
|
||||||
<a className="ml-5 font-mono text-[9px] font-bold uppercase underline" href={`/admin/users/${user.id}`}>Cancel</a>
|
<a className="ml-5 font-mono text-[9px] font-bold uppercase underline" href={`/admin/users/${user.id}`}>Cancel</a>
|
||||||
@@ -131,7 +172,7 @@ export default async function AdminUserPage({
|
|||||||
) : (
|
) : (
|
||||||
<form action={addUserMinecraftAccount} className="flex flex-col gap-3 border-t border-line pt-6 sm:flex-row">
|
<form action={addUserMinecraftAccount} className="flex flex-col gap-3 border-t border-line pt-6 sm:flex-row">
|
||||||
<input name="userId" type="hidden" value={user.id} />
|
<input name="userId" type="hidden" value={user.id} />
|
||||||
<input className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-sm outline-none focus:border-accent" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" placeholder="Minecraft username" required />
|
<input aria-label="Minecraft username" className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-sm outline-none focus:border-accent" maxLength={16} minLength={3} name="username" pattern="[A-Za-z0-9_]{3,16}" placeholder="Minecraft username" required />
|
||||||
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Verify and add</button>
|
<button className="border border-ink bg-ink px-5 py-3 font-mono text-[10px] font-bold uppercase tracking-wider text-canvas" type="submit">Verify and add</button>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
@@ -139,11 +180,16 @@ export default async function AdminUserPage({
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Audit trail</p>
|
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Audit trail</p>
|
||||||
<h2 className="mt-2 border-b border-line pb-4 font-display text-3xl font-black uppercase">Recent events</h2>
|
<h2 className="mt-2 font-display text-3xl font-black uppercase">Recent events</h2>
|
||||||
|
<form className="mt-4 grid gap-4 border-y border-line bg-panel p-4 sm:grid-cols-2" method="get">
|
||||||
|
<label className="font-mono text-[10px] font-bold uppercase" htmlFor="user-event-category">View<select className="mt-2 block w-full border border-line bg-canvas p-2 font-sans text-sm font-normal normal-case" defaultValue={selectedCategory} id="user-event-category" name="eventCategory">{eventCategoryValues.map((value) => <option key={value} value={value}>{value === "all" ? "All activity" : value}</option>)}</select></label>
|
||||||
|
<fieldset><legend className="font-mono text-[10px] font-bold uppercase">Types</legend><details className="mt-2 border border-line bg-canvas p-2"><summary className="cursor-pointer font-mono text-[9px] underline">{selectedEventTypes.length ? `${selectedEventTypes.length} selected` : "All types"}</summary><div className="mt-3 max-h-44 space-y-2 overflow-y-auto">{availableEventTypes.map((type) => <label className="flex items-start gap-2 font-mono text-[9px]" key={type}><input className="mt-0.5 size-4" defaultChecked={selectedEventTypes.includes(type)} name="eventType" type="checkbox" value={type} /><span className="break-all">{type}</span></label>)}</div></details></fieldset>
|
||||||
|
<div className="flex gap-4 sm:col-span-2"><button className="bg-ink px-4 py-2 font-mono text-[9px] font-bold uppercase text-canvas" type="submit">Filter events</button><Link className="self-center font-mono text-[9px] font-bold uppercase underline" href={`/admin/users/${user.id}`}>Clear</Link></div>
|
||||||
|
</form>
|
||||||
<div className="divide-y divide-line">
|
<div className="divide-y divide-line">
|
||||||
{recentEvents.map((event) => {
|
{recentEvents.map((event) => {
|
||||||
const ip = eventIpSummary(event.data);
|
const ip = eventIpSummary(event.data);
|
||||||
return <div className="grid gap-2 py-4 sm:grid-cols-[1fr_auto]" key={event.id}><div><p className="font-mono text-xs font-bold">{event.type}</p>{(ip.location || ip.classification) && <p className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"} · {ip.classification ?? "unknown"}</p>}</div><span className="font-mono text-[9px] text-muted">{event.time.toISOString()}</span></div>;
|
return <article className="grid gap-2 py-4 sm:grid-cols-[1fr_auto]" key={event.id}><div><h3 className="font-mono text-xs font-bold"><Link className="underline decoration-line underline-offset-4 hover:decoration-accent" href={`/admin/events/${event.id}`}>{event.type}</Link></h3>{(ip.location || ip.classification) && <p className="mt-1 text-xs text-muted">{ip.location ?? "Location unavailable"} · {ip.classification ?? "unknown"}</p>}</div><time className="font-mono text-[9px] text-muted" dateTime={event.time.toISOString()}>{event.time.toISOString()}</time></article>;
|
||||||
})}
|
})}
|
||||||
{!recentEvents.length && <p className="py-6 text-sm text-muted">No events recorded for this user.</p>}
|
{!recentEvents.length && <p className="py-6 text-sm text-muted">No events recorded for this user.</p>}
|
||||||
</div>
|
</div>
|
||||||
@@ -160,11 +206,26 @@ export default async function AdminUserPage({
|
|||||||
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider" type="submit">Save and synchronize</button>
|
<button className="mt-6 border border-ink px-4 py-2 font-mono text-[9px] font-bold uppercase tracking-wider" type="submit">Save and synchronize</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<section className="border border-line bg-panel p-6">
|
||||||
|
<div className="flex items-center justify-between gap-3"><p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Access groups</p><Link className="font-mono text-[9px] font-bold uppercase underline underline-offset-4" href="/admin/groups">Manage</Link></div>
|
||||||
|
{effectiveGroup ? <div className="mt-4 flex items-center justify-between gap-3"><Link className="font-mono text-xs font-bold underline decoration-line underline-offset-4" href={`/admin/groups/${effectiveGroup.id}`}>{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}</Link><span className={`px-2 py-1 font-mono text-[9px] font-bold uppercase ${effectiveGroup.accessEnabled ? "bg-signal text-ink" : "bg-accent text-canvas"}`}>{effectiveGroup.accessEnabled ? "On" : "Off"}</span></div> : <p className="mt-4 text-sm text-accent">No effective group configured.</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
<section className="border border-line bg-panel p-6">
|
<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>
|
||||||
<div className="mt-4 divide-y divide-line">
|
<div className="mt-4 divide-y divide-line">
|
||||||
{observations.map((observation) => <div className="py-3" key={observation.id}><p className="font-mono text-xs">{observation.ipAddress}</p><p className="mt-1 font-mono text-[9px] text-muted">{observation.source} · {observation.observedAt.toISOString()}</p></div>)}
|
{addressGroups.map((group) => (
|
||||||
{!observations.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
|
<div className="py-3" key={group.network}>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="font-mono text-xs font-bold">{group.network}</p>
|
||||||
|
<span className="font-mono text-[9px] text-muted">×{group.count}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 font-mono text-[9px] text-muted">{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}</p>
|
||||||
|
<p className="mt-1 break-all font-mono text-[9px] text-muted">Latest {group.latestAddress}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!addressGroups.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { and, eq, ilike, isNull, or, sql } from "drizzle-orm";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function AdminUsersPage({
|
export default async function AdminUsersPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
@@ -15,6 +17,7 @@ export default async function AdminUsersPage({
|
|||||||
? or(
|
? or(
|
||||||
ilike(users.firstName, pattern),
|
ilike(users.firstName, pattern),
|
||||||
ilike(users.discordUsername, pattern),
|
ilike(users.discordUsername, pattern),
|
||||||
|
ilike(users.discordGlobalName, pattern),
|
||||||
eq(users.discordUserId, search),
|
eq(users.discordUserId, search),
|
||||||
sql`exists (
|
sql`exists (
|
||||||
select 1 from ${minecraftAccounts}
|
select 1 from ${minecraftAccounts}
|
||||||
@@ -33,6 +36,7 @@ export default async function AdminUsersPage({
|
|||||||
id: users.id,
|
id: users.id,
|
||||||
firstName: users.firstName,
|
firstName: users.firstName,
|
||||||
discordUsername: users.discordUsername,
|
discordUsername: users.discordUsername,
|
||||||
|
discordGlobalName: users.discordGlobalName,
|
||||||
discordUserId: users.discordUserId,
|
discordUserId: users.discordUserId,
|
||||||
onboardingCompletedAt: users.onboardingCompletedAt,
|
onboardingCompletedAt: users.onboardingCompletedAt,
|
||||||
primaryUsername: minecraftAccounts.username,
|
primaryUsername: minecraftAccounts.username,
|
||||||
@@ -64,6 +68,7 @@ export default async function AdminUsersPage({
|
|||||||
</div>
|
</div>
|
||||||
<form className="flex w-full max-w-md gap-2" method="get">
|
<form className="flex w-full max-w-md gap-2" method="get">
|
||||||
<input
|
<input
|
||||||
|
aria-label="Search users"
|
||||||
className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-xs outline-none focus:border-accent"
|
className="min-w-0 flex-1 border border-line bg-panel px-4 py-3 font-mono text-xs outline-none focus:border-accent"
|
||||||
defaultValue={search}
|
defaultValue={search}
|
||||||
name="q"
|
name="q"
|
||||||
@@ -74,18 +79,19 @@ 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">The requested user could not be found.</p>}
|
{query.error && <p className="mt-7 border-l-2 border-accent bg-panel px-5 py-4 text-sm text-accent" role="alert">The requested user could not be found.</p>}
|
||||||
|
|
||||||
<div className="mt-8 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
<div className="mt-8 overflow-x-auto border border-line bg-panel shadow-[8px_8px_0_var(--color-shadow)]">
|
||||||
<table className="w-full min-w-[760px] border-collapse text-left">
|
<table className="w-full min-w-[760px] border-collapse text-left">
|
||||||
|
<caption className="sr-only">Registered portal users</caption>
|
||||||
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
|
<thead className="border-b border-line font-mono text-[10px] uppercase tracking-widest text-muted">
|
||||||
<tr><th className="p-4">User</th><th className="p-4">Discord</th><th className="p-4">Primary</th><th className="p-4">Accounts</th><th className="p-4">Status</th></tr>
|
<tr><th className="p-4" scope="col">User</th><th className="p-4" scope="col">Discord</th><th className="p-4" scope="col">Primary</th><th className="p-4" scope="col">Accounts</th><th className="p-4" scope="col">Status</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-line">
|
<tbody className="divide-y divide-line">
|
||||||
{results.map((user) => (
|
{results.map((user) => (
|
||||||
<tr className="transition-colors hover:bg-canvas/60" key={user.id}>
|
<tr className="transition-colors hover:bg-canvas/60" key={user.id}>
|
||||||
<td className="p-4"><Link className="font-display text-lg font-black underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? "Name needed"}</Link></td>
|
<th className="p-4 text-left" scope="row"><Link className="font-display text-lg font-black underline decoration-line underline-offset-4 hover:text-accent" href={`/admin/users/${user.id}`}>{user.firstName ?? "Name needed"}</Link></th>
|
||||||
<td className="p-4"><div className="font-mono text-xs">@{user.discordUsername}</div><div className="mt-1 font-mono text-[9px] text-muted">{user.discordUserId}</div></td>
|
<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.primaryUsername ?? "—"}</td>
|
||||||
<td className="p-4 font-mono text-xs">{user.accountCount}</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>
|
<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>
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { isRequestTimestampFresh, verifyHashedToken } from "@minecraft-account-manager/auth";
|
import { isRequestTimestampFresh, resolveEffectiveGroup, verifyHashedToken } from "@minecraft-account-manager/auth";
|
||||||
import { problemDetails, velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
|
import { problemDetails, velocityAccessRequestSchema } from "@minecraft-account-manager/contracts";
|
||||||
import {
|
import {
|
||||||
appSettings,
|
appSettings,
|
||||||
events,
|
events,
|
||||||
|
groups,
|
||||||
ipObservations,
|
ipObservations,
|
||||||
minecraftAccounts,
|
minecraftAccounts,
|
||||||
pluginCredentials,
|
pluginCredentials,
|
||||||
pluginRequests,
|
pluginRequests,
|
||||||
|
userGroupMemberships,
|
||||||
} 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 { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
import { isUniqueConstraintViolation } from "@/lib/database-errors";
|
import { isUniqueConstraintViolation } from "@/lib/database-errors";
|
||||||
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
||||||
|
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;
|
||||||
@@ -209,6 +212,48 @@ async function handleVelocityAccess(request: Request) {
|
|||||||
return { allowed: false as const, message: denialMessage };
|
return { allowed: false as const, message: denialMessage };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [explicitGroup] = await tx
|
||||||
|
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled })
|
||||||
|
.from(userGroupMemberships)
|
||||||
|
.innerJoin(groups, eq(groups.id, userGroupMemberships.groupId))
|
||||||
|
.where(eq(userGroupMemberships.userId, account.userId))
|
||||||
|
.limit(1);
|
||||||
|
const [defaultGroup] = await tx
|
||||||
|
.select({ id: groups.id, name: groups.name, accessEnabled: groups.accessEnabled })
|
||||||
|
.from(groups)
|
||||||
|
.where(eq(groups.isDefault, true))
|
||||||
|
.limit(1);
|
||||||
|
const effectiveGroup = resolveEffectiveGroup(explicitGroup ?? null, defaultGroup ?? null);
|
||||||
|
|
||||||
|
if (!effectiveGroup?.accessEnabled) {
|
||||||
|
await tx.insert(events).values({
|
||||||
|
id: randomUUID(),
|
||||||
|
source: `/velocity/${input.serverId}`,
|
||||||
|
type: "games.minecraft.account-manager.game.login.denied",
|
||||||
|
subject: `minecraft-account/${account.id}`,
|
||||||
|
time: occurredAt,
|
||||||
|
actorUserId: account.userId,
|
||||||
|
data: {
|
||||||
|
username: input.username,
|
||||||
|
reason: "group_access_disabled",
|
||||||
|
ipIntelligence: auditIpData,
|
||||||
|
},
|
||||||
|
ipAddress: input.ipAddress,
|
||||||
|
correlationId: input.requestId,
|
||||||
|
});
|
||||||
|
await tx.insert(ipObservations).values({
|
||||||
|
userId: account.userId,
|
||||||
|
minecraftAccountId: account.id,
|
||||||
|
source: "game",
|
||||||
|
ipAddress: input.ipAddress,
|
||||||
|
minecraftUuid: input.minecraftUuid,
|
||||||
|
username: input.username,
|
||||||
|
classification: intelligence.classification,
|
||||||
|
observedAt: occurredAt,
|
||||||
|
});
|
||||||
|
return { allowed: false as const, message: "Your account group does not currently have server access." };
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
@@ -258,6 +303,7 @@ async function handleVelocityAccess(request: Request) {
|
|||||||
previousUsername: account.username === input.username ? null : account.username,
|
previousUsername: account.username === input.username ? null : account.username,
|
||||||
uuidBackfilled: account.minecraftUuid === null,
|
uuidBackfilled: account.minecraftUuid === null,
|
||||||
ipIntelligence: auditIpData,
|
ipIntelligence: auditIpData,
|
||||||
|
accessGroup: effectiveGroup.name,
|
||||||
},
|
},
|
||||||
ipAddress: input.ipAddress,
|
ipAddress: input.ipAddress,
|
||||||
correlationId: input.requestId,
|
correlationId: input.requestId,
|
||||||
@@ -284,7 +330,10 @@ export async function POST(request: Request) {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("Velocity access request failed");
|
logger.error(
|
||||||
|
{ err: error, event: "velocity.access_failed", instance },
|
||||||
|
"Velocity access request failed",
|
||||||
|
);
|
||||||
return problemResponse(problemDetails(
|
return problemResponse(problemDetails(
|
||||||
"urn:error:service-unavailable",
|
"urn:error:service-unavailable",
|
||||||
"Service unavailable",
|
"Service unavailable",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createAuthRepository, ipObservations, recordEvent } from "@minecraft-ac
|
|||||||
import { getClientIp } from "@minecraft-account-manager/network";
|
import { getClientIp } from "@minecraft-account-manager/network";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
import { applicationUrl } from "@/lib/application-url";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence";
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ export async function GET(request: NextRequest) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const destination = result.user.firstName ? "/account" : "/welcome";
|
const destination = result.user.firstName ? "/account" : "/welcome";
|
||||||
const response = NextResponse.redirect(new URL(destination, request.url));
|
const response = NextResponse.redirect(applicationUrl(destination));
|
||||||
response.cookies.set(SESSION_COOKIE_NAME, result.sessionToken, {
|
response.cookies.set(SESSION_COOKIE_NAME, result.sessionToken, {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: process.env.NODE_ENV === "production",
|
secure: process.env.NODE_ENV === "production",
|
||||||
@@ -49,7 +50,7 @@ export async function GET(request: NextRequest) {
|
|||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof InvalidLoginCodeError) {
|
if (error instanceof InvalidLoginCodeError) {
|
||||||
return NextResponse.redirect(new URL("/auth/error", request.url));
|
return NextResponse.redirect(applicationUrl("/auth/error"));
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
--ink: #171916;
|
--ink: #171916;
|
||||||
--muted: #57594f;
|
--muted: #57594f;
|
||||||
--line: #9e9a88;
|
--line: #9e9a88;
|
||||||
--accent: #bc3f24;
|
--accent: #a32f1b;
|
||||||
--signal: #b5d452;
|
--signal: #b5d452;
|
||||||
--shadow: #262a23;
|
--shadow: #262a23;
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,33 @@ body {
|
|||||||
background: var(--canvas);
|
background: var(--canvas);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 3px solid var(--accent);
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link {
|
||||||
|
position: fixed;
|
||||||
|
left: 1rem;
|
||||||
|
top: 1rem;
|
||||||
|
z-index: 100;
|
||||||
|
transform: translateY(-200%);
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--panel);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg a:hover .map-marker,
|
||||||
|
svg a:focus .map-marker {
|
||||||
|
stroke: var(--ink);
|
||||||
|
stroke-width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: var(--panel);
|
color: var(--panel);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<rect width="64" height="64" rx="8" fill="#171916"/>
|
||||||
|
<path d="M9 9h21v21H9z" fill="#bc3f24"/>
|
||||||
|
<path d="M34 9h21v21H34zM9 34h21v21H9z" fill="#eee8d8"/>
|
||||||
|
<path d="M34 34h21v21H34z" fill="#b5d452"/>
|
||||||
|
<path d="M21 18h13v8H26v4h8v8H21z" fill="#171916"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 325 B |
@@ -1,16 +1,21 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { SiteFooter } from "@/components/site-footer";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Blocklist — Minecraft Account Manager",
|
title: "SoMC Portal — Minecraft Account Manager",
|
||||||
description: "Connect your Discord identity to approved Minecraft accounts.",
|
description: "Connect your Discord identity to approved Minecraft accounts.",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
|
export default function RootLayout({ children }: Readonly<{ children: ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body>{children}</body>
|
<body className="flex min-h-screen flex-col">
|
||||||
|
<a className="skip-link" href="#main-content">Skip to main content</a>
|
||||||
|
<div className="flex-1" id="main-content" tabIndex={-1}>{children}</div>
|
||||||
|
<SiteFooter />
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,11 +18,11 @@ export default async function HomePage({
|
|||||||
<div className="terrain" aria-hidden="true" />
|
<div className="terrain" aria-hidden="true" />
|
||||||
<div className="relative mx-auto flex min-h-screen max-w-7xl flex-col px-6 pb-10 pt-7 sm:px-10 lg:px-16">
|
<div className="relative mx-auto flex min-h-screen max-w-7xl flex-col px-6 pb-10 pt-7 sm:px-10 lg:px-16">
|
||||||
<header className="flex items-center justify-between border-b border-line pb-5">
|
<header className="flex items-center justify-between border-b border-line pb-5">
|
||||||
<a className="flex items-center gap-3" href="#top" aria-label="Blocklist home">
|
<a className="flex items-center gap-3" href="#top" aria-label="SoMC Portal home">
|
||||||
<span className="grid size-9 place-items-center border border-accent bg-accent text-sm font-black text-canvas shadow-[4px_4px_0_var(--color-shadow)]">
|
<span className="grid size-9 place-items-center border border-accent bg-accent text-sm font-black text-canvas shadow-[4px_4px_0_var(--color-shadow)]">
|
||||||
B
|
B
|
||||||
</span>
|
</span>
|
||||||
<span className="font-display text-sm font-bold uppercase tracking-[0.22em]">Blocklist</span>
|
<span className="font-display text-sm font-bold uppercase tracking-[0.22em]">SoMC Portal</span>
|
||||||
</a>
|
</a>
|
||||||
<span className="hidden items-center gap-2 font-mono text-xs uppercase tracking-widest text-muted sm:flex">
|
<span className="hidden items-center gap-2 font-mono text-xs uppercase tracking-widest text-muted sm:flex">
|
||||||
<span className="size-2 bg-signal shadow-[0_0_12px_var(--color-signal)]" />
|
<span className="size-2 bg-signal shadow-[0_0_12px_var(--color-signal)]" />
|
||||||
@@ -84,10 +84,6 @@ export default async function HomePage({
|
|||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer className="flex flex-col gap-3 border-t border-line pt-5 font-mono text-[10px] uppercase tracking-[0.18em] text-muted sm:flex-row sm:items-center sm:justify-between">
|
|
||||||
<span>Java Edition only</span>
|
|
||||||
<span>Unknown players are denied by default</span>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { NicknameNotice } from "./nickname-notice";
|
||||||
|
|
||||||
|
describe("NicknameNotice", () => {
|
||||||
|
it("announces a synchronized nickname and provides a dismissal action", () => {
|
||||||
|
const markup = renderToStaticMarkup(<NicknameNotice nickname="Dani · Steve" />);
|
||||||
|
|
||||||
|
expect(markup).toContain('role="status"');
|
||||||
|
expect(markup).toContain("We updated your Discord nickname to");
|
||||||
|
expect(markup).toContain("Dani · Steve");
|
||||||
|
expect(markup).toContain("Awesome, thanks!");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("announces synchronization errors assertively", () => {
|
||||||
|
const markup = renderToStaticMarkup(<NicknameNotice error="Discord rejected the update." />);
|
||||||
|
expect(markup).toContain('role="alert"');
|
||||||
|
expect(markup).toContain("Discord rejected the update.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export function NicknameNotice({ nickname, error }: { nickname?: string; error?: string }) {
|
||||||
|
if (!nickname && !error) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
aria-live={error ? "assertive" : "polite"}
|
||||||
|
className={`mt-8 border-l-2 bg-panel px-5 py-4 ${error ? "border-accent" : "border-signal"}`}
|
||||||
|
role={error ? "alert" : "status"}
|
||||||
|
>
|
||||||
|
<p className="text-sm leading-6">
|
||||||
|
{error ? error : <>We updated your Discord nickname to <strong>{nickname}</strong>.</>}
|
||||||
|
</p>
|
||||||
|
<form action="/account" className="mt-3" method="get">
|
||||||
|
<button className="font-mono text-[10px] font-bold uppercase underline underline-offset-4" type="submit">
|
||||||
|
Awesome, thanks!
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { renderToStaticMarkup } from "react-dom/server";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { SiteFooter } from "./site-footer";
|
||||||
|
|
||||||
|
describe("SiteFooter", () => {
|
||||||
|
it("credits DMG Games with the requested sponsor link", () => {
|
||||||
|
const markup = renderToStaticMarkup(<SiteFooter />);
|
||||||
|
|
||||||
|
expect(markup).toContain("Social Minecraft is sponsored by");
|
||||||
|
expect(markup).toContain('href="https://dmg.games"');
|
||||||
|
expect(markup).toContain("DMG Games.");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export function SiteFooter() {
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-line bg-panel px-6 py-6 text-center font-mono text-[10px] uppercase tracking-[0.16em] text-muted">
|
||||||
|
Social Minecraft is sponsored by{" "}
|
||||||
|
<a
|
||||||
|
className="font-bold text-ink underline decoration-accent underline-offset-4 transition-colors hover:text-accent"
|
||||||
|
href="https://dmg.games"
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
DMG Games.
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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",
|
||||||
|
latitude: 37.4056,
|
||||||
|
longitude: -122.0775,
|
||||||
|
location: "Mountain View, California, US",
|
||||||
|
classification: "clear",
|
||||||
|
source: "game",
|
||||||
|
observedAt: new Date("2026-08-01T12:00:00Z"),
|
||||||
|
}]} unavailableCount={2} />);
|
||||||
|
|
||||||
|
expect(markup).toContain('role="group"');
|
||||||
|
expect(markup).toContain('class="map-marker-target"');
|
||||||
|
expect(markup).toContain("Latest approximate location for registered users");
|
||||||
|
expect(markup).toContain('href="/admin/users/11111111-1111-4111-8111-111111111111"');
|
||||||
|
expect(markup).toContain("Mountain View, California, US");
|
||||||
|
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,84 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
location: string;
|
||||||
|
classification: string;
|
||||||
|
source: string;
|
||||||
|
observedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserWorldMap({ locations, unavailableCount }: { locations: UserMapLocation[]; unavailableCount: number }) {
|
||||||
|
return (
|
||||||
|
<section className="mt-8 border border-line bg-panel p-5 shadow-[8px_8px_0_var(--color-shadow)] sm:p-7">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Latest known location</p>
|
||||||
|
<h2 className="mt-2 font-display text-3xl font-black uppercase">Community world</h2>
|
||||||
|
</div>
|
||||||
|
<p className="max-w-sm text-xs leading-5 text-muted">{locations.length} mapped · {unavailableCount} without coordinates. Locations are approximate IP intelligence, not precise device positions.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 overflow-hidden border border-line bg-[#b9d4d1]">
|
||||||
|
<svg aria-labelledby="user-world-map-title user-world-map-description" className="h-auto w-full" role="group" viewBox={`0 0 ${WIDTH} ${HEIGHT}`}>
|
||||||
|
<title id="user-world-map-title">Latest approximate location for registered users</title>
|
||||||
|
<desc id="user-world-map-description">An open-data world map with one linked marker for every user whose latest geolocated observation has valid coordinates. A complete text list follows.</desc>
|
||||||
|
<rect fill="#b9d4d1" height={HEIGHT} width={WIDTH} />
|
||||||
|
<g aria-hidden="true" fill="var(--canvas)" stroke="var(--line)" strokeWidth="0.7">
|
||||||
|
{countries.features.map((country, index) => {
|
||||||
|
const path = countryPath(country);
|
||||||
|
return path ? <path d={path} key={country.id ?? index} /> : null;
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
<g>
|
||||||
|
{locations.map((user) => {
|
||||||
|
const projected = projection([user.longitude, user.latitude]);
|
||||||
|
if (!projected) return null;
|
||||||
|
const x = Math.min(WIDTH - 14, Math.max(14, projected[0]));
|
||||||
|
const y = Math.min(HEIGHT - 14, Math.max(14, projected[1]));
|
||||||
|
return (
|
||||||
|
<a aria-label={`${user.name}, ${user.location}, last seen ${user.observedAt.toISOString()}`} href={`/admin/users/${user.userId}`} key={user.userId}>
|
||||||
|
<circle className="map-marker-target" cx={x} cy={y} fill="none" pointerEvents="stroke" r="7" stroke="transparent" strokeWidth="24" vectorEffect="non-scaling-stroke" />
|
||||||
|
<circle className="map-marker" cx={x} cy={y} fill="var(--accent)" pointerEvents="none" r="7" stroke="var(--panel)" strokeWidth="3">
|
||||||
|
<title>{user.name} · {user.location} · {user.classification}</title>
|
||||||
|
</circle>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-right font-mono text-[9px] text-muted">Map boundaries: Natural Earth, public domain</p>
|
||||||
|
|
||||||
|
<details className="mt-5 border-t border-line pt-4">
|
||||||
|
<summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase underline underline-offset-4">View accessible location list</summary>
|
||||||
|
<div className="mt-4 overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[680px] border-collapse text-left text-xs">
|
||||||
|
<caption className="sr-only">Latest approximate registered-user locations</caption>
|
||||||
|
<thead className="border-b border-line font-mono text-[9px] uppercase tracking-wider text-muted"><tr><th className="py-3 pr-4" scope="col">User</th><th className="p-3" scope="col">Location</th><th className="p-3" scope="col">Network</th><th className="p-3" scope="col">Source</th><th className="py-3 pl-4" scope="col">Last observed</th></tr></thead>
|
||||||
|
<tbody className="divide-y divide-line">
|
||||||
|
{locations.map((user) => <tr key={user.userId}><th className="py-3 pr-4 text-left" scope="row"><Link className="font-mono font-bold underline underline-offset-4" href={`/admin/users/${user.userId}`}>{user.name}</Link><span className="mt-1 block font-mono text-[9px] font-normal text-muted">@{user.discordUsername}</span></th><td className="p-3">{user.location}</td><td className="p-3 font-mono uppercase">{user.classification}</td><td className="p-3">{user.source}</td><td className="py-3 pl-4 font-mono text-[9px]"><time dateTime={user.observedAt.toISOString()}>{user.observedAt.toISOString()}</time></td></tr>)}
|
||||||
|
{!locations.length && <tr><td className="py-6 text-muted" colSpan={5}>No user observations currently include valid coordinates.</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { groupAccessAddresses } from "./access-address-groups";
|
||||||
|
|
||||||
|
describe("groupAccessAddresses", () => {
|
||||||
|
it("collapses repeated observations from the same network into one recent summary", () => {
|
||||||
|
const groups = groupAccessAddresses([
|
||||||
|
{ id: "old", ipAddress: "198.51.100.21", source: "web", classification: "clear", observedAt: new Date("2026-08-01T10:00:00Z"), intelligence: null },
|
||||||
|
{ id: "new", ipAddress: "198.51.100.240", source: "game", classification: "clear", observedAt: new Date("2026-08-01T12:00:00Z"), intelligence: { provider: "proxycheck" } },
|
||||||
|
{ id: "other", ipAddress: "203.0.113.9", source: "web", classification: "vpn", observedAt: new Date("2026-08-01T11:00:00Z"), intelligence: null },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(groups).toHaveLength(2);
|
||||||
|
expect(groups[0]).toMatchObject({
|
||||||
|
network: "198.51.100.0/24",
|
||||||
|
latestAddress: "198.51.100.240",
|
||||||
|
sources: ["game", "web"],
|
||||||
|
count: 2,
|
||||||
|
classification: "clear",
|
||||||
|
intelligence: { provider: "proxycheck" },
|
||||||
|
});
|
||||||
|
expect(groups[0]?.latestObservedAt.toISOString()).toBe("2026-08-01T12:00:00.000Z");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { addressGroup } from "@minecraft-account-manager/network";
|
||||||
|
|
||||||
|
type AccessObservation = {
|
||||||
|
id: string;
|
||||||
|
ipAddress: string;
|
||||||
|
source: string;
|
||||||
|
classification: string;
|
||||||
|
observedAt: Date;
|
||||||
|
intelligence: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AccessAddressGroup = {
|
||||||
|
network: string;
|
||||||
|
latestAddress: string;
|
||||||
|
sources: string[];
|
||||||
|
count: number;
|
||||||
|
firstObservedAt: Date;
|
||||||
|
latestObservedAt: Date;
|
||||||
|
classification: string;
|
||||||
|
intelligence: Record<string, unknown> | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function groupAccessAddresses(observations: AccessObservation[]) {
|
||||||
|
const groups = new Map<string, AccessAddressGroup & { sourceSet: Set<string> }>();
|
||||||
|
|
||||||
|
for (const observation of observations) {
|
||||||
|
const network = addressGroup(observation.ipAddress);
|
||||||
|
const existing = groups.get(network);
|
||||||
|
if (!existing) {
|
||||||
|
groups.set(network, {
|
||||||
|
network,
|
||||||
|
latestAddress: observation.ipAddress,
|
||||||
|
sources: [],
|
||||||
|
sourceSet: new Set([observation.source]),
|
||||||
|
count: 1,
|
||||||
|
firstObservedAt: observation.observedAt,
|
||||||
|
latestObservedAt: observation.observedAt,
|
||||||
|
classification: observation.classification,
|
||||||
|
intelligence: observation.intelligence,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.count += 1;
|
||||||
|
existing.sourceSet.add(observation.source);
|
||||||
|
if (observation.observedAt < existing.firstObservedAt) {
|
||||||
|
existing.firstObservedAt = observation.observedAt;
|
||||||
|
}
|
||||||
|
if (observation.observedAt > existing.latestObservedAt) {
|
||||||
|
existing.latestAddress = observation.ipAddress;
|
||||||
|
existing.latestObservedAt = observation.observedAt;
|
||||||
|
existing.classification = observation.classification;
|
||||||
|
existing.intelligence = observation.intelligence;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...groups.values()]
|
||||||
|
.map(({ sourceSet, ...group }) => ({ ...group, sources: [...sourceSet].sort() }))
|
||||||
|
.sort((left, right) => right.latestObservedAt.getTime() - left.latestObservedAt.getTime());
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { fillDailySeries } from "./admin-metrics";
|
||||||
|
|
||||||
|
describe("admin dashboard metrics", () => {
|
||||||
|
it("fills missing UTC registration days with zero", () => {
|
||||||
|
expect(fillDailySeries(
|
||||||
|
[{ day: "2026-07-30", count: 2 }, { day: "2026-08-01", count: 1 }],
|
||||||
|
new Date("2026-08-01T22:00:00Z"),
|
||||||
|
3,
|
||||||
|
)).toEqual([
|
||||||
|
{ day: "2026-07-30", count: 2 },
|
||||||
|
{ day: "2026-07-31", count: 0 },
|
||||||
|
{ day: "2026-08-01", count: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
export interface DailyCount {
|
||||||
|
day: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fillDailySeries(rows: DailyCount[], end: Date, days: number) {
|
||||||
|
const counts = new Map(rows.map((row) => [row.day, Number(row.count)]));
|
||||||
|
const endDay = new Date(Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate()));
|
||||||
|
return Array.from({ length: days }, (_, index) => {
|
||||||
|
const date = new Date(endDay);
|
||||||
|
date.setUTCDate(endDay.getUTCDate() - (days - index - 1));
|
||||||
|
const day = date.toISOString().slice(0, 10);
|
||||||
|
return { day, count: counts.get(day) ?? 0 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { applicationUrl } from "./application-url";
|
||||||
|
|
||||||
|
describe("applicationUrl", () => {
|
||||||
|
it("builds browser redirects from the configured public application URL", () => {
|
||||||
|
expect(applicationUrl("/welcome", "https://portal.somc.club"))
|
||||||
|
.toEqual(new URL("https://portal.somc.club/welcome"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export function applicationUrl(path: string, baseUrl = process.env.APP_URL) {
|
||||||
|
if (!baseUrl) throw new Error("APP_URL is required to build public application URLs");
|
||||||
|
return new URL(path, baseUrl);
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ import { db } from "@/lib/database";
|
|||||||
|
|
||||||
const UI_ACCESSED = "games.minecraft.account-manager.ui.accessed";
|
const UI_ACCESSED = "games.minecraft.account-manager.ui.accessed";
|
||||||
|
|
||||||
export async function recordAdminEvent(
|
export async function recordAdminSubjectEvent(
|
||||||
admin: { email: string | null; name: string | null },
|
admin: { email: string | null; name: string | null },
|
||||||
targetUserId: string,
|
subject: string,
|
||||||
type: string,
|
type: string,
|
||||||
data: Record<string, unknown>,
|
data: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
@@ -16,12 +16,21 @@ export async function recordAdminEvent(
|
|||||||
return recordEvent(db, {
|
return recordEvent(db, {
|
||||||
type,
|
type,
|
||||||
source: "/web/admin",
|
source: "/web/admin",
|
||||||
subject: `user/${targetUserId}`,
|
subject,
|
||||||
ipAddress: ipAddress ?? undefined,
|
ipAddress: ipAddress ?? undefined,
|
||||||
data: { ...data, adminEmail: admin.email, adminName: admin.name },
|
data: { ...data, adminEmail: admin.email, adminName: admin.name },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function recordAdminEvent(
|
||||||
|
admin: { email: string | null; name: string | null },
|
||||||
|
targetUserId: string,
|
||||||
|
type: string,
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
return recordAdminSubjectEvent(admin, `user/${targetUserId}`, type, data);
|
||||||
|
}
|
||||||
|
|
||||||
export async function recordUserEvent(
|
export async function recordUserEvent(
|
||||||
user: { id: string },
|
user: { id: string },
|
||||||
type: string,
|
type: string,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { getGuildMemberIdentity } from "@minecraft-account-manager/minecraft";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
|
export async function discordIdentity(input: {
|
||||||
|
discordUserId: string;
|
||||||
|
discordUsername: string;
|
||||||
|
discordGlobalName: string | null;
|
||||||
|
}) {
|
||||||
|
const guildId = process.env.DISCORD_GUILD_ID?.trim();
|
||||||
|
const botToken = process.env.DISCORD_BOT_TOKEN?.trim();
|
||||||
|
const fallback = {
|
||||||
|
id: input.discordUserId,
|
||||||
|
username: input.discordUsername,
|
||||||
|
globalName: input.discordGlobalName,
|
||||||
|
nickname: null as string | null,
|
||||||
|
live: false,
|
||||||
|
};
|
||||||
|
if (!guildId || !botToken) return fallback;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return { ...await getGuildMemberIdentity({
|
||||||
|
guildId,
|
||||||
|
discordUserId: input.discordUserId,
|
||||||
|
botToken,
|
||||||
|
}), live: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
{ err: error, event: "discord.identity_lookup_failed", discordUserId: input.discordUserId },
|
||||||
|
"Discord guild identity lookup failed",
|
||||||
|
);
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { eventCategory, normalizeSelectedEventTypes } from "./event-filters";
|
||||||
|
|
||||||
|
describe("event filters", () => {
|
||||||
|
it("accepts only available event types and removes duplicates", () => {
|
||||||
|
expect(normalizeSelectedEventTypes(["login.allowed", "unknown", "login.allowed"], ["login.allowed", "group.updated"]))
|
||||||
|
.toEqual(["login.allowed"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies events into operator-friendly views", () => {
|
||||||
|
expect(eventCategory("games.minecraft.account-manager.group.deleted")).toBe("groups");
|
||||||
|
expect(eventCategory("games.minecraft.account-manager.game.login.denied")).toBe("admission");
|
||||||
|
expect(eventCategory("games.minecraft.account-manager.network.vpn-blocked")).toBe("security");
|
||||||
|
expect(eventCategory("games.minecraft.account-manager.auth.magic-link.consumed")).toBe("security");
|
||||||
|
expect(eventCategory("games.minecraft.account-manager.discord.nickname.updated")).toBe("identity");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export const eventCategoryValues = ["all", "admission", "security", "identity", "groups", "operations"] as const;
|
||||||
|
export type EventCategory = (typeof eventCategoryValues)[number];
|
||||||
|
|
||||||
|
export function eventCategory(type: string): Exclude<EventCategory, "all"> {
|
||||||
|
if (type.includes(".group.")) return "groups";
|
||||||
|
if (type.includes(".network.") || type.includes(".auth.") || type.includes("authentication") || type.includes("replay")) return "security";
|
||||||
|
if (type.includes(".game.login.")) return "admission";
|
||||||
|
if (type.includes(".discord.") || type.includes(".user.") || type.includes("minecraft-account")) return "identity";
|
||||||
|
return "operations";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEventCategory(value: string | undefined): EventCategory {
|
||||||
|
return eventCategoryValues.includes(value as EventCategory) ? value as EventCategory : "all";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSelectedEventTypes(value: string | string[] | undefined, availableTypes: string[]) {
|
||||||
|
const requested = Array.isArray(value) ? value : value ? [value] : [];
|
||||||
|
const available = new Set(availableTypes);
|
||||||
|
return [...new Set(requested.filter((type) => available.has(type)))].slice(0, 20);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { ipIntelligence } from "@minecraft-account-manager/database";
|
|||||||
import { and, eq, gt } from "drizzle-orm";
|
import { and, eq, gt } from "drizzle-orm";
|
||||||
import { headers } from "next/headers";
|
import { headers } from "next/headers";
|
||||||
import { db } from "@/lib/database";
|
import { db } from "@/lib/database";
|
||||||
|
import { logger } from "@/lib/logger";
|
||||||
|
|
||||||
const classifications = new Set<IpClassification>([
|
const classifications = new Set<IpClassification>([
|
||||||
"unknown",
|
"unknown",
|
||||||
@@ -77,6 +78,13 @@ export async function getIpIntelligence(
|
|||||||
options: { now?: Date; forceRefresh?: boolean } = {},
|
options: { now?: Date; forceRefresh?: boolean } = {},
|
||||||
): Promise<IpIntelligenceResult> {
|
): Promise<IpIntelligenceResult> {
|
||||||
if (!isPublicIp(ipAddress)) {
|
if (!isPublicIp(ipAddress)) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
event: "ip_intelligence.skipped",
|
||||||
|
reason: "non_public_address",
|
||||||
|
},
|
||||||
|
"IP intelligence lookup skipped for a non-public client address",
|
||||||
|
);
|
||||||
return { classification: "unknown", provider: null };
|
return { classification: "unknown", provider: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,14 +104,23 @@ export async function getIpIntelligence(
|
|||||||
const result = await provider.classify(ipAddress);
|
const result = await provider.classify(ipAddress);
|
||||||
await cacheResult(ipAddress, result, now, cacheHours() * 60 * 60_000);
|
await cacheResult(ipAddress, result, now, cacheHours() * 60 * 60_000);
|
||||||
return result;
|
return result;
|
||||||
} catch {
|
} catch (error) {
|
||||||
|
const providerName = process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() || null;
|
||||||
const result: IpIntelligenceResult = {
|
const result: IpIntelligenceResult = {
|
||||||
classification: "unknown",
|
classification: "unknown",
|
||||||
provider: process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() || null,
|
provider: providerName,
|
||||||
lookupError: true,
|
lookupError: true,
|
||||||
};
|
};
|
||||||
await cacheResult(ipAddress, result, now, 5 * 60_000).catch(() => undefined);
|
await cacheResult(ipAddress, result, now, 5 * 60_000).catch((cacheError) => {
|
||||||
console.error("IP intelligence lookup failed");
|
logger.error(
|
||||||
|
{ err: cacheError, event: "ip_intelligence.cache_failed", provider: providerName },
|
||||||
|
"Failed to cache an IP intelligence lookup error",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
logger.error(
|
||||||
|
{ err: error, event: "ip_intelligence.lookup_failed", provider: providerName },
|
||||||
|
"IP intelligence lookup failed",
|
||||||
|
);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,6 +151,15 @@ export async function checkAccountAdditionNetwork() {
|
|||||||
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");
|
||||||
if (!ipAddress) {
|
if (!ipAddress) {
|
||||||
|
logger.warn(
|
||||||
|
{
|
||||||
|
event: "client_ip.unavailable",
|
||||||
|
trustProxy: process.env.TRUST_PROXY === "true",
|
||||||
|
forwardedForPresent: requestHeaders.has("x-forwarded-for"),
|
||||||
|
realIpPresent: requestHeaders.has("x-real-ip"),
|
||||||
|
},
|
||||||
|
"Client IP address was unavailable for account addition",
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
allowed: false as const,
|
allowed: false as const,
|
||||||
reason: "unavailable" as const,
|
reason: "unavailable" as const,
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { createLogger } from "@minecraft-account-manager/logging";
|
||||||
|
|
||||||
|
export const logger = createLogger("minecraft-account-manager-web");
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { parseUserLocation, projectWorldPoint } from "./user-location-map";
|
||||||
|
|
||||||
|
describe("user location map", () => {
|
||||||
|
it("extracts a valid approximate location from cached IP intelligence", () => {
|
||||||
|
expect(parseUserLocation({
|
||||||
|
classification: "clear",
|
||||||
|
location: {
|
||||||
|
city: "Mountain View",
|
||||||
|
region: "California",
|
||||||
|
countryCode: "US",
|
||||||
|
latitude: 37.4056,
|
||||||
|
longitude: -122.0775,
|
||||||
|
},
|
||||||
|
})).toEqual({
|
||||||
|
latitude: 37.4056,
|
||||||
|
longitude: -122.0775,
|
||||||
|
label: "Mountain View, California, US",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects missing and out-of-range coordinates", () => {
|
||||||
|
expect(parseUserLocation({ location: { latitude: 91, longitude: 0 } })).toBeNull();
|
||||||
|
expect(parseUserLocation({ location: { city: "Unknown" } })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("projects longitude and latitude into an equirectangular SVG", () => {
|
||||||
|
expect(projectWorldPoint(0, 0, 800, 400)).toEqual({ x: 400, y: 200 });
|
||||||
|
expect(projectWorldPoint(90, 180, 800, 400)).toEqual({ x: 800, y: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
type UnknownMap = Record<string, unknown>;
|
||||||
|
|
||||||
|
function objectValue(value: unknown): UnknownMap | null {
|
||||||
|
return value && typeof value === "object" && !Array.isArray(value)
|
||||||
|
? value as UnknownMap
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function coordinate(value: unknown) {
|
||||||
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedUserLocation {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseUserLocation(value: unknown): ParsedUserLocation | null {
|
||||||
|
const intelligence = objectValue(value);
|
||||||
|
const location = objectValue(intelligence?.location);
|
||||||
|
if (!location) return null;
|
||||||
|
const latitude = coordinate(location.latitude);
|
||||||
|
const longitude = coordinate(location.longitude);
|
||||||
|
if (latitude === null || longitude === null || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const label = [location.city, location.region, location.countryCode ?? location.country]
|
||||||
|
.filter((part): part is string => typeof part === "string" && part.trim().length > 0)
|
||||||
|
.join(", ");
|
||||||
|
return { latitude, longitude, label: label || "Approximate location unavailable" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectWorldPoint(latitude: number, longitude: number, width: number, height: number) {
|
||||||
|
return {
|
||||||
|
x: ((longitude + 180) / 360) * width,
|
||||||
|
y: ((90 - latitude) / 180) * height,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -30,6 +30,8 @@ This OKF bundle is the product record for implemented and proposed behavior. Sto
|
|||||||
* [US-014 — Receive standardized API errors](us-014-problem-details.md) - Application APIs return RFC 9457 Problem Details.
|
* [US-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-015 — Deploy and operate securely](us-015-platform-operations.md) - Operators have reproducible builds, migrations, credentials, and security controls.
|
||||||
* [US-016 — Build and publish versioned releases](us-016-automated-releases.md) - Gitea Actions publish the Velocity JAR and web and migration images.
|
* [US-016 — Build and publish versioned releases](us-016-automated-releases.md) - Gitea Actions publish the Velocity JAR and web and migration images.
|
||||||
|
* [US-017 — Control admission with groups](us-017-group-access.md) - Each user has one effective group that explicitly controls Minecraft access.
|
||||||
|
* [US-018 — Monitor community account activity](us-018-admin-dashboard.md) - Administrators review registrations, monthly activity, denials, and risky networks.
|
||||||
|
|
||||||
# Tracking
|
# Tracking
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
## 2026-08-01
|
## 2026-08-01
|
||||||
|
|
||||||
|
* **Extend**: Plot each user's latest approximate location on an accessible, server-rendered Natural Earth world map in the operations dashboard.
|
||||||
|
* **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.
|
* **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.
|
* **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.
|
* **Create**: Added Gitea CI and semantic-release pipelines for downloadable Velocity JARs and versioned web and migration images.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Enter the account portal through Discord
|
title: Enter the account portal through Discord
|
||||||
description: Direct visitors are guided to the configured Discord community and its account commands.
|
description: Direct visitors are guided to the configured Discord community and its account commands.
|
||||||
tags: [player, portal, discord, onboarding]
|
tags: [player, portal, discord, onboarding]
|
||||||
timestamp: 2026-08-01T18:43:58Z
|
timestamp: 2026-08-01T22:34:31Z
|
||||||
story_id: US-001
|
story_id: US-001
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -18,11 +18,15 @@ As a prospective player, I want the portal to direct me to the community Discord
|
|||||||
- [x] Given a configured invite URL, when the visitor selects the join action, then the Discord invite opens in a new browser context.
|
- [x] Given a configured 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 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] 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
|
# Implementation
|
||||||
|
|
||||||
- [`apps/web/src/app/page.tsx`](../apps/web/src/app/page.tsx)
|
- [`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/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`
|
- Configuration: `DISCORD_GUILD_ID`, `DISCORD_INVITE_URL`
|
||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Authenticate with a Discord magic link
|
title: Authenticate with a Discord magic link
|
||||||
description: Discord users receive private single-use links that establish secure portal sessions.
|
description: Discord users receive private single-use links that establish secure portal sessions.
|
||||||
tags: [player, discord, authentication, security]
|
tags: [player, discord, authentication, security]
|
||||||
timestamp: 2026-08-01T18:43:58Z
|
timestamp: 2026-08-01T20:43:46Z
|
||||||
story_id: US-002
|
story_id: US-002
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -19,6 +19,7 @@ As a Discord community member, I want `/register` and `/account` to issue a priv
|
|||||||
- [x] Given a login token, then it expires after ten minutes and can be consumed only once.
|
- [x] Given 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 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 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.
|
- [x] Given an invalid, expired, or consumed link, then the user sees a safe recovery page instructing them to request another link.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
@@ -27,10 +28,12 @@ As a Discord community member, I want `/register` and `/account` to issue a priv
|
|||||||
- [`packages/auth/src/index.ts`](../packages/auth/src/index.ts)
|
- [`packages/auth/src/index.ts`](../packages/auth/src/index.ts)
|
||||||
- [`packages/database/src/auth-repository.ts`](../packages/database/src/auth-repository.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/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
|
# Validation
|
||||||
|
|
||||||
- [`packages/auth/test/magic-link.test.ts`](../packages/auth/test/magic-link.test.ts)
|
- [`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.
|
- Discord command and authentication workspaces pass TypeScript validation.
|
||||||
|
|
||||||
# Related Stories
|
# Related Stories
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Manage linked accounts from the dashboard
|
title: Manage linked accounts from the dashboard
|
||||||
description: Authenticated users maintain their profile and active Java Edition accounts.
|
description: Authenticated users maintain their profile and active Java Edition accounts.
|
||||||
tags: [player, dashboard, minecraft, profile]
|
tags: [player, dashboard, minecraft, profile]
|
||||||
timestamp: 2026-08-01T18:43:58Z
|
timestamp: 2026-08-01T23:10:59Z
|
||||||
story_id: US-005
|
story_id: US-005
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -20,8 +20,10 @@ As a registered player, I want to manage my profile and linked Minecraft account
|
|||||||
- [x] The user can soft-remove an active account.
|
- [x] The user can soft-remove an active account.
|
||||||
- [x] The user can choose exactly one active primary account.
|
- [x] The user can choose exactly one active primary account.
|
||||||
- [x] Removing a primary account promotes another active account when one exists.
|
- [x] Removing a primary account promotes another active account when one exists.
|
||||||
- [x] Name and primary changes show the expected Discord nickname and require confirmation.
|
- [x] Name, primary, and account-removal changes automatically synchronize the expected Discord nickname and report the result.
|
||||||
- [x] The dashboard shows recent portal and game IP observations with classification and available location.
|
- [x] The dashboard shows 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.
|
- [x] The user can revoke the current session by signing out.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
@@ -32,7 +34,7 @@ As a registered player, I want to manage my profile and linked Minecraft account
|
|||||||
|
|
||||||
# Validation
|
# Validation
|
||||||
|
|
||||||
Server actions verify the current session and constrain every account lookup by the authenticated user ID.
|
Server actions verify the current session and constrain every account lookup by the authenticated user ID. Nickname result announcements are covered by [`apps/web/src/components/nickname-notice.test.tsx`](../apps/web/src/components/nickname-notice.test.tsx).
|
||||||
|
|
||||||
# Related Stories
|
# Related Stories
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Keep Discord nicknames synchronized
|
title: Keep Discord nicknames synchronized
|
||||||
description: Preferred names and primary Minecraft usernames determine community guild nicknames.
|
description: Preferred names and primary Minecraft usernames determine community guild nicknames.
|
||||||
tags: [player, admin, discord, identity]
|
tags: [player, admin, discord, identity]
|
||||||
timestamp: 2026-08-01T18:43:58Z
|
timestamp: 2026-08-01T23:10:59Z
|
||||||
story_id: US-006
|
story_id: US-006
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -16,10 +16,11 @@ As a community member, I want my Discord nickname to reflect my preferred name a
|
|||||||
|
|
||||||
- [x] Given a preferred name and primary account, then the nickname format is `First name (MinecraftUsername)`.
|
- [x] Given 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 Discord's 32-character limit, then the preferred-name portion is shortened while preserving the Minecraft username.
|
||||||
- [x] Given no remaining Minecraft account, then administrative synchronization falls back to the preferred name.
|
- [x] Given no remaining Minecraft account, then synchronization uses `First name (TBD)`.
|
||||||
- [x] User name and primary changes display the proposed nickname before confirmation.
|
- [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] Administrator name, primary, and primary-removal operations synchronize the nickname automatically.
|
||||||
- [x] Discord failures are reported without falsely claiming the requested profile change completed.
|
|
||||||
- [x] A protected administrative retry action can synchronize the current desired nickname.
|
- [x] A protected administrative retry action can synchronize the current desired nickname.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
@@ -27,6 +28,7 @@ As a community member, I want my Discord nickname to reflect my preferred name a
|
|||||||
- [`packages/minecraft/src/index.ts`](../packages/minecraft/src/index.ts)
|
- [`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/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/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
|
# Validation
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Enrich portal and game login IPs
|
title: Enrich portal and game login IPs
|
||||||
description: Login audit events include cached approximate location and network intelligence from ProxyCheck.io.
|
description: Login audit events include cached approximate location and network intelligence from ProxyCheck.io.
|
||||||
tags: [security, network, audit, proxycheck]
|
tags: [security, network, audit, proxycheck]
|
||||||
timestamp: 2026-08-01T18:43:58Z
|
timestamp: 2026-08-01T22:04:17Z
|
||||||
story_id: US-007
|
story_id: US-007
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -22,6 +22,7 @@ As an operator, I want portal and registered game logins enriched with network c
|
|||||||
- [x] Unknown game accounts do not trigger paid ProxyCheck lookups.
|
- [x] Unknown game accounts do not trigger paid ProxyCheck lookups.
|
||||||
- [x] Login events and IP observations retain the available classification and approximate location.
|
- [x] Login events and IP observations retain the available classification and approximate location.
|
||||||
- [x] Users and administrators can see available location and classification in audit views.
|
- [x] Users and administrators can see available location and classification in audit views.
|
||||||
|
- [x] Repeated access observations are summarized by IPv4 /24 or IPv6 /64 network with counts, sources, and latest activity.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
|
|
||||||
@@ -34,6 +35,8 @@ As an operator, I want portal and registered game logins enriched with network c
|
|||||||
|
|
||||||
- [`packages/network/test/proxycheck.test.ts`](../packages/network/test/proxycheck.test.ts)
|
- [`packages/network/test/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/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
|
# Related Stories
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Enforce registration at the Velocity proxy
|
title: Enforce registration at the Velocity proxy
|
||||||
description: Online-mode Java connections are admitted only after a fail-closed account-manager decision.
|
description: Online-mode Java connections are admitted only after a fail-closed account-manager decision.
|
||||||
tags: [minecraft, velocity, whitelist, security]
|
tags: [minecraft, velocity, whitelist, security]
|
||||||
timestamp: 2026-08-01T18:52:20Z
|
timestamp: 2026-08-01T23:10:59Z
|
||||||
story_id: US-009
|
story_id: US-009
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -22,7 +22,8 @@ As a registered player, I want the Velocity proxy to recognize my approved Java
|
|||||||
- [x] Username fallback applies only when the stored account has no UUID.
|
- [x] Username fallback applies only when the stored account has no UUID.
|
||||||
- [x] Successful fallback backfills UUID and canonical username.
|
- [x] Successful fallback backfills UUID and canonical username.
|
||||||
- [x] Changed usernames are persisted and audited.
|
- [x] Changed usernames are persisted and audited.
|
||||||
- [x] Unknown players, API failures, malformed responses, and unauthorized requests fail closed with registration guidance.
|
- [x] Registered players are allowed only when their single effective group has access enabled; explicit assignments override the default group.
|
||||||
|
- [x] Unknown players, group-disabled players, API failures, malformed responses, and unauthorized requests fail closed with registration guidance.
|
||||||
- [x] The plugin records the real Velocity connection IP and supports Java Edition online mode only.
|
- [x] The plugin records the real Velocity connection IP and supports Java Edition online mode only.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
@@ -41,3 +42,4 @@ As a registered player, I want the Velocity proxy to recognize my approved Java
|
|||||||
|
|
||||||
- [Validate Minecraft accounts](us-004-minecraft-validation.md)
|
- [Validate Minecraft accounts](us-004-minecraft-validation.md)
|
||||||
- [Standardize API errors](us-014-problem-details.md)
|
- [Standardize API errors](us-014-problem-details.md)
|
||||||
|
- [Control Minecraft admission with groups](us-017-group-access.md)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Preserve a CloudEvents-style audit trail
|
title: Preserve a CloudEvents-style audit trail
|
||||||
description: Authentication, UI, account, Discord, network, and game actions create searchable immutable-style events.
|
description: Authentication, UI, account, Discord, network, and game actions create searchable immutable-style events.
|
||||||
tags: [audit, cloudevents, security, events]
|
tags: [audit, cloudevents, security, events]
|
||||||
timestamp: 2026-08-01T18:43:58Z
|
timestamp: 2026-08-01T23:10:59Z
|
||||||
story_id: US-010
|
story_id: US-010
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -19,7 +19,8 @@ As an operator, I want security and identity activity recorded consistently, so
|
|||||||
- [x] Portal access, magic-link creation and consumption, account changes, nickname changes, VPN blocks, and game decisions are recorded.
|
- [x] Portal access, magic-link creation and consumption, account changes, nickname changes, VPN blocks, and game decisions are recorded.
|
||||||
- [x] Username changes learned from Velocity create their own event.
|
- [x] Username changes learned from Velocity create their own event.
|
||||||
- [x] Administrative actions include the acting SSO identity in event data.
|
- [x] Administrative actions include the acting SSO identity in event data.
|
||||||
- [x] Events can be inspected globally and from an individual admin user view.
|
- [x] Events can be filtered by operator-friendly view and selected event types globally and from an individual user view.
|
||||||
|
- [x] Every listed event links to a detail page showing its complete CloudEvents envelope and formatted JSON data.
|
||||||
- [x] `published_at` reserves an outbox path for future Kafka publishing.
|
- [x] `published_at` reserves an outbox path for future Kafka publishing.
|
||||||
|
|
||||||
# Implementation
|
# Implementation
|
||||||
@@ -28,6 +29,7 @@ As an operator, I want security and identity activity recorded consistently, so
|
|||||||
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
- [`packages/database/src/schema.ts`](../packages/database/src/schema.ts)
|
||||||
- [`apps/web/src/lib/audit.ts`](../apps/web/src/lib/audit.ts)
|
- [`apps/web/src/lib/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/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
|
# Validation
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Operate settings and audit views
|
title: Operate settings and audit views
|
||||||
description: Authorized administrators control server messaging and investigate recent platform events.
|
description: Authorized administrators control server messaging and investigate recent platform events.
|
||||||
tags: [admin, settings, audit, operations]
|
tags: [admin, settings, audit, operations]
|
||||||
timestamp: 2026-08-01T18:43:58Z
|
timestamp: 2026-08-01T22:34:31Z
|
||||||
story_id: US-012
|
story_id: US-012
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -14,7 +14,7 @@ As an administrator, I want operational settings and audit visibility, so that I
|
|||||||
|
|
||||||
# Acceptance Criteria
|
# Acceptance Criteria
|
||||||
|
|
||||||
- [x] The admin console reports whether deployment-managed Discord guild and invite settings are configured.
|
- [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] An authorized administrator can update the denied-player registration message.
|
||||||
- [x] Settings actions validate message length server-side.
|
- [x] Settings actions validate message length server-side.
|
||||||
- [x] Administrators can browse the latest 100 events.
|
- [x] Administrators can browse the latest 100 events.
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Manage users as an administrator
|
title: Manage users as an administrator
|
||||||
description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames.
|
description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames.
|
||||||
tags: [admin, users, minecraft, discord]
|
tags: [admin, users, minecraft, discord]
|
||||||
timestamp: 2026-08-01T18:43:58Z
|
timestamp: 2026-08-01T22:34:31Z
|
||||||
story_id: US-013
|
story_id: US-013
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -16,7 +16,7 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
|
|||||||
|
|
||||||
- [x] Administrators can search by preferred name, Discord username or ID, Minecraft username, or UUID.
|
- [x] Administrators can search by preferred name, Discord username or ID, Minecraft username, or UUID.
|
||||||
- [x] Search results show onboarding state, primary username, and active account count.
|
- [x] Search results show onboarding state, primary username, and active account count.
|
||||||
- [x] A user detail view shows Discord identity, active accounts, recent events, and recent IP observations.
|
- [x] A user detail view shows Discord display name, username, guild nickname, immutable ID, active accounts, groups, recent events, and recent IP observations.
|
||||||
- [x] Administrators can update the preferred name and synchronize Discord.
|
- [x] Administrators can update the preferred name and synchronize Discord.
|
||||||
- [x] Administrators can add Mojang-verified accounts or explicitly override an unverified username.
|
- [x] Administrators can add Mojang-verified accounts or explicitly override an unverified username.
|
||||||
- [x] Administrators can remove an account only after a visible confirmation step.
|
- [x] Administrators can remove an account only after a visible confirmation step.
|
||||||
@@ -39,3 +39,4 @@ Nickname fallback behavior is tested in [`packages/minecraft/test/minecraft.test
|
|||||||
|
|
||||||
- [Administrator SSO](us-011-admin-sso.md)
|
- [Administrator SSO](us-011-admin-sso.md)
|
||||||
- [Synchronize Discord nicknames](us-006-discord-nickname.md)
|
- [Synchronize Discord nicknames](us-006-discord-nickname.md)
|
||||||
|
- [Control Minecraft admission with groups](us-017-group-access.md)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ type: User Story
|
|||||||
title: Deploy and operate the platform securely
|
title: Deploy and operate the platform securely
|
||||||
description: Operators have repeatable builds, migrations, credential provisioning, configuration, and security checks.
|
description: Operators have repeatable builds, migrations, credential provisioning, configuration, and security checks.
|
||||||
tags: [operations, security, database, deployment]
|
tags: [operations, security, database, deployment]
|
||||||
timestamp: 2026-08-01T19:46:09Z
|
timestamp: 2026-08-01T23:10:59Z
|
||||||
story_id: US-015
|
story_id: US-015
|
||||||
status: verified
|
status: verified
|
||||||
---
|
---
|
||||||
@@ -21,7 +21,10 @@ As a platform operator, I want reproducible deployment and security controls, so
|
|||||||
- [x] The Velocity Gradle wrapper produces a tested shaded JAR.
|
- [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] 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] 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] 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] 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.
|
- [x] Architecture, Keycloak, API error, security, bot, and Velocity operating documentation is available.
|
||||||
|
|
||||||
@@ -33,10 +36,12 @@ As a platform operator, I want reproducible deployment and security controls, so
|
|||||||
- [`packages/database/scripts/create-plugin-credential.ts`](../packages/database/scripts/create-plugin-credential.ts)
|
- [`packages/database/scripts/create-plugin-credential.ts`](../packages/database/scripts/create-plugin-credential.ts)
|
||||||
- [`plugins/velocity/build.gradle.kts`](../plugins/velocity/build.gradle.kts)
|
- [`plugins/velocity/build.gradle.kts`](../plugins/velocity/build.gradle.kts)
|
||||||
- [`apps/web/next.config.ts`](../apps/web/next.config.ts)
|
- [`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
|
# Validation
|
||||||
|
|
||||||
Use `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, `npm run velocity:build`, `npm audit`, and `npm run design:validate`.
|
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
|
# Related Stories
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ type: User Story
|
|||||||
title: Build and publish versioned releases
|
title: Build and publish versioned releases
|
||||||
description: Gitea Actions validate every change and publish semantically versioned Velocity and container artifacts.
|
description: Gitea Actions validate every change and publish semantically versioned Velocity and container artifacts.
|
||||||
tags: [operations, ci, release, velocity, docker]
|
tags: [operations, ci, release, velocity, docker]
|
||||||
timestamp: 2026-08-01T19:46:09Z
|
timestamp: 2026-08-01T20:05:49Z
|
||||||
story_id: US-016
|
story_id: US-016
|
||||||
status: implemented
|
status: verified
|
||||||
---
|
---
|
||||||
|
|
||||||
# User Story
|
# User Story
|
||||||
@@ -20,9 +20,10 @@ As a platform operator, I want automated validation and semantic releases, so th
|
|||||||
- [x] Main-branch conventional commits determine the next semantic version and create a `vMAJOR.MINOR.PATCH` tag.
|
- [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 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] A public Gitea release exposes the versioned Velocity JAR as a downloadable asset.
|
||||||
- [x] Releases publish versioned and `latest` web runtime images to the Gitea registry.
|
- [x] Releases publish semantically versioned web runtime images to the Gitea registry.
|
||||||
- [ ] Releases publish versioned and `latest` Discord bot images to the Gitea registry.
|
- [x] Releases publish semantically versioned Discord bot images to the Gitea registry.
|
||||||
- [x] Releases publish versioned and `latest` migration images that run versioned Drizzle migrations.
|
- [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] 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.
|
- [x] Operators are told which repository secrets must be configured before the first push.
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ As a platform operator, I want automated validation and semantic releases, so th
|
|||||||
|
|
||||||
# Validation
|
# 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 versioned and `latest` web and migration images. Discord bot image publication is implemented for the next feature release. Pull-request commitlint configuration is present; its conditional execution will be exercised by the first pull request.
|
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
|
# Related Stories
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
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)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
---
|
||||||
|
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:32:54Z
|
||||||
|
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] An open-data world map plots each user's latest observation with valid approximate coordinates.
|
||||||
|
- [x] Map markers link to user records and have an accessible text-table equivalent.
|
||||||
|
- [x] Natural Earth boundaries are bundled and server-rendered without disclosing map or location requests to a third party.
|
||||||
|
- [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)
|
||||||
|
- [`apps/web/src/components/user-world-map.tsx`](../apps/web/src/components/user-world-map.tsx)
|
||||||
|
- [`apps/web/src/lib/user-location-map.ts`](../apps/web/src/lib/user-location-map.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).
|
||||||
|
- Coordinate parsing, projection, linked markers, text fallback, and attribution are covered by the user-world-map tests.
|
||||||
|
- The Next.js production build reports the dashboard and database-backed console pages as dynamic server-rendered routes.
|
||||||
|
|
||||||
|
# Related Stories
|
||||||
|
|
||||||
|
- [Preserve a CloudEvents-style audit trail](us-010-audit-events.md)
|
||||||
|
- [Deploy and operate the platform securely](us-015-platform-operations.md)
|
||||||
|
- [Block anonymized account additions](us-008-vpn-blocking.md)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Accessibility review
|
||||||
|
|
||||||
|
Review date: 2026-08-01
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Player account management, administrator navigation, dashboard metrics and chart, user records, group management, event filtering, event details, forms, tables, and status notifications.
|
||||||
|
|
||||||
|
## Implemented checks and improvements
|
||||||
|
|
||||||
|
- Added a keyboard-visible “Skip to main content” link and consistent high-visibility `:focus-visible` outlines.
|
||||||
|
- Darkened the accent color so accent text reaches at least 4.5:1 contrast on both canvas and panel backgrounds.
|
||||||
|
- Preserved reduced-motion behavior and disabled decorative cursor animation when requested.
|
||||||
|
- Added labels or accessible names to search, Minecraft username, settings, group, and event-filter controls.
|
||||||
|
- Added `fieldset` and `legend` semantics to multi-select event-type filters.
|
||||||
|
- Added table captions, column scopes, and row scopes to administrator data tables.
|
||||||
|
- Added `role=status` with polite announcements for successful nickname changes and `role=alert` with assertive announcements for errors.
|
||||||
|
- Added semantic `time` elements for audit and security activity timestamps.
|
||||||
|
- Made event JSON keyboard-focusable so horizontally overflowing content can be reviewed without a pointer.
|
||||||
|
- Added an accessible title, description, per-point labels, and textual values to the registration chart.
|
||||||
|
- Added labelled, keyboard-linked world-map markers plus a complete semantic table equivalent for approximate user locations.
|
||||||
|
- Added explicit new-tab context to the external Discord invite link.
|
||||||
|
- Kept destructive account and group actions behind native keyboard-operable `details` confirmation disclosures.
|
||||||
|
- Allowed administrator navigation to wrap at narrow viewport widths instead of overflowing.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- ESLint with the Next.js ruleset passes.
|
||||||
|
- Component rendering tests verify nickname status and error announcement roles and dismissal text.
|
||||||
|
- The production build passes and reports database-backed player and administrator pages as dynamic server-rendered routes.
|
||||||
|
- Color contrast was calculated for the canvas, panel, muted text, accent text, and signal combinations used by the interface.
|
||||||
|
|
||||||
|
## Follow-up
|
||||||
|
|
||||||
|
Authenticated browser automation is still recommended in CI with axe-core and a test Keycloak realm. It should cover keyboard order, zoom to 200%, reflow at 320 CSS pixels, and screen-reader announcements against a running production build.
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
### Web application
|
### Web application
|
||||||
|
|
||||||
The Next.js application owns user onboarding, account management, admin configuration, server-side Minecraft profile validation, sessions, and the HTTP API used by Discord and Velocity integrations.
|
The Next.js application owns user onboarding, account management, admin configuration and metrics, server-side Minecraft profile validation, sessions, and the HTTP API used by Discord and Velocity integrations. Database-backed portal and console pages are dynamic React Server Components: authentication, queries, filtering, dashboard aggregation, and the Natural Earth user-location map execute on the server and return rendered HTML. Map boundaries are bundled open data, so rendering does not disclose administrator or user location requests to a map provider.
|
||||||
|
|
||||||
User authentication begins with an opaque, short-lived, single-use token created for a Discord user. Only a cryptographic hash of the token is persisted. Admin authentication is a separate Keycloak OIDC flow and requires the `minecraft-account-manager-admin` role.
|
User authentication begins with an opaque, short-lived, single-use token created for a Discord user. Only a cryptographic hash of the token is persisted. Admin authentication is a separate Keycloak OIDC flow and requires the `minecraft-account-manager-admin` role.
|
||||||
|
|
||||||
|
|||||||
+1
-4
@@ -32,13 +32,10 @@ Each release creates:
|
|||||||
|
|
||||||
- Gitea release asset `minecraft-account-manager-velocity-VERSION.jar`
|
- Gitea release asset `minecraft-account-manager-velocity-VERSION.jar`
|
||||||
- `git.garvis.dev/dmg/minecraft-account-manager:VERSION`
|
- `git.garvis.dev/dmg/minecraft-account-manager:VERSION`
|
||||||
- `git.garvis.dev/dmg/minecraft-account-manager:latest`
|
|
||||||
- `git.garvis.dev/dmg/minecraft-account-manager-bot:VERSION`
|
- `git.garvis.dev/dmg/minecraft-account-manager-bot:VERSION`
|
||||||
- `git.garvis.dev/dmg/minecraft-account-manager-bot:latest`
|
|
||||||
- `git.garvis.dev/dmg/minecraft-account-manager-migrate:VERSION`
|
- `git.garvis.dev/dmg/minecraft-account-manager-migrate:VERSION`
|
||||||
- `git.garvis.dev/dmg/minecraft-account-manager-migrate:latest`
|
|
||||||
|
|
||||||
Use immutable version tags for deployments. `latest` is a convenience pointer to the newest release.
|
Only immutable semantic-version tags are published. Mutable tags such as `latest` must never be used in deployments or release-asset URLs.
|
||||||
|
|
||||||
## Discord bot
|
## Discord bot
|
||||||
|
|
||||||
|
|||||||
@@ -24,12 +24,16 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
|
|||||||
- Velocity credentials are high-entropy bearer tokens stored only as hashes.
|
- Velocity credentials are high-entropy bearer tokens stored only as hashes.
|
||||||
- Velocity requests have a 45-second clock window and database-unique request IDs for cross-instance replay prevention.
|
- Velocity requests have a 45-second clock window and database-unique request IDs for cross-instance replay prevention.
|
||||||
- Velocity and its API fail closed.
|
- Velocity and its API fail closed.
|
||||||
|
- Registered players require an enabled effective group; explicit assignments replace rather than combine with the protected, disabled-by-default `everyone` fallback.
|
||||||
|
- Group and membership mutations re-check the Keycloak administrator role server-side; destructive group deletion and its audit event commit atomically.
|
||||||
|
- Event filters accept only event types already present in the ledger, and event detail routes remain role-protected.
|
||||||
|
- The administrator-only location map uses bundled Natural Earth boundaries and approximate cached IP intelligence; it sends no coordinates or map requests to third parties.
|
||||||
- ORM-parameterized queries are used throughout.
|
- ORM-parameterized queries are used throughout.
|
||||||
- CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured.
|
- CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured.
|
||||||
- Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured.
|
- Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured.
|
||||||
- Private and reserved addresses are not sent to ProxyCheck.io; lookup results are cached to reduce disclosure and API usage.
|
- Private and reserved addresses are not sent to ProxyCheck.io; lookup results are cached to reduce disclosure and API usage.
|
||||||
- Portal and game login events include approximate network location and VPN/proxy classification when available.
|
- Portal and game login events include approximate network location and VPN/proxy classification when available.
|
||||||
- Secrets are excluded from logs and repository configuration.
|
- Structured Pino logging redacts credential fields, and secrets are excluded from logs and repository configuration.
|
||||||
|
|
||||||
## Outstanding production requirements
|
## Outstanding production requirements
|
||||||
|
|
||||||
|
|||||||
Generated
+258
-1
@@ -22,6 +22,7 @@
|
|||||||
"@minecraft-account-manager/auth": "*",
|
"@minecraft-account-manager/auth": "*",
|
||||||
"@minecraft-account-manager/contracts": "*",
|
"@minecraft-account-manager/contracts": "*",
|
||||||
"@minecraft-account-manager/database": "*",
|
"@minecraft-account-manager/database": "*",
|
||||||
|
"@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"
|
||||||
@@ -38,19 +39,25 @@
|
|||||||
"@minecraft-account-manager/auth": "*",
|
"@minecraft-account-manager/auth": "*",
|
||||||
"@minecraft-account-manager/contracts": "*",
|
"@minecraft-account-manager/contracts": "*",
|
||||||
"@minecraft-account-manager/database": "*",
|
"@minecraft-account-manager/database": "*",
|
||||||
|
"@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",
|
||||||
"next": "^16.2.1",
|
"next": "^16.2.1",
|
||||||
"next-auth": "^4.24.13",
|
"next-auth": "^4.24.13",
|
||||||
"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",
|
||||||
|
"@types/d3-geo": "^3.1.1",
|
||||||
"@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",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
@@ -1781,6 +1788,10 @@
|
|||||||
"resolved": "apps/discord-bot",
|
"resolved": "apps/discord-bot",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@minecraft-account-manager/logging": {
|
||||||
|
"resolved": "packages/logging",
|
||||||
|
"link": true
|
||||||
|
},
|
||||||
"node_modules/@minecraft-account-manager/minecraft": {
|
"node_modules/@minecraft-account-manager/minecraft": {
|
||||||
"resolved": "packages/minecraft",
|
"resolved": "packages/minecraft",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -2038,6 +2049,12 @@
|
|||||||
"url": "https://github.com/sponsors/panva"
|
"url": "https://github.com/sponsors/panva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@pinojs/redact": {
|
||||||
|
"version": "0.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
|
||||||
|
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz",
|
||||||
@@ -2712,6 +2729,16 @@
|
|||||||
"assertion-error": "^2.0.1"
|
"assertion-error": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-geo": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/deep-eql": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
@@ -2726,6 +2753,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/geojson": {
|
||||||
|
"version": "7946.0.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||||
|
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -2769,6 +2803,27 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@types/react": "^19.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/topojson-client": {
|
||||||
|
"version": "3.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
|
||||||
|
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*",
|
||||||
|
"@types/topojson-specification": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/topojson-specification": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/geojson": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/ws": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -3810,6 +3865,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/atomic-sleep": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/available-typed-arrays": {
|
"node_modules/available-typed-arrays": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz",
|
||||||
@@ -4063,6 +4127,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/commander": {
|
||||||
|
"version": "2.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||||
|
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/concat-map": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -4108,6 +4178,30 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-array": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"internmap": "1 - 2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-geo": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-array": "2.5.0 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -5697,6 +5791,15 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/internmap": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ipaddr.js": {
|
"node_modules/ipaddr.js": {
|
||||||
"version": "2.4.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
|
||||||
@@ -7052,6 +7155,15 @@
|
|||||||
"node": "^10.13.0 || >=12.0.0"
|
"node": "^10.13.0 || >=12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/on-exit-leak-free": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/openid-client": {
|
"node_modules/openid-client": {
|
||||||
"version": "5.7.1",
|
"version": "5.7.1",
|
||||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz",
|
||||||
@@ -7220,6 +7332,43 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pino": {
|
||||||
|
"version": "10.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
|
||||||
|
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@pinojs/redact": "^0.4.0",
|
||||||
|
"atomic-sleep": "^1.0.0",
|
||||||
|
"on-exit-leak-free": "^2.1.0",
|
||||||
|
"pino-abstract-transport": "^3.0.0",
|
||||||
|
"pino-std-serializers": "^7.0.0",
|
||||||
|
"process-warning": "^5.0.0",
|
||||||
|
"quick-format-unescaped": "^4.0.3",
|
||||||
|
"real-require": "^0.2.0",
|
||||||
|
"safe-stable-stringify": "^2.3.1",
|
||||||
|
"sonic-boom": "^4.0.1",
|
||||||
|
"thread-stream": "^4.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"pino": "bin.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-abstract-transport": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pino-std-serializers": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/possible-typed-array-names": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -7317,6 +7466,22 @@
|
|||||||
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
"integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/process-warning": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/prop-types": {
|
"node_modules/prop-types": {
|
||||||
"version": "15.8.1",
|
"version": "15.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||||
@@ -7360,6 +7525,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/quick-format-unescaped": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "19.2.8",
|
"version": "19.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||||
@@ -7388,6 +7559,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/real-require": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/reflect.getprototypeof": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -7600,6 +7780,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/safe-stable-stringify": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@@ -7834,6 +8023,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/sonic-boom": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"atomic-sleep": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map": {
|
"node_modules/source-map": {
|
||||||
"version": "0.6.1",
|
"version": "0.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||||
@@ -7864,6 +8062,15 @@
|
|||||||
"source-map": "^0.6.0"
|
"source-map": "^0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stable-hash": {
|
"node_modules/stable-hash": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||||
@@ -8106,6 +8313,24 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/thread-stream": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"real-require": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/thread-stream/node_modules/real-require": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "2.9.0",
|
"version": "2.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||||
@@ -8194,6 +8419,20 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/topojson-client": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"commander": "2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"topo2geo": "bin/topo2geo",
|
||||||
|
"topomerge": "bin/topomerge",
|
||||||
|
"topoquantize": "bin/topoquantize"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -9118,6 +9357,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/world-atlas": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.21.1",
|
"version": "8.21.1",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||||
@@ -9215,6 +9460,18 @@
|
|||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"packages/logging": {
|
||||||
|
"name": "@minecraft-account-manager/logging",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"pino": "^10.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.0.3",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"packages/minecraft": {
|
"packages/minecraft": {
|
||||||
"name": "@minecraft-account-manager/minecraft",
|
"name": "@minecraft-account-manager/minecraft",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ export function hashToken(token: string) {
|
|||||||
return createHash("sha256").update(token).digest("hex");
|
return createHash("sha256").update(token).digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveEffectiveGroup<T>(explicitGroup: T | null, defaultGroup: T | null) {
|
||||||
|
return explicitGroup ?? defaultGroup;
|
||||||
|
}
|
||||||
|
|
||||||
export function verifyHashedToken(providedToken: string, expectedHash: string) {
|
export function verifyHashedToken(providedToken: string, expectedHash: string) {
|
||||||
const provided = Buffer.from(hashToken(providedToken), "utf8");
|
const provided = Buffer.from(hashToken(providedToken), "utf8");
|
||||||
const expected = Buffer.from(expectedHash, "utf8");
|
const expected = Buffer.from(expectedHash, "utf8");
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveEffectiveGroup } from "../src/index";
|
||||||
|
|
||||||
|
describe("group-based admission", () => {
|
||||||
|
const everyone = { name: "everyone", accessEnabled: false };
|
||||||
|
|
||||||
|
it("uses the default group only when a user has no explicit assignment", () => {
|
||||||
|
expect(resolveEffectiveGroup(null, everyone)).toEqual(everyone);
|
||||||
|
expect(resolveEffectiveGroup({ name: "limited", accessEnabled: true }, everyone)).toEqual({
|
||||||
|
name: "limited",
|
||||||
|
accessEnabled: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not combine default and explicitly assigned group access", () => {
|
||||||
|
const enabledDefault = { name: "everyone", accessEnabled: true };
|
||||||
|
const limited = { name: "limited", accessEnabled: false };
|
||||||
|
expect(resolveEffectiveGroup(limited, enabledDefault)?.accessEnabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE "groups" (
|
||||||
|
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
"name" varchar(50) NOT NULL,
|
||||||
|
"slug" varchar(50) NOT NULL,
|
||||||
|
"description" text,
|
||||||
|
"access_enabled" boolean DEFAULT false NOT NULL,
|
||||||
|
"is_default" boolean DEFAULT false NOT NULL,
|
||||||
|
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp (3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user_group_memberships" (
|
||||||
|
"user_id" uuid NOT NULL,
|
||||||
|
"group_id" uuid NOT NULL,
|
||||||
|
"created_at" timestamp (3) with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_group_memberships" ADD CONSTRAINT "user_group_memberships_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "user_group_memberships" ADD CONSTRAINT "user_group_memberships_group_id_groups_id_fk" FOREIGN KEY ("group_id") REFERENCES "public"."groups"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "groups_slug_uidx" ON "groups" USING btree (lower("slug"));--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "groups_one_default_uidx" ON "groups" USING btree ("is_default") WHERE "groups"."is_default" = true;--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "user_group_memberships_user_group_uidx" ON "user_group_memberships" USING btree ("user_id","group_id");--> statement-breakpoint
|
||||||
|
CREATE INDEX "user_group_memberships_group_idx" ON "user_group_memberships" USING btree ("group_id");--> statement-breakpoint
|
||||||
|
INSERT INTO "groups" ("name", "slug", "description", "access_enabled", "is_default")
|
||||||
|
VALUES ('everyone', 'everyone', 'Default group containing every registered user.', false, true);
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
DROP INDEX "user_group_memberships_user_group_uidx";--> statement-breakpoint
|
||||||
|
DELETE FROM "user_group_memberships"
|
||||||
|
WHERE ctid IN (
|
||||||
|
SELECT ctid
|
||||||
|
FROM (
|
||||||
|
SELECT ctid, row_number() OVER (PARTITION BY "user_id" ORDER BY "created_at" DESC, "group_id") AS assignment_rank
|
||||||
|
FROM "user_group_memberships"
|
||||||
|
) ranked_assignments
|
||||||
|
WHERE assignment_rank > 1
|
||||||
|
);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX "user_group_memberships_user_uidx" ON "user_group_memberships" USING btree ("user_id");
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,20 @@
|
|||||||
"when": 1785605590058,
|
"when": 1785605590058,
|
||||||
"tag": "0001_silent_ultragirl",
|
"tag": "0001_silent_ultragirl",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785623198008,
|
||||||
|
"tag": "0002_simple_queen_noir",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1785625186545,
|
||||||
|
"tag": "0003_smiling_silver_samurai",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -113,6 +113,7 @@ export async function findUserBySessionToken(db: Database, tokenHash: string, no
|
|||||||
id: users.id,
|
id: users.id,
|
||||||
discordUserId: users.discordUserId,
|
discordUserId: users.discordUserId,
|
||||||
discordUsername: users.discordUsername,
|
discordUsername: users.discordUsername,
|
||||||
|
discordGlobalName: users.discordGlobalName,
|
||||||
firstName: users.firstName,
|
firstName: users.firstName,
|
||||||
onboardingCompletedAt: users.onboardingCompletedAt,
|
onboardingCompletedAt: users.onboardingCompletedAt,
|
||||||
sessionExpiresAt: sessions.expiresAt,
|
sessionExpiresAt: sessions.expiresAt,
|
||||||
|
|||||||
@@ -62,6 +62,40 @@ export const users = pgTable(
|
|||||||
(table) => [uniqueIndex("users_discord_user_id_uidx").on(table.discordUserId)],
|
(table) => [uniqueIndex("users_discord_user_id_uidx").on(table.discordUserId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const groups = pgTable(
|
||||||
|
"groups",
|
||||||
|
{
|
||||||
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
|
name: varchar("name", { length: 50 }).notNull(),
|
||||||
|
slug: varchar("slug", { length: 50 }).notNull(),
|
||||||
|
description: text("description"),
|
||||||
|
accessEnabled: boolean("access_enabled").notNull().default(false),
|
||||||
|
isDefault: boolean("is_default").notNull().default(false),
|
||||||
|
...timestamps(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("groups_slug_uidx").on(sql`lower(${table.slug})`),
|
||||||
|
uniqueIndex("groups_one_default_uidx").on(table.isDefault).where(sql`${table.isDefault} = true`),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export const userGroupMemberships = pgTable(
|
||||||
|
"user_group_memberships",
|
||||||
|
{
|
||||||
|
userId: uuid("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
groupId: uuid("group_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => groups.id, { onDelete: "cascade" }),
|
||||||
|
createdAt: createdAt(),
|
||||||
|
},
|
||||||
|
(table) => [
|
||||||
|
uniqueIndex("user_group_memberships_user_uidx").on(table.userId),
|
||||||
|
index("user_group_memberships_group_idx").on(table.groupId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
export const minecraftAccounts = pgTable(
|
export const minecraftAccounts = pgTable(
|
||||||
"minecraft_accounts",
|
"minecraft_accounts",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "@minecraft-account-manager/logging",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": { ".": "./src/index.ts" },
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pino": "^10.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^25.0.3",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.1.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import pino, { type DestinationStream, type Logger } from "pino";
|
||||||
|
|
||||||
|
const redactedPaths = [
|
||||||
|
"apiKey",
|
||||||
|
"authorization",
|
||||||
|
"password",
|
||||||
|
"token",
|
||||||
|
"*.apiKey",
|
||||||
|
"*.authorization",
|
||||||
|
"*.password",
|
||||||
|
"*.token",
|
||||||
|
"headers.authorization",
|
||||||
|
"req.headers.authorization",
|
||||||
|
];
|
||||||
|
|
||||||
|
export function createLogger(
|
||||||
|
service: string,
|
||||||
|
options: { destination?: DestinationStream } = {},
|
||||||
|
): Logger {
|
||||||
|
return pino(
|
||||||
|
{
|
||||||
|
level: process.env.LOG_LEVEL?.trim() || "info",
|
||||||
|
base: {
|
||||||
|
service,
|
||||||
|
environment: process.env.NODE_ENV ?? "development",
|
||||||
|
version: process.env.APP_VERSION ?? "development",
|
||||||
|
},
|
||||||
|
redact: {
|
||||||
|
paths: redactedPaths,
|
||||||
|
censor: "[Redacted]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
options.destination,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Writable } from "node:stream";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createLogger } from "../src/index";
|
||||||
|
|
||||||
|
function captureLog() {
|
||||||
|
let output = "";
|
||||||
|
const destination = new Writable({
|
||||||
|
write(chunk, _encoding, callback) {
|
||||||
|
output += chunk.toString();
|
||||||
|
callback();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { destination, read: () => JSON.parse(output.trim()) as Record<string, unknown> };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("structured application logging", () => {
|
||||||
|
it("emits service metadata and redacts credential fields", () => {
|
||||||
|
const capture = captureLog();
|
||||||
|
const logger = createLogger("account-manager-test", { destination: capture.destination });
|
||||||
|
|
||||||
|
logger.info({ token: "secret-token", apiKey: "secret-key", operation: "test" }, "Test event");
|
||||||
|
|
||||||
|
expect(capture.read()).toMatchObject({
|
||||||
|
service: "account-manager-test",
|
||||||
|
token: "[Redacted]",
|
||||||
|
apiKey: "[Redacted]",
|
||||||
|
operation: "test",
|
||||||
|
msg: "Test event",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": { "types": ["node", "vitest/globals"] },
|
||||||
|
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -43,7 +43,47 @@ export function formatManagedDiscordNickname(
|
|||||||
minecraftUsername: string | null,
|
minecraftUsername: string | null,
|
||||||
) {
|
) {
|
||||||
if (minecraftUsername) return formatDiscordNickname(firstName, minecraftUsername);
|
if (minecraftUsername) return formatDiscordNickname(firstName, minecraftUsername);
|
||||||
return [...firstName.trim()].slice(0, DISCORD_NICKNAME_LIMIT).join("").trimEnd();
|
return formatDiscordNickname(firstName, "TBD");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscordGuildIdentity {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
globalName: string | null;
|
||||||
|
nickname: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGuildMemberIdentity(
|
||||||
|
input: { guildId: string; discordUserId: string; botToken: string },
|
||||||
|
request: typeof fetch = fetch,
|
||||||
|
): Promise<DiscordGuildIdentity> {
|
||||||
|
const response = await request(
|
||||||
|
`https://discord.com/api/v10/guilds/${input.guildId}/members/${input.discordUserId}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
authorization: `Bot ${input.botToken}`,
|
||||||
|
accept: "application/json",
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(`Discord guild member lookup failed (${response.status})`);
|
||||||
|
|
||||||
|
const payload: unknown = await response.json();
|
||||||
|
if (!payload || typeof payload !== "object") throw new Error("Discord guild member lookup returned invalid data");
|
||||||
|
const member = payload as { nick?: unknown; user?: unknown };
|
||||||
|
if (!member.user || typeof member.user !== "object") throw new Error("Discord guild member lookup omitted user data");
|
||||||
|
const user = member.user as { id?: unknown; username?: unknown; global_name?: unknown };
|
||||||
|
if (typeof user.id !== "string" || typeof user.username !== "string") {
|
||||||
|
throw new Error("Discord guild member lookup returned invalid user data");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
globalName: typeof user.global_name === "string" ? user.global_name : null,
|
||||||
|
nickname: typeof member.nick === "string" ? member.nick : null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateGuildNickname(
|
export async function updateGuildNickname(
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { updateGuildNickname } from "../src/index";
|
import { getGuildMemberIdentity, updateGuildNickname } from "../src/index";
|
||||||
|
|
||||||
|
describe("Discord guild identity", () => {
|
||||||
|
it("reads the member username, global name, nickname, and immutable ID", async () => {
|
||||||
|
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response(JSON.stringify({
|
||||||
|
nick: "Sam (Notch)",
|
||||||
|
user: { id: "987654321098765432", username: "samcraft", global_name: "Sam" },
|
||||||
|
}), { status: 200, headers: { "content-type": "application/json" } }));
|
||||||
|
|
||||||
|
await expect(getGuildMemberIdentity({
|
||||||
|
guildId: "123456789012345678",
|
||||||
|
discordUserId: "987654321098765432",
|
||||||
|
botToken: "secret",
|
||||||
|
}, request)).resolves.toEqual({
|
||||||
|
id: "987654321098765432",
|
||||||
|
username: "samcraft",
|
||||||
|
globalName: "Sam",
|
||||||
|
nickname: "Sam (Notch)",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("Discord nickname updates", () => {
|
describe("Discord nickname updates", () => {
|
||||||
it("updates a member in the configured guild using bot authentication", async () => {
|
it("updates a member in the configured guild using bot authentication", async () => {
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ describe("Java Edition profiles", () => {
|
|||||||
expect(formatDiscordNickname("Sam", "Notch")).toBe("Sam (Notch)");
|
expect(formatDiscordNickname("Sam", "Notch")).toBe("Sam (Notch)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the user's name when an admin removes their final account", () => {
|
it("marks the Discord nickname TBD when the user has no Minecraft account", () => {
|
||||||
expect(formatManagedDiscordNickname("Alexandria Catherine", null)).toBe("Alexandria Catherine");
|
expect(formatManagedDiscordNickname("Alexandria Catherine", null)).toBe("Alexandria Catherine (TBD)");
|
||||||
expect(formatManagedDiscordNickname("A name that is definitely longer than Discord allows", null)).toHaveLength(32);
|
expect(formatManagedDiscordNickname("A name that is definitely longer than Discord allows", null).length).toBeLessThanOrEqual(32);
|
||||||
|
expect(formatManagedDiscordNickname("A name that is definitely longer than Discord allows", null)).toMatch(/ \(TBD\)$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -118,6 +118,26 @@ export class ProxyCheckProvider implements IpIntelligenceProvider {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function addressGroup(ipAddress: string) {
|
||||||
|
const hostAddress = ipAddress.split("/", 1)[0] ?? ipAddress;
|
||||||
|
if (!isIP(hostAddress)) return ipAddress;
|
||||||
|
|
||||||
|
let address = ipaddr.parse(hostAddress);
|
||||||
|
if (address instanceof ipaddr.IPv6 && address.isIPv4MappedAddress()) {
|
||||||
|
address = address.toIPv4Address();
|
||||||
|
}
|
||||||
|
|
||||||
|
const prefixLength = address.kind() === "ipv4" ? 24 : 64;
|
||||||
|
const bytes = address.toByteArray();
|
||||||
|
for (let bit = prefixLength; bit < bytes.length * 8; bit += 1) {
|
||||||
|
const byteIndex = Math.floor(bit / 8);
|
||||||
|
const bitMask = 1 << (7 - (bit % 8));
|
||||||
|
bytes[byteIndex] = (bytes[byteIndex] ?? 0) & ~bitMask;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${ipaddr.fromByteArray(bytes).toString()}/${prefixLength}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function isPublicIp(ipAddress: string) {
|
export function isPublicIp(ipAddress: string) {
|
||||||
if (!isIP(ipAddress)) return false;
|
if (!isIP(ipAddress)) return false;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { addressGroup } from "../src/index";
|
||||||
|
|
||||||
|
describe("access address groups", () => {
|
||||||
|
it("groups nearby IPv4 and IPv6 addresses by their stable network prefix", () => {
|
||||||
|
expect(addressGroup("198.51.100.21")).toBe("198.51.100.0/24");
|
||||||
|
expect(addressGroup("198.51.100.240")).toBe("198.51.100.0/24");
|
||||||
|
expect(addressGroup("198.51.100.99/32")).toBe("198.51.100.0/24");
|
||||||
|
expect(addressGroup("2001:db8:abcd:1234:1111::1")).toBe("2001:db8:abcd:1234::/64");
|
||||||
|
expect(addressGroup("2001:db8:abcd:1234:ffff::9")).toBe("2001:db8:abcd:1234::/64");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user