From c5de0a1810187a9b0cd0a9fa5f907290534e4b44 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 1 Aug 2026 13:45:18 -0400 Subject: [PATCH] feat(platform): add Discord onboarding and Velocity gate --- .env.example | 6 + README.md | 17 +- apps/discord-bot/.env.example | 5 + apps/discord-bot/README.md | 12 +- apps/discord-bot/package.json | 10 +- apps/discord-bot/src/commands.ts | 10 + apps/discord-bot/src/config.ts | 7 + apps/discord-bot/src/deploy-commands.ts | 11 + apps/discord-bot/src/index.ts | 81 +- apps/web/next.config.ts | 28 + apps/web/package.json | 5 + apps/web/src/app/account/actions.ts | 132 + apps/web/src/app/account/page.tsx | 148 + apps/web/src/app/admin/(console)/actions.ts | 35 + .../src/app/admin/(console)/events/page.tsx | 32 + apps/web/src/app/admin/(console)/layout.tsx | 44 + apps/web/src/app/admin/(console)/page.tsx | 48 + apps/web/src/app/admin/login/page.tsx | 26 + .../src/app/api/auth/[...nextauth]/route.ts | 6 + apps/web/src/app/api/velocity/access/route.ts | 179 ++ apps/web/src/app/auth/actions.ts | 23 + apps/web/src/app/auth/discord/route.ts | 51 + apps/web/src/app/auth/error/page.tsx | 14 + apps/web/src/app/page.tsx | 26 +- apps/web/src/app/welcome/actions.ts | 109 + apps/web/src/app/welcome/discord/page.tsx | 34 + apps/web/src/app/welcome/minecraft/page.tsx | 48 + apps/web/src/app/welcome/page.tsx | 25 + .../src/components/admin-sign-in-button.tsx | 15 + .../src/components/admin-sign-out-button.tsx | 15 + apps/web/src/lib/audit.ts | 50 + apps/web/src/lib/auth/admin-auth.ts | 58 + apps/web/src/lib/auth/user-session.ts | 20 + apps/web/src/lib/database.ts | 18 + apps/web/src/types/next-auth.d.ts | 7 + docs/admin-oidc-keycloak-setup.md | 2 +- docs/architecture.md | 4 +- docs/security-review.md | 39 + package-lock.json | 2487 ++++++----------- package.json | 10 +- packages/auth/package.json | 18 + packages/auth/src/index.ts | 177 ++ packages/auth/test/magic-link.test.ts | 104 + packages/auth/test/oidc-roles.test.ts | 31 + packages/auth/test/plugin-auth.test.ts | 18 + packages/auth/tsconfig.json | 7 + .../drizzle/0001_silent_ultragirl.sql | 10 + .../database/drizzle/meta/0001_snapshot.json | 1083 +++++++ packages/database/drizzle/meta/_journal.json | 7 + packages/database/package.json | 3 + .../scripts/create-plugin-credential.ts | 29 + packages/database/src/auth-repository.ts | 132 + packages/database/src/events.ts | 35 + packages/database/src/index.ts | 2 + packages/database/src/schema.ts | 16 +- packages/database/tsconfig.json | 2 +- packages/minecraft/package.json | 15 + packages/minecraft/src/index.ts | 65 + packages/minecraft/test/discord.test.ts | 28 + packages/minecraft/test/minecraft.test.ts | 30 + packages/minecraft/tsconfig.json | 5 + packages/network/package.json | 9 + packages/network/src/index.ts | 30 + packages/network/test/client-ip.test.ts | 27 + packages/network/tsconfig.json | 5 + plugins/velocity/.gitignore | 2 + plugins/velocity/.gitkeep | 0 plugins/velocity/README.md | 25 +- plugins/velocity/build.gradle.kts | 38 + .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + plugins/velocity/gradlew | 248 ++ plugins/velocity/gradlew.bat | 82 + plugins/velocity/settings.gradle.kts | 1 + .../accountmanager/AccessDecision.java | 7 + .../accountmanager/AccountManagerClient.java | 70 + .../MinecraftAccountManagerPlugin.java | 69 + .../accountmanager/PluginConfig.java | 42 + .../src/main/resources/config.properties | 6 + .../AccountManagerClientTest.java | 30 + 80 files changed, 4840 insertions(+), 1572 deletions(-) create mode 100644 apps/discord-bot/.env.example create mode 100644 apps/discord-bot/src/commands.ts create mode 100644 apps/discord-bot/src/config.ts create mode 100644 apps/discord-bot/src/deploy-commands.ts create mode 100644 apps/web/src/app/account/actions.ts create mode 100644 apps/web/src/app/account/page.tsx create mode 100644 apps/web/src/app/admin/(console)/actions.ts create mode 100644 apps/web/src/app/admin/(console)/events/page.tsx create mode 100644 apps/web/src/app/admin/(console)/layout.tsx create mode 100644 apps/web/src/app/admin/(console)/page.tsx create mode 100644 apps/web/src/app/admin/login/page.tsx create mode 100644 apps/web/src/app/api/auth/[...nextauth]/route.ts create mode 100644 apps/web/src/app/api/velocity/access/route.ts create mode 100644 apps/web/src/app/auth/actions.ts create mode 100644 apps/web/src/app/auth/discord/route.ts create mode 100644 apps/web/src/app/auth/error/page.tsx create mode 100644 apps/web/src/app/welcome/actions.ts create mode 100644 apps/web/src/app/welcome/discord/page.tsx create mode 100644 apps/web/src/app/welcome/minecraft/page.tsx create mode 100644 apps/web/src/app/welcome/page.tsx create mode 100644 apps/web/src/components/admin-sign-in-button.tsx create mode 100644 apps/web/src/components/admin-sign-out-button.tsx create mode 100644 apps/web/src/lib/audit.ts create mode 100644 apps/web/src/lib/auth/admin-auth.ts create mode 100644 apps/web/src/lib/auth/user-session.ts create mode 100644 apps/web/src/lib/database.ts create mode 100644 apps/web/src/types/next-auth.d.ts create mode 100644 docs/security-review.md create mode 100644 packages/auth/package.json create mode 100644 packages/auth/src/index.ts create mode 100644 packages/auth/test/magic-link.test.ts create mode 100644 packages/auth/test/oidc-roles.test.ts create mode 100644 packages/auth/test/plugin-auth.test.ts create mode 100644 packages/auth/tsconfig.json create mode 100644 packages/database/drizzle/0001_silent_ultragirl.sql create mode 100644 packages/database/drizzle/meta/0001_snapshot.json create mode 100644 packages/database/scripts/create-plugin-credential.ts create mode 100644 packages/database/src/auth-repository.ts create mode 100644 packages/database/src/events.ts create mode 100644 packages/minecraft/package.json create mode 100644 packages/minecraft/src/index.ts create mode 100644 packages/minecraft/test/discord.test.ts create mode 100644 packages/minecraft/test/minecraft.test.ts create mode 100644 packages/minecraft/tsconfig.json create mode 100644 packages/network/package.json create mode 100644 packages/network/src/index.ts create mode 100644 packages/network/test/client-ip.test.ts create mode 100644 packages/network/tsconfig.json create mode 100644 plugins/velocity/.gitignore delete mode 100644 plugins/velocity/.gitkeep create mode 100644 plugins/velocity/build.gradle.kts create mode 100644 plugins/velocity/gradle/wrapper/gradle-wrapper.jar create mode 100644 plugins/velocity/gradle/wrapper/gradle-wrapper.properties create mode 100755 plugins/velocity/gradlew create mode 100644 plugins/velocity/gradlew.bat create mode 100644 plugins/velocity/settings.gradle.kts create mode 100644 plugins/velocity/src/main/java/games/twentyfaces/accountmanager/AccessDecision.java create mode 100644 plugins/velocity/src/main/java/games/twentyfaces/accountmanager/AccountManagerClient.java create mode 100644 plugins/velocity/src/main/java/games/twentyfaces/accountmanager/MinecraftAccountManagerPlugin.java create mode 100644 plugins/velocity/src/main/java/games/twentyfaces/accountmanager/PluginConfig.java create mode 100644 plugins/velocity/src/main/resources/config.properties create mode 100644 plugins/velocity/src/test/java/games/twentyfaces/accountmanager/AccountManagerClientTest.java diff --git a/.env.example b/.env.example index 0480e98..6967961 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,7 @@ APP_URL=http://localhost:3000 SESSION_SECRET= # Admin OIDC / Keycloak +NEXTAUTH_URL=http://localhost:3000 AUTH_SECRET= KEYCLOAK_ISSUER_URL= KEYCLOAK_CLIENT_ID=minecraft-account-manager-admin @@ -12,6 +13,11 @@ KEYCLOAK_REQUIRED_ROLE=minecraft-account-manager-admin # Discord DISCORD_BOT_TOKEN= DISCORD_APPLICATION_ID= +DISCORD_GUILD_ID= +DISCORD_INVITE_URL=https://discord.gg/your-invite + +# Trust forwarding headers only when your reverse proxy overwrites them +TRUST_PROXY=false # Optional VPN intelligence provider (deferred for v1) IP_INTELLIGENCE_PROVIDER=none diff --git a/README.md b/README.md index f20396a..db44336 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ A Discord-first account registry for a private Java Edition Minecraft network. P - `apps/discord-bot` — discord.js slash-command bot - `packages/contracts` — shared Zod contracts and CloudEvents types - `packages/database` — PostgreSQL Drizzle schema and versioned migrations -- `plugins/velocity` — Velocity admission plugin (planned) +- `plugins/velocity` — fail-closed Velocity admission plugin ## Requirements @@ -26,6 +26,8 @@ npm run db:migrate npm run dev ``` +Set `DISCORD_GUILD_ID` and `DISCORD_INVITE_URL` in `.env.local` so unauthenticated visitors can reach the Discord server. The HTTPS invite is the most reliable way to open Discord or join; the landing page also offers a `discord://` app link. + Open `http://localhost:3000`. ## Validation @@ -35,6 +37,7 @@ npm test npm run typecheck npm run lint npm run build +npm run velocity:build ``` ## Database workflow @@ -48,14 +51,22 @@ npm run db:migrate Do not use `drizzle push`; it bypasses the reviewed migration history and can cause destructive schema changes. +Provision or rotate a Velocity API token after migrating: + +```bash +npm run plugin:create-credential --workspace @minecraft-account-manager/database -- velocity-main +``` + +The token is displayed once and stored only as a SHA-256 hash. + ## Confirmed product decisions - PostgreSQL and Drizzle ORM - Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role -- Admin-managed Discord guild configuration +- Deployment-managed Discord guild ID and invite URL - discord.js bot with `/register` and `/account` - Java Edition online-mode accounts only - Velocity admission checks are fail closed - VPN detection is represented in the schema but may remain disabled in the first release until a provider is selected -See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities. +See [`docs/architecture.md`](docs/architecture.md) for trust boundaries and service responsibilities, and [`docs/security-review.md`](docs/security-review.md) for implemented controls and production requirements. diff --git a/apps/discord-bot/.env.example b/apps/discord-bot/.env.example new file mode 100644 index 0000000..363a03f --- /dev/null +++ b/apps/discord-bot/.env.example @@ -0,0 +1,5 @@ +DATABASE_URL=postgresql://minecraft:minecraft@localhost:5432/minecraft_accounts +APP_URL=http://localhost:3000 +DISCORD_BOT_TOKEN= +DISCORD_APPLICATION_ID= +DISCORD_GUILD_ID= diff --git a/apps/discord-bot/README.md b/apps/discord-bot/README.md index 5cd2d4d..eb02658 100644 --- a/apps/discord-bot/README.md +++ b/apps/discord-bot/README.md @@ -1,5 +1,13 @@ # Discord bot -The bot will provide ephemeral `/register` and `/account` responses containing short-lived one-time links. The target guild is read from admin-managed application settings rather than a deployment-only environment variable. +The bot provides ephemeral `/register` and `/account` responses containing ten-minute, single-use links. Commands only work in the guild configured by `DISCORD_GUILD_ID`. -Implementation begins in Phase 2 alongside the one-time login service. +## Setup + +```bash +cp apps/discord-bot/.env.example apps/discord-bot/.env +npm run commands:deploy --workspace @minecraft-account-manager/discord-bot +npm run dev --workspace @minecraft-account-manager/discord-bot +``` + +The bot requires permission to use application commands. Nickname management will additionally require `Manage Nicknames`, with the bot role above managed members. diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json index 6f6ca64..ff1ecf8 100644 --- a/apps/discord-bot/package.json +++ b/apps/discord-bot/package.json @@ -4,13 +4,21 @@ "private": true, "type": "module", "scripts": { + "dev": "tsx watch src/index.ts", + "start": "tsx src/index.ts", + "commands:deploy": "tsx src/deploy-commands.ts", "typecheck": "tsc --noEmit" }, "dependencies": { + "@minecraft-account-manager/auth": "*", "@minecraft-account-manager/contracts": "*", - "discord.js": "^14.25.1" + "@minecraft-account-manager/database": "*", + "discord.js": "^14.25.1", + "dotenv": "^17.2.3", + "drizzle-orm": "^0.45.1" }, "devDependencies": { + "tsx": "^4.21.0", "typescript": "^5.9.3" } } diff --git a/apps/discord-bot/src/commands.ts b/apps/discord-bot/src/commands.ts new file mode 100644 index 0000000..8347f7a --- /dev/null +++ b/apps/discord-bot/src/commands.ts @@ -0,0 +1,10 @@ +import { SlashCommandBuilder } from "discord.js"; + +export const commands = [ + new SlashCommandBuilder() + .setName("register") + .setDescription("Register a Minecraft account through a private login link"), + new SlashCommandBuilder() + .setName("account") + .setDescription("Open your Minecraft account dashboard through a private login link"), +].map((command) => command.toJSON()); diff --git a/apps/discord-bot/src/config.ts b/apps/discord-bot/src/config.ts new file mode 100644 index 0000000..f026a67 --- /dev/null +++ b/apps/discord-bot/src/config.ts @@ -0,0 +1,7 @@ +export function requiredEnvironment(name: string) { + const value = process.env[name]?.trim(); + if (!value) throw new Error(`Missing required environment variable: ${name}`); + return value; +} + +export const commandNames = ["register", "account"] as const; diff --git a/apps/discord-bot/src/deploy-commands.ts b/apps/discord-bot/src/deploy-commands.ts new file mode 100644 index 0000000..31fb8a0 --- /dev/null +++ b/apps/discord-bot/src/deploy-commands.ts @@ -0,0 +1,11 @@ +import "dotenv/config"; +import { REST, Routes } from "discord.js"; +import { commands } from "./commands"; +import { requiredEnvironment } from "./config"; + +const token = requiredEnvironment("DISCORD_BOT_TOKEN"); +const applicationId = requiredEnvironment("DISCORD_APPLICATION_ID"); +const rest = new REST({ version: "10" }).setToken(token); + +await rest.put(Routes.applicationCommands(applicationId), { body: commands }); +console.log(`Deployed ${commands.length} global Discord commands.`); diff --git a/apps/discord-bot/src/index.ts b/apps/discord-bot/src/index.ts index f4600a7..3471606 100644 --- a/apps/discord-bot/src/index.ts +++ b/apps/discord-bot/src/index.ts @@ -1,2 +1,79 @@ -// Discord command handling is added in Phase 2 after the one-time login service exists. -export {}; +import "dotenv/config"; +import { createMagicLink, LoginRateLimitedError } from "@minecraft-account-manager/auth"; +import { + createAuthRepository, + createDatabase, + recordEvent, +} from "@minecraft-account-manager/database"; +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + Client, + Events, + GatewayIntentBits, +} from "discord.js"; +import { commandNames, requiredEnvironment } from "./config"; + +const token = requiredEnvironment("DISCORD_BOT_TOKEN"); +const appUrl = requiredEnvironment("APP_URL"); +const databaseUrl = requiredEnvironment("DATABASE_URL"); +const discordGuildId = requiredEnvironment("DISCORD_GUILD_ID"); +const { db } = createDatabase(databaseUrl); +const authRepository = createAuthRepository(db); +const client = new Client({ intents: [GatewayIntentBits.Guilds] }); + +client.once(Events.ClientReady, (readyClient) => { + console.log(`Discord bot ready as ${readyClient.user.tag}`); +}); + +client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isChatInputCommand()) return; + if (!commandNames.includes(interaction.commandName as (typeof commandNames)[number])) return; + + await interaction.deferReply({ ephemeral: true }); + + try { + if (interaction.guildId !== discordGuildId) { + await interaction.editReply("This command is only available in the configured community server."); + return; + } + + const magicLink = await createMagicLink( + { + id: interaction.user.id, + username: interaction.user.username, + globalName: interaction.user.globalName, + }, + { repository: authRepository, appUrl }, + ); + + await recordEvent(db, { + type: "games.minecraft.account-manager.auth.magic-link.created", + source: "/discord-bot", + subject: `discord-user/${interaction.user.id}`, + data: { command: interaction.commandName, guildId: interaction.guildId }, + }); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel(interaction.commandName === "register" ? "Start registration" : "Open my account") + .setStyle(ButtonStyle.Link) + .setURL(magicLink.url), + ); + + await interaction.editReply({ + content: "This private link works once and expires in 10 minutes. Do not share it.", + components: [row], + }); + } catch (error) { + if (error instanceof LoginRateLimitedError) { + await interaction.editReply("Please wait 30 seconds before requesting another private account link."); + return; + } + console.error("Failed to create Discord account link", error); + await interaction.editReply("I could not create an account link. Please try again shortly."); + } +}); + +await client.login(token); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 5951b49..0b7c0a1 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,7 +1,35 @@ import type { NextConfig } from "next"; +const contentSecurityPolicy = [ + "default-src 'self'", + `script-src 'self' 'unsafe-inline'${process.env.NODE_ENV === "development" ? " 'unsafe-eval'" : ""}`, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data:", + "font-src 'self'", + "connect-src 'self'", + "object-src 'none'", + "base-uri 'self'", + "form-action 'self'", + "frame-ancestors 'none'", +].join("; "); + const nextConfig: NextConfig = { output: "standalone", + poweredByHeader: false, + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { key: "Content-Security-Policy", value: contentSecurityPolicy }, + { key: "Referrer-Policy", value: "no-referrer" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, + ], + }, + ]; + }, serverExternalPackages: ["postgres"], transpilePackages: [ "@minecraft-account-manager/contracts", diff --git a/apps/web/package.json b/apps/web/package.json index 6c8b718..9a8201f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,9 +10,14 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@minecraft-account-manager/auth": "*", "@minecraft-account-manager/contracts": "*", "@minecraft-account-manager/database": "*", + "@minecraft-account-manager/minecraft": "*", + "@minecraft-account-manager/network": "*", + "drizzle-orm": "^0.45.1", "next": "^16.2.1", + "next-auth": "^4.24.13", "react": "^19.2.3", "react-dom": "^19.2.3" }, diff --git a/apps/web/src/app/account/actions.ts b/apps/web/src/app/account/actions.ts new file mode 100644 index 0000000..71c56a3 --- /dev/null +++ b/apps/web/src/app/account/actions.ts @@ -0,0 +1,132 @@ +"use server"; + +import { formatDiscordNickname, lookupJavaProfile, updateGuildNickname } from "@minecraft-account-manager/minecraft"; +import { minecraftAccounts, users } from "@minecraft-account-manager/database"; +import { and, eq, isNull, ne } from "drizzle-orm"; +import { redirect } from "next/navigation"; +import { recordUserEvent } from "@/lib/audit"; +import { db } from "@/lib/database"; +import { requireCurrentUser } from "@/lib/auth/user-session"; + +const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/; + +export async function updateFirstName(formData: FormData) { + const user = await requireCurrentUser(); + const firstName = String(formData.get("firstName") ?? "").trim(); + if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) { + redirect("/account?error=invalid-name"); + } + await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id)); + await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName }); + redirect("/account?confirmNickname=1"); +} + +export async function addMinecraftAccount(formData: FormData) { + const user = await requireCurrentUser(); + const requestedUsername = String(formData.get("username") ?? "").trim(); + const confirmed = formData.get("confirmUnverified") === "yes"; + if (!USERNAME_PATTERN.test(requestedUsername)) redirect("/account?error=invalid-username"); + + const profile = await lookupJavaProfile(requestedUsername); + if (!profile && !confirmed) redirect(`/account?unverified=${encodeURIComponent(requestedUsername)}`); + + const [existing] = await db.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where( + and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), + ).limit(1); + + let failed = false; + try { + await db.insert(minecraftAccounts).values({ + userId: user.id, + minecraftUuid: profile?.uuid ?? null, + username: profile?.username ?? requestedUsername, + validationStatus: profile ? "verified" : "user_confirmed", + lastVerifiedAt: profile ? new Date() : null, + isPrimary: !existing, + }); + } catch { + failed = true; + } + if (failed) redirect("/account?error=already-registered"); + await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.added", { + username: profile?.username ?? requestedUsername, + minecraftUuid: profile?.uuid ?? null, + validationStatus: profile ? "verified" : "user_confirmed", + }); + redirect(existing ? "/account?added=1" : "/account?confirmNickname=1"); +} + +export async function setPrimaryAccount(formData: FormData) { + const user = await requireCurrentUser(); + const accountId = String(formData.get("accountId") ?? ""); + + const changed = await db.transaction(async (tx) => { + const [account] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where( + and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), + ).limit(1); + if (!account) return false; + + await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where( + and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), + ); + await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id)); + return true; + }); + + if (!changed) redirect("/account?error=unknown-account"); + await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.primary-changed", { accountId }); + redirect("/account?confirmNickname=1"); +} + +export async function removeMinecraftAccount(formData: FormData) { + const user = await requireCurrentUser(); + const accountId = String(formData.get("accountId") ?? ""); + + const removed = await db.transaction(async (tx) => { + const [account] = await tx.select({ id: minecraftAccounts.id, isPrimary: minecraftAccounts.isPrimary }).from(minecraftAccounts).where( + and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), + ).limit(1); + if (!account) return false; + + await tx.update(minecraftAccounts).set({ deletedAt: new Date(), isPrimary: false, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id)); + if (account.isPrimary) { + const [replacement] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where( + and(eq(minecraftAccounts.userId, user.id), ne(minecraftAccounts.id, account.id), isNull(minecraftAccounts.deletedAt)), + ).limit(1); + if (replacement) { + await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, replacement.id)); + } + } + return true; + }); + + if (!removed) redirect("/account?error=unknown-account"); + await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.removed", { accountId }); + redirect("/account?removed=1&confirmNickname=1"); +} + +export async function confirmDashboardNickname() { + const user = await requireCurrentUser(); + const [account] = await db.select({ username: minecraftAccounts.username }).from(minecraftAccounts).where( + and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)), + ).limit(1); + const guildId = process.env.DISCORD_GUILD_ID?.trim(); + const botToken = process.env.DISCORD_BOT_TOKEN?.trim(); + + if (!user.firstName || !account || !guildId || !botToken) redirect("/account?error=nickname-not-configured"); + + try { + await updateGuildNickname({ + guildId, + discordUserId: user.discordUserId, + nickname: formatDiscordNickname(user.firstName, account.username), + botToken, + }); + } catch { + redirect("/account?error=nickname-update-failed&confirmNickname=1"); + } + await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", { + nickname: formatDiscordNickname(user.firstName, account.username), + }); + redirect("/account?nicknameUpdated=1"); +} diff --git a/apps/web/src/app/account/page.tsx b/apps/web/src/app/account/page.tsx new file mode 100644 index 0000000..eaa7ae2 --- /dev/null +++ b/apps/web/src/app/account/page.tsx @@ -0,0 +1,148 @@ +import { formatDiscordNickname } from "@minecraft-account-manager/minecraft"; +import { ipObservations, minecraftAccounts } from "@minecraft-account-manager/database"; +import { and, desc, eq, isNull } from "drizzle-orm"; +import { logout } from "@/app/auth/actions"; +import { db } from "@/lib/database"; +import { requireCurrentUser } from "@/lib/auth/user-session"; +import { + addMinecraftAccount, + confirmDashboardNickname, + removeMinecraftAccount, + setPrimaryAccount, + updateFirstName, +} from "./actions"; + +const errorMessages: Record = { + "invalid-name": "Enter a valid name between 1 and 50 characters.", + "invalid-username": "Java usernames use 3–16 letters, numbers, or underscores.", + "already-registered": "That Minecraft account is already registered.", + "unknown-account": "That account is no longer available.", + "nickname-not-configured": "Discord nickname updates are not configured.", + "nickname-update-failed": "Discord rejected the nickname update. An admin may need to adjust bot permissions.", +}; + +export default async function AccountPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + const user = await requireCurrentUser("/account"); + const query = await searchParams; + const [accounts, observations] = await Promise.all([ + db + .select() + .from(minecraftAccounts) + .where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt))) + .orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username), + db + .select() + .from(ipObservations) + .where(eq(ipObservations.userId, user.id)) + .orderBy(desc(ipObservations.observedAt)) + .limit(20), + ]); + const primary = accounts.find((account) => account.isPrimary); + const desiredNickname = user.firstName && primary + ? formatDiscordNickname(user.firstName, primary.username) + : null; + + return ( +
+
+
+
+

