From b7c0083647d2f74c5fe35f34cdf9ed19f94e7645 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 1 Aug 2026 19:21:23 -0400 Subject: [PATCH] feat(portal): add SSR operations and exclusive groups --- README.md | 5 +- apps/web/src/app/account/actions.ts | 246 ++-- apps/web/src/app/account/page.tsx | 100 +- apps/web/src/app/admin/(console)/actions.ts | 4 +- .../admin/(console)/events/[eventId]/page.tsx | 56 + .../src/app/admin/(console)/events/page.tsx | 78 +- .../admin/(console)/groups/[groupId]/page.tsx | 61 +- .../src/app/admin/(console)/groups/actions.ts | 95 +- .../src/app/admin/(console)/groups/page.tsx | 14 +- apps/web/src/app/admin/(console)/layout.tsx | 9 +- apps/web/src/app/admin/(console)/page.tsx | 172 ++- .../src/app/admin/(console)/settings/page.tsx | 51 + .../admin/(console)/users/[userId]/page.tsx | 68 +- .../src/app/admin/(console)/users/page.tsx | 10 +- apps/web/src/app/api/velocity/access/route.ts | 22 +- apps/web/src/app/globals.css | 23 +- apps/web/src/app/layout.tsx | 3 +- .../src/components/nickname-notice.test.tsx | 20 + apps/web/src/components/nickname-notice.tsx | 20 + apps/web/src/lib/admin-metrics.test.ts | 16 + apps/web/src/lib/admin-metrics.ts | 15 + .../lib/dashboard-change-confirmation.test.ts | 16 - .../src/lib/dashboard-change-confirmation.ts | 3 - apps/web/src/lib/event-filters.test.ts | 17 + apps/web/src/lib/event-filters.ts | 20 + design/index.md | 3 +- design/log.md | 1 + design/us-005-user-dashboard.md | 8 +- design/us-006-discord-nickname.md | 10 +- design/us-009-velocity-admission.md | 4 +- design/us-010-audit-events.md | 6 +- design/us-015-platform-operations.md | 5 +- design/us-017-group-access.md | 18 +- design/us-018-admin-dashboard.md | 42 + docs/accessibility.md | 34 + docs/architecture.md | 2 +- docs/security-review.md | 5 +- packages/auth/src/index.ts | 4 +- packages/auth/test/group-access.test.ts | 22 +- .../drizzle/0003_smiling_silver_samurai.sql | 11 + .../database/drizzle/meta/0003_snapshot.json | 1271 +++++++++++++++++ packages/database/drizzle/meta/_journal.json | 7 + packages/database/src/schema.ts | 2 +- packages/minecraft/src/index.ts | 2 +- packages/minecraft/test/minecraft.test.ts | 7 +- 45 files changed, 2245 insertions(+), 363 deletions(-) create mode 100644 apps/web/src/app/admin/(console)/events/[eventId]/page.tsx create mode 100644 apps/web/src/app/admin/(console)/settings/page.tsx create mode 100644 apps/web/src/components/nickname-notice.test.tsx create mode 100644 apps/web/src/components/nickname-notice.tsx create mode 100644 apps/web/src/lib/admin-metrics.test.ts create mode 100644 apps/web/src/lib/admin-metrics.ts delete mode 100644 apps/web/src/lib/dashboard-change-confirmation.test.ts delete mode 100644 apps/web/src/lib/dashboard-change-confirmation.ts create mode 100644 apps/web/src/lib/event-filters.test.ts create mode 100644 apps/web/src/lib/event-filters.ts create mode 100644 design/us-018-admin-dashboard.md create mode 100644 docs/accessibility.md create mode 100644 packages/database/drizzle/0003_smiling_silver_samurai.sql create mode 100644 packages/database/drizzle/meta/0003_snapshot.json diff --git a/README.md b/README.md index e748519..6521bc9 100644 --- a/README.md +++ b/README.md @@ -76,11 +76,12 @@ The token is displayed once and stored only as a SHA-256 hash. - PostgreSQL and Drizzle ORM - Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role -- Admin user search, account management, primary-account changes, and Discord nickname synchronization +- Admin user search, account management, event exploration, operational metrics, 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 - discord.js bot with `/register` and `/account` - Java Edition online-mode accounts only - Velocity admission checks are fail closed - ProxyCheck.io geolocation and VPN/proxy/Tor detection with a 48-hour PostgreSQL cache -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. diff --git a/apps/web/src/app/account/actions.ts b/apps/web/src/app/account/actions.ts index 97492d8..8b9d147 100644 --- a/apps/web/src/app/account/actions.ts +++ b/apps/web/src/app/account/actions.ts @@ -1,61 +1,75 @@ "use server"; -import { formatDiscordNickname, lookupJavaProfile, updateGuildNickname } from "@minecraft-account-manager/minecraft"; import { minecraftAccounts, users } from "@minecraft-account-manager/database"; +import { formatManagedDiscordNickname, lookupJavaProfile, updateGuildNickname } from "@minecraft-account-manager/minecraft"; import { and, eq, isNull, ne } from "drizzle-orm"; import { redirect } from "next/navigation"; import { recordUserEvent } from "@/lib/audit"; -import { db } from "@/lib/database"; -import { hasDiscordNicknameConfirmation } from "@/lib/dashboard-change-confirmation"; import { requireCurrentUser } from "@/lib/auth/user-session"; +import { db } from "@/lib/database"; import { checkAccountAdditionNetwork, toAuditIpData } from "@/lib/ip-intelligence"; import { logger } from "@/lib/logger"; const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,16}$/; +type CurrentUser = Awaited>; + +type NicknameSyncResult = "updated" | "not-configured" | "failed"; + +async function synchronizeNickname(user: CurrentUser, nickname: string, operation: string): Promise { + const guildId = process.env.DISCORD_GUILD_ID?.trim(); + const botToken = process.env.DISCORD_BOT_TOKEN?.trim(); + if (!guildId || !botToken) return "not-configured"; + + try { + await updateGuildNickname({ guildId, discordUserId: user.discordUserId, nickname, botToken }); + } catch (error) { + logger.error( + { err: error, event: "account.discord_nickname_update_failed", operation }, + "Failed to update the Discord guild nickname", + ); + return "failed"; + } + + try { + await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", { + nickname, + operation, + }); + } catch (error) { + logger.error( + { err: error, event: "account.discord_nickname_audit_failed", operation }, + "Discord nickname updated but its audit event could not be recorded", + ); + } + return "updated"; +} + +function nicknameResultUrl(nickname: string, synchronization: NicknameSyncResult, additionalQuery?: string) { + const result = synchronization === "updated" + ? `nicknameUpdated=${encodeURIComponent(nickname)}` + : `error=${synchronization === "not-configured" ? "nickname-not-configured" : "nickname-update-failed"}&nicknameExpected=${encodeURIComponent(nickname)}`; + return `/account?${additionalQuery ? `${additionalQuery}&` : ""}${result}`; +} + export async function updateFirstName(formData: FormData) { const user = await requireCurrentUser(); const firstName = String(formData.get("firstName") ?? "").trim(); - const confirmed = hasDiscordNicknameConfirmation(formData); if (firstName.length < 1 || firstName.length > 50 || /[\u0000-\u001f\u007f]/.test(firstName)) { redirect("/account?error=invalid-name"); } - const [primary] = await db.select({ username: minecraftAccounts.username }).from(minecraftAccounts).where( - and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)), - ).limit(1); - if (!primary) redirect("/account?error=nickname-not-configured"); - if (!confirmed) redirect(`/account?pendingName=${encodeURIComponent(firstName)}`); - - const guildId = process.env.DISCORD_GUILD_ID?.trim(); - const botToken = process.env.DISCORD_BOT_TOKEN?.trim(); - if (!guildId || !botToken) redirect("/account?error=nickname-not-configured"); - const nickname = formatDiscordNickname(firstName, primary.username); - - try { - await db.transaction(async (tx) => { - await tx.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id)); - await updateGuildNickname({ - guildId, - discordUserId: user.discordUserId, - nickname, - botToken, - }); - }); - } catch (error) { - logger.error( - { err: error, event: "account.first_name_update_failed" }, - "Failed to update the user name and Discord nickname", - ); - redirect(`/account?error=nickname-update-failed&pendingName=${encodeURIComponent(firstName)}`); - } + const [primary] = await db + .select({ username: minecraftAccounts.username }) + .from(minecraftAccounts) + .where(and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt))) + .limit(1); + const nickname = formatManagedDiscordNickname(firstName, primary?.username ?? null); + await db.update(users).set({ firstName, updatedAt: new Date() }).where(eq(users.id, user.id)); + const synchronization = await synchronizeNickname(user, nickname, "update-first-name"); await recordUserEvent(user, "games.minecraft.account-manager.user.first-name.updated", { firstName }); - await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", { - nickname, - operation: "update-first-name", - }); - redirect("/account?nicknameUpdated=1"); + redirect(nicknameResultUrl(nickname, synchronization)); } export async function addMinecraftAccount(formData: FormData) { @@ -82,138 +96,124 @@ export async function addMinecraftAccount(formData: FormData) { const profile = await lookupJavaProfile(requestedUsername); if (!profile && !confirmed) redirect(`/account?unverified=${encodeURIComponent(requestedUsername)}`); - const [existing] = await db.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where( - and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), - ).limit(1); + const [existing] = await db + .select({ id: minecraftAccounts.id }) + .from(minecraftAccounts) + .where(and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt))) + .limit(1); - let failed = false; + const username = profile?.username ?? requestedUsername; try { await db.insert(minecraftAccounts).values({ userId: user.id, minecraftUuid: profile?.uuid ?? null, - username: profile?.username ?? requestedUsername, + username, validationStatus: profile ? "verified" : "user_confirmed", lastVerifiedAt: profile ? new Date() : null, isPrimary: !existing, }); } catch { - failed = true; + redirect("/account?error=already-registered"); } - if (failed) redirect("/account?error=already-registered"); + const nickname = !existing && user.firstName + ? formatManagedDiscordNickname(user.firstName, username) + : null; + const synchronization = nickname + ? await synchronizeNickname(user, nickname, "add-first-account") + : null; await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.added", { - username: profile?.username ?? requestedUsername, + username, minecraftUuid: profile?.uuid ?? null, validationStatus: profile ? "verified" : "user_confirmed", }); - redirect(existing ? "/account?added=1" : "/account?confirmNickname=1"); + + redirect(nickname && synchronization + ? nicknameResultUrl(nickname, synchronization, "added=1") + : "/account?added=1"); } export async function setPrimaryAccount(formData: FormData) { const user = await requireCurrentUser(); const accountId = String(formData.get("accountId") ?? ""); - const confirmed = hasDiscordNicknameConfirmation(formData); - 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); + 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"); - if (!confirmed) redirect(`/account?pendingPrimary=${encodeURIComponent(requestedAccount.id)}`); - const guildId = process.env.DISCORD_GUILD_ID?.trim(); - const botToken = process.env.DISCORD_BOT_TOKEN?.trim(); - if (!guildId || !botToken) redirect("/account?error=nickname-not-configured"); - const nickname = formatDiscordNickname(user.firstName, requestedAccount.username); + const changed = await db.transaction(async (tx) => { + const [account] = await tx + .select({ id: minecraftAccounts.id }) + .from(minecraftAccounts) + .where(and(eq(minecraftAccounts.id, requestedAccount.id), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt))) + .limit(1); + if (!account) return false; - let changed = false; - try { - changed = await db.transaction(async (tx) => { - const [account] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where( - and(eq(minecraftAccounts.id, requestedAccount.id), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), - ).limit(1); - if (!account) return false; - - await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where( - 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)); - await updateGuildNickname({ - guildId, - discordUserId: user.discordUserId, - nickname, - botToken, - }); - return true; - }); - } catch (error) { - logger.error( - { err: error, event: "account.primary_update_failed" }, - "Failed to update the primary account and Discord nickname", + await tx.update(minecraftAccounts).set({ isPrimary: false, updatedAt: new Date() }).where( + and(eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), ); - redirect(`/account?error=nickname-update-failed&pendingPrimary=${encodeURIComponent(requestedAccount.id)}`); - } - + 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"); + + 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, }); - await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", { - nickname, - operation: "set-primary-account", - }); - redirect("/account?nicknameUpdated=1"); + redirect(nicknameResultUrl(nickname, synchronization)); } export async function removeMinecraftAccount(formData: FormData) { const user = await requireCurrentUser(); const accountId = String(formData.get("accountId") ?? ""); - const removed = await db.transaction(async (tx) => { - const [account] = await tx.select({ id: minecraftAccounts.id, isPrimary: minecraftAccounts.isPrimary }).from(minecraftAccounts).where( - and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt)), - ).limit(1); - if (!account) return false; + const result = await db.transaction(async (tx) => { + const [account] = await tx + .select({ id: minecraftAccounts.id, isPrimary: minecraftAccounts.isPrimary }) + .from(minecraftAccounts) + .where(and(eq(minecraftAccounts.id, accountId), eq(minecraftAccounts.userId, user.id), isNull(minecraftAccounts.deletedAt))) + .limit(1); + if (!account) return null; await tx.update(minecraftAccounts).set({ deletedAt: new Date(), isPrimary: false, updatedAt: new Date() }).where(eq(minecraftAccounts.id, account.id)); + let replacementUsername: string | null = null; if (account.isPrimary) { - const [replacement] = await tx.select({ id: minecraftAccounts.id }).from(minecraftAccounts).where( - and(eq(minecraftAccounts.userId, user.id), ne(minecraftAccounts.id, account.id), isNull(minecraftAccounts.deletedAt)), - ).limit(1); + const [replacement] = await tx + .select({ id: minecraftAccounts.id, username: minecraftAccounts.username }) + .from(minecraftAccounts) + .where(and(eq(minecraftAccounts.userId, user.id), ne(minecraftAccounts.id, account.id), isNull(minecraftAccounts.deletedAt))) + .limit(1); if (replacement) { + replacementUsername = replacement.username; await tx.update(minecraftAccounts).set({ isPrimary: true, updatedAt: new Date() }).where(eq(minecraftAccounts.id, replacement.id)); } + } else { + const [primary] = await tx + .select({ username: minecraftAccounts.username }) + .from(minecraftAccounts) + .where(and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt))) + .limit(1); + replacementUsername = primary?.username ?? null; } - return true; + return { replacementUsername }; }); - if (!removed) redirect("/account?error=unknown-account"); + if (!result) redirect("/account?error=unknown-account"); + const nickname = user.firstName + ? formatManagedDiscordNickname(user.firstName, result.replacementUsername) + : null; + const synchronization = nickname + ? await synchronizeNickname(user, nickname, "remove-account") + : null; await recordUserEvent(user, "games.minecraft.account-manager.minecraft-account.removed", { accountId }); - redirect("/account?removed=1&confirmNickname=1"); -} -export async function confirmDashboardNickname() { - const user = await requireCurrentUser(); - const [account] = await db.select({ username: minecraftAccounts.username }).from(minecraftAccounts).where( - and(eq(minecraftAccounts.userId, user.id), eq(minecraftAccounts.isPrimary, true), isNull(minecraftAccounts.deletedAt)), - ).limit(1); - const guildId = process.env.DISCORD_GUILD_ID?.trim(); - const botToken = process.env.DISCORD_BOT_TOKEN?.trim(); - - if (!user.firstName || !account || !guildId || !botToken) redirect("/account?error=nickname-not-configured"); - - try { - await updateGuildNickname({ - guildId, - discordUserId: user.discordUserId, - nickname: formatDiscordNickname(user.firstName, account.username), - botToken, - }); - } catch { - redirect("/account?error=nickname-update-failed&confirmNickname=1"); - } - await recordUserEvent(user, "games.minecraft.account-manager.discord.nickname.updated", { - nickname: formatDiscordNickname(user.firstName, account.username), - }); - redirect("/account?nicknameUpdated=1"); + redirect(nickname && synchronization + ? nicknameResultUrl(nickname, synchronization, "removed=1") + : "/account?removed=1"); } diff --git a/apps/web/src/app/account/page.tsx b/apps/web/src/app/account/page.tsx index a7a164e..1b2856e 100644 --- a/apps/web/src/app/account/page.tsx +++ b/apps/web/src/app/account/page.tsx @@ -1,4 +1,5 @@ -import { formatDiscordNickname } from "@minecraft-account-manager/minecraft"; +import { resolveEffectiveGroup } from "@minecraft-account-manager/auth"; +import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft"; import { groups, ipIntelligence, ipObservations, minecraftAccounts, userGroupMemberships } from "@minecraft-account-manager/database"; import { and, desc, eq, isNull, or } from "drizzle-orm"; import { logout } from "@/app/auth/actions"; @@ -7,9 +8,9 @@ import { requireCurrentUser } from "@/lib/auth/user-session"; import { groupAccessAddresses } from "@/lib/access-address-groups"; import { discordIdentity } from "@/lib/discord-identity"; import { intelligenceSummary } from "@/lib/event-ip-summary"; +import { NicknameNotice } from "@/components/nickname-notice"; import { addMinecraftAccount, - confirmDashboardNickname, removeMinecraftAccount, setPrimaryAccount, updateFirstName, @@ -30,6 +31,8 @@ const errorMessages: Record = { "ip-check-unavailable": "We could not verify your network, so account addition is temporarily blocked.", }; +export const dynamic = "force-dynamic"; + export default async function AccountPage({ searchParams, }: { @@ -39,7 +42,7 @@ export default async function AccountPage({ const query = await searchParams; const error = queryValue(query.error); const unverified = queryValue(query.unverified); - const [accounts, observations, discord, accessGroups] = await Promise.all([ + const [accounts, observations, discord, availableGroups] = await Promise.all([ db .select() .from(minecraftAccounts) @@ -68,27 +71,15 @@ export default async function AccountPage({ ]); const addressGroups = groupAccessAddresses(observations); const primary = accounts.find((account) => account.isPrimary); - const desiredNickname = user.firstName && primary - ? formatDiscordNickname(user.firstName, primary.username) + const desiredNickname = user.firstName + ? formatManagedDiscordNickname(user.firstName, primary?.username ?? null) : null; - const pendingName = queryValue(query.pendingName)?.trim(); - const pendingPrimaryId = queryValue(query.pendingPrimary); - const pendingPrimary = accounts.find((account) => account.id === pendingPrimaryId); - const pendingChange = pendingName && pendingName.length <= 50 && primary - ? { - kind: "name" as const, - label: `Change your name to ${pendingName}`, - nickname: formatDiscordNickname(pendingName, primary.username), - firstName: pendingName, - } - : pendingPrimary && user.firstName - ? { - kind: "primary" as const, - label: `Make ${pendingPrimary.username} your primary account`, - nickname: formatDiscordNickname(user.firstName, pendingPrimary.username), - accountId: pendingPrimary.id, - } - : 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 (
@@ -101,46 +92,17 @@ export default async function AccountPage({
- {error && ( -

+ {error && !nicknameError && ( +

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

)} - {queryValue(query.nicknameUpdated) &&

Profile and Discord nickname updated

} - - {pendingChange && ( -
-
-

Review linked identity change

-

{pendingChange.label}

-

Nothing changes until you confirm. This will also update your Discord nickname to:

-

{pendingChange.nickname}

-
-
-
- {pendingChange.kind === "name" - ? - : } - - -
- Cancel -
-
- )} - - {queryValue(query.confirmNickname) && desiredNickname && ( -
-
-

Confirm Discord change

-

Your community nickname will become

-

{desiredNickname}

-
-
- -
-
- )} +
@@ -162,8 +124,8 @@ export default async function AccountPage({

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

- {!account.isPrimary &&
} -
+ {!account.isPrimary &&
} +
Remove

Remove {account.username}? Your Discord nickname will update automatically.

))} @@ -180,7 +142,7 @@ export default async function AccountPage({ ) : (
- +
)} @@ -224,16 +186,14 @@ export default async function AccountPage({ - {desiredNickname &&

Current Discord nickname: {desiredNickname}

} -

You will review the new Discord nickname before anything changes.

- + {desiredNickname &&

Managed Discord nickname: {desiredNickname}

} +

Saving automatically synchronizes this Discord guild nickname.

+

Access groups

-
- {accessGroups.map((group) =>
{group.name}{group.isDefault ? " · default" : ""}{group.accessEnabled ? "Access on" : "Access off"}
)} -
-

Minecraft access is allowed when any listed group has access on.

+ {effectiveGroup ?
{effectiveGroup.name}{effectiveGroup.isDefault ? " · default" : ""}{effectiveGroup.accessEnabled ? "Access on" : "Access off"}
:

No default access group is configured.

} +

Your effective group alone determines Minecraft access.

diff --git a/apps/web/src/app/admin/(console)/actions.ts b/apps/web/src/app/admin/(console)/actions.ts index 9f898a3..fa07a07 100644 --- a/apps/web/src/app/admin/(console)/actions.ts +++ b/apps/web/src/app/admin/(console)/actions.ts @@ -11,7 +11,7 @@ export async function saveDiscordSettings(formData: FormData) { const registrationMessage = String(formData.get("registrationMessage") ?? "").trim(); if (registrationMessage.length < 10 || registrationMessage.length > 500) { - redirect("/admin?error=invalid-message"); + redirect("/admin/settings?error=invalid-message"); } await db @@ -28,5 +28,5 @@ export async function saveDiscordSettings(formData: FormData) { }, }); - redirect("/admin?saved=1"); + redirect("/admin/settings?saved=1"); } diff --git a/apps/web/src/app/admin/(console)/events/[eventId]/page.tsx b/apps/web/src/app/admin/(console)/events/[eventId]/page.tsx new file mode 100644 index 0000000..1750e92 --- /dev/null +++ b/apps/web/src/app/admin/(console)/events/[eventId]/page.tsx @@ -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 ( +
+ ← Event explorer +
+

CloudEvent detail

+

{event.type}

+

{event.id}

+
+ +
+

Envelope

+
+ + {event.specVersion} + {event.source} + {event.subject ?? "Not provided"} + {event.dataContentType} + {event.dataSchema ?? "Not provided"} + {event.actorUserId ? {event.actorUserId} : "Not provided"} + {event.correlationId ?? "Not provided"} + {event.ipAddress ?? "Not provided"} + {network.classification || network.location ? `${network.classification ?? "unknown"} · ${network.location ?? "location unavailable"}` : "Not provided"} + {event.publishedAt ? event.publishedAt.toISOString() : "Pending publication"} + {event.createdAt.toISOString()} +
+
+ +
+

Event data

+
{JSON.stringify(event.data, null, 2)}
+
+
+ ); +} + +function Detail({ label, children }: { label: string; children: React.ReactNode }) { + return
{label}
{children}
; +} diff --git a/apps/web/src/app/admin/(console)/events/page.tsx b/apps/web/src/app/admin/(console)/events/page.tsx index a04f415..7bcac96 100644 --- a/apps/web/src/app/admin/(console)/events/page.tsx +++ b/apps/web/src/app/admin/(console)/events/page.tsx @@ -1,34 +1,92 @@ import { events } from "@minecraft-account-manager/database"; -import { desc } from "drizzle-orm"; +import { desc, inArray } from "drizzle-orm"; +import Link from "next/link"; import { db } from "@/lib/database"; +import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters"; import { eventIpSummary } from "@/lib/event-ip-summary"; -export default async function EventsPage() { - const recentEvents = await db.select().from(events).orderBy(desc(events.time)).limit(100); +export const dynamic = "force-dynamic"; + +function values(value: string | string[] | undefined) { + return Array.isArray(value) ? value : value ? [value] : []; +} + +export default async function EventsPage({ + searchParams, +}: { + searchParams: Promise>; +}) { + 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 (

CloudEvents ledger

-

Recent events

-
+

Event explorer

+

Filter the immutable audit ledger, then open an event to inspect its complete CloudEvents envelope and data.

+ +
+
+ +
+ Event types +
0}> + {selectedTypes.length ? `${selectedTypes.length} selected` : "All types in this view"} +
+ {availableTypes.map((type) => ( + + ))} +
+
+
+
+
+ + Clear filters +
+
+ +

Showing {recentEvents.length} most recent matching events

+
+ - + {recentEvents.map((event) => { const ip = eventIpSummary(event.data); return ( - - - + + + ); })} - {!recentEvents.length && } + {!recentEvents.length && }
Filtered account manager events
TimeTypeSubjectIPNetwork
TimeTypeSubjectIPNetwork
{event.time.toISOString()}{event.type}
{event.type} {event.subject ?? "—"} {event.ipAddress ?? "—"}
{ip.classification ?? "—"}
{ip.location ?? "Location unavailable"}
No events have been recorded.
No events match these filters.
diff --git a/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx b/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx index 80e97fc..5e3aaff 100644 --- a/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx +++ b/apps/web/src/app/admin/(console)/groups/[groupId]/page.tsx @@ -3,15 +3,17 @@ import { asc, eq } from "drizzle-orm"; import Link from "next/link"; import { notFound } from "next/navigation"; import { db } from "@/lib/database"; -import { addGroupMember, removeGroupMember, setGroupAccess } from "../actions"; +import { addGroupMember, assignDefaultGroup, deleteGroup, removeGroupMember, setGroupAccess } from "../actions"; const savedMessages: Record = { created: "Group created with access disabled.", access: "Group access policy updated.", - "member-added": "User added to the group.", - "member-removed": "User removed from the group.", + "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, @@ -32,10 +34,16 @@ export default async function GroupPage({ discordGlobalName: users.discordGlobalName, discordUserId: users.discordUserId, }).from(users).orderBy(asc(users.discordUsername)), - db.select({ userId: userGroupMemberships.userId }).from(userGroupMemberships) - .where(eq(userGroupMemberships.groupId, group.id)), + db.select({ + userId: userGroupMemberships.userId, + groupId: userGroupMemberships.groupId, + groupName: groups.name, + }).from(userGroupMemberships).innerJoin(groups, eq(groups.id, userGroupMemberships.groupId)), ]); - const memberIds = new Set(memberships.map((membership) => membership.userId)); + 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 (
@@ -55,30 +63,38 @@ export default async function GroupPage({ - {query.saved &&

{savedMessages[query.saved] ?? "Group updated."}

} + {query.saved &&

{savedMessages[query.saved] ?? "Group updated."}

}

Membership

Registered users

- {group.isDefault ? allUsers.length : memberIds.size} members + {memberCount} members
- {group.isDefault &&

Membership in everyone is automatic and cannot be removed.

} + {group.isDefault &&

Users belong to everyone only while they have no explicit group assignment.

}
{allUsers.map((user) => { - const isMember = group.isDefault || memberIds.has(user.id); + const assignment = assignmentByUser.get(user.id); + const isMember = group.isDefault ? !assignment : assignment?.groupId === group.id; return (
{user.firstName ?? user.discordGlobalName ?? user.discordUsername}

@{user.discordUsername} · {user.discordUserId}

+ {!isMember && assignment &&

Currently assigned to {assignment.groupName}

}
- {group.isDefault ? ( - Automatic member + {isMember ? ( + group.isDefault ? Default assignment : ( +
+ + + +
+ ) ) : ( -
+ - +
)}
@@ -87,6 +103,23 @@ export default async function GroupPage({ {!allUsers.length &&

No registered users yet.

}
+ + {!group.isDefault && ( +
+

Danger zone

+

Delete {group.name}

+

Deleting this group returns its {memberCount} {memberCount === 1 ? "member" : "members"} to the protected default group. This cannot be undone.

+
+ Review deletion +
+ + + + Members will use everyone immediately. +
+
+
+ )}
); } diff --git a/apps/web/src/app/admin/(console)/groups/actions.ts b/apps/web/src/app/admin/(console)/groups/actions.ts index 5a6067a..ca0a82a 100644 --- a/apps/web/src/app/admin/(console)/groups/actions.ts +++ b/apps/web/src/app/admin/(console)/groups/actions.ts @@ -1,7 +1,10 @@ "use server"; -import { groups, userGroupMemberships, users } from "@minecraft-account-manager/database"; +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"; @@ -74,10 +77,22 @@ export async function addGroupMember(formData: FormData) { ]); if (!group || !user || group.isDefault) redirect("/admin/groups?error=invalid-membership"); - await db.insert(userGroupMemberships).values({ groupId: group.id, userId: user.id }).onConflictDoNothing(); - await recordAdminSubjectEvent(admin, `user/${user.id}`, "games.minecraft.account-manager.group.member-added", { + 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")); } @@ -96,9 +111,81 @@ export async function removeGroupMember(formData: FormData) { eq(userGroupMemberships.groupId, group.id), eq(userGroupMemberships.userId, userId), )); - await recordAdminSubjectEvent(admin, `user/${userId}`, "games.minecraft.account-manager.group.member-removed", { + 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"); +} diff --git a/apps/web/src/app/admin/(console)/groups/page.tsx b/apps/web/src/app/admin/(console)/groups/page.tsx index a859b2d..8213bbd 100644 --- a/apps/web/src/app/admin/(console)/groups/page.tsx +++ b/apps/web/src/app/admin/(console)/groups/page.tsx @@ -10,9 +10,13 @@ const errors: Record = { "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 default async function GroupsPage({ searchParams }: { searchParams: Promise<{ error?: string }> }) { +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)), @@ -23,20 +27,22 @@ export default async function GroupsPage({ searchParams }: { searchParams: Promi for (const membership of memberships) { membershipCounts.set(membership.groupId, (membershipCounts.get(membership.groupId) ?? 0) + 1); } + const explicitlyAssignedUsers = memberships.length; return (

Admission policy

Access groups

-

Every registered user belongs to everyone. A player can join only when at least one of their groups has access enabled.

+

Each user has one effective group. Users without an explicit assignment fall back to everyone; Minecraft admission follows only that group’s access setting.

- {query.error &&

{errors[query.error] ?? "The group operation failed."}

} + {query.error &&

{errors[query.error] ?? "The group operation failed."}

} + {query.saved === "deleted" &&

Group deleted. Its former members now use the default group.

}
{allGroups.map((group) => { - const memberCount = group.isDefault ? registeredUsers.length : membershipCounts.get(group.id) ?? 0; + const memberCount = group.isDefault ? registeredUsers.length - explicitlyAssignedUsers : membershipCounts.get(group.id) ?? 0; return (
diff --git a/apps/web/src/app/admin/(console)/layout.tsx b/apps/web/src/app/admin/(console)/layout.tsx index 8c5fa42..7190e73 100644 --- a/apps/web/src/app/admin/(console)/layout.tsx +++ b/apps/web/src/app/admin/(console)/layout.tsx @@ -9,6 +9,8 @@ import { AdminSignOutButton } from "@/components/admin-sign-out-button"; import { adminAuthOptions, requiredAdminRole } from "@/lib/auth/admin-auth"; import { db } from "@/lib/database"; +export const dynamic = "force-dynamic"; + export default async function AdminConsoleLayout({ children }: { children: ReactNode }) { const session = await getServerSession(adminAuthOptions); if (!session) redirect("/admin/login"); @@ -29,10 +31,11 @@ export default async function AdminConsoleLayout({ children }: { children: React return (
-
+
SoMC Portal / Ops -