Account registry

+

{user.firstName ?? user.discordUsername}

+
+
+
+ + {query.error && ( +

+ {errorMessages[query.error] ?? "The requested change could not be completed."} +

+ )} + {query.nicknameUpdated &&

Discord nickname updated

} + + {query.confirmNickname && desiredNickname && ( +
+
+

Confirm Discord change

+

Your community nickname will become

+

{desiredNickname}

+
+
+ +
+
+ )} + +
+
+
+
+

Whitelist identities

Minecraft accounts

+ {accounts.length} active +
+ +
+ {accounts.map((account) => ( +
+
+
+

{account.username}

+ {account.isPrimary && Primary} + {account.validationStatus === "verified" ? "UUID verified" : "User confirmed"} +
+

{account.minecraftUuid ?? "UUID will be learned at game login"}

+
+
+ {!account.isPrimary &&
} +
+
+
+ ))} + {accounts.length === 0 &&

No active Minecraft accounts. Add one before joining the server.

} +
+ + {query.unverified ? ( +
+

Mojang couldn’t verify “{query.unverified}”

+

Continue only if you are certain the spelling is correct.

+ + + Cancel +
+ ) : ( +
+ + +
+ )} +
+ +
+

Recent security activity

+

Access addresses

+ {observations.length ? ( +
+ {observations.map((observation) =>
{observation.ipAddress}{observation.source} · {observation.observedAt.toISOString()}
)} +
+ ) :

No web or game access addresses have been recorded yet.

} +
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/app/admin/(console)/actions.ts b/apps/web/src/app/admin/(console)/actions.ts new file mode 100644 index 0000000..17b6802 --- /dev/null +++ b/apps/web/src/app/admin/(console)/actions.ts @@ -0,0 +1,35 @@ +"use server"; + +import { appSettings } from "@minecraft-account-manager/database"; +import { getServerSession } from "next-auth"; +import { redirect } from "next/navigation"; +import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth"; +import { db } from "@/lib/database"; + +export async function saveDiscordSettings(formData: FormData) { + const session = await getServerSession(adminAuthOptions); + const roles = (session?.user as { roles?: string[] } | undefined)?.roles ?? []; + if (!session || !roles.includes(requiredAdminRole)) redirect("/admin/login"); + + const registrationMessage = String(formData.get("registrationMessage") ?? "").trim(); + + if (registrationMessage.length < 10 || registrationMessage.length > 500) { + redirect("/admin?error=invalid-message"); + } + + await db + .insert(appSettings) + .values({ + id: "default", + registrationMessage, + }) + .onConflictDoUpdate({ + target: appSettings.id, + set: { + registrationMessage, + updatedAt: new Date(), + }, + }); + + redirect("/admin?saved=1"); +} diff --git a/apps/web/src/app/admin/(console)/events/page.tsx b/apps/web/src/app/admin/(console)/events/page.tsx new file mode 100644 index 0000000..f2d06ba --- /dev/null +++ b/apps/web/src/app/admin/(console)/events/page.tsx @@ -0,0 +1,32 @@ +import { events } from "@minecraft-account-manager/database"; +import { desc } from "drizzle-orm"; +import { db } from "@/lib/database"; + +export default async function EventsPage() { + const recentEvents = await db.select().from(events).orderBy(desc(events.time)).limit(100); + + return ( +
+

CloudEvents ledger

+

Recent events

+
+ + + + + + {recentEvents.map((event) => ( + + + + + + + ))} + {!recentEvents.length && } + +
TimeTypeSubjectIP
{event.time.toISOString()}{event.type}{event.subject ?? "—"}{event.ipAddress ?? "—"}
No events have been recorded.
+
+
+ ); +} diff --git a/apps/web/src/app/admin/(console)/layout.tsx b/apps/web/src/app/admin/(console)/layout.tsx new file mode 100644 index 0000000..4ebf4cb --- /dev/null +++ b/apps/web/src/app/admin/(console)/layout.tsx @@ -0,0 +1,44 @@ +import type { ReactNode } from "react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { getClientIp } from "@minecraft-account-manager/network"; +import { recordEvent } from "@minecraft-account-manager/database"; +import { headers } from "next/headers"; +import { getServerSession } from "next-auth"; +import { AdminSignOutButton } from "@/components/admin-sign-out-button"; +import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth"; +import { db } from "@/lib/database"; + +export default async function AdminConsoleLayout({ children }: { children: ReactNode }) { + const session = await getServerSession(adminAuthOptions); + if (!session) redirect("/admin/login"); + + const roles = (session.user as typeof session.user & { roles?: string[] })?.roles ?? []; + if (!roles.includes(requiredAdminRole)) redirect("/admin/login?error=forbidden"); + + const requestHeaders = await headers(); + const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true"); + await recordEvent(db, { + type: "games.minecraft.account-manager.ui.accessed", + source: "/web/admin", + subject: "admin-console", + ipAddress: ipAddress ?? undefined, + data: { adminEmail: session.user?.email ?? null }, + }); + + return ( +
+
+
+ Blocklist / Ops + + +
+
+ {children} +
+ ); +} diff --git a/apps/web/src/app/admin/(console)/page.tsx b/apps/web/src/app/admin/(console)/page.tsx new file mode 100644 index 0000000..a1dcdfe --- /dev/null +++ b/apps/web/src/app/admin/(console)/page.tsx @@ -0,0 +1,48 @@ +import { eq } from "drizzle-orm"; +import { appSettings } from "@minecraft-account-manager/database"; +import { db } from "@/lib/database"; +import { saveDiscordSettings } from "./actions"; + +export default async function AdminPage({ + searchParams, +}: { + searchParams: Promise<{ saved?: string; error?: string }>; +}) { + const query = await searchParams; + const [settings] = await db.select().from(appSettings).where(eq(appSettings.id, "default")).limit(1); + const message = settings?.registrationMessage ?? "Please register your Minecraft account before joining."; + + return ( +
+
+
+

System settings

+

Server gate

+

The Discord guild and invite are deployment environment settings. Operators can adjust the message shown to denied Minecraft players here.

+
+
Guild:
{process.env.DISCORD_GUILD_ID ? "configured" : "missing"}
+
Invite:
{process.env.DISCORD_INVITE_URL ? "configured" : "missing"}
+
+
+ +
+ {query.saved &&

Settings saved

} + {query.error &&

Check the highlighted configuration values and try again.

} + + +