From e43db344026881778b0aa01e2640f3b618666a8d Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Fri, 7 Aug 2026 19:09:33 -0400 Subject: [PATCH] feat(admin): show recent address locations --- .../admin/(console)/users/[userId]/page.tsx | 40 ++++++++++++------- .../web/src/lib/access-address-groups.test.ts | 17 +++++++- apps/web/src/lib/access-address-groups.ts | 9 +++++ design/log.md | 1 + design/us-013-admin-user-management.md | 7 +++- 5 files changed, 56 insertions(+), 18 deletions(-) diff --git a/apps/web/src/app/admin/(console)/users/[userId]/page.tsx b/apps/web/src/app/admin/(console)/users/[userId]/page.tsx index 963cccd..0d25459 100644 --- a/apps/web/src/app/admin/(console)/users/[userId]/page.tsx +++ b/apps/web/src/app/admin/(console)/users/[userId]/page.tsx @@ -1,10 +1,10 @@ import { resolveEffectiveGroup } from "@minecraft-account-manager/auth"; import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft"; -import { events, groups, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database"; +import { events, groups, ipIntelligence, ipObservations, minecraftAccounts, userGroupMemberships, users } from "@minecraft-account-manager/database"; import { and, desc, eq, inArray, isNull, or } from "drizzle-orm"; import Link from "next/link"; import { notFound } from "next/navigation"; -import { groupAccessAddresses } from "@/lib/access-address-groups"; +import { accessAddressDetails, groupAccessAddresses } from "@/lib/access-address-groups"; import { db } from "@/lib/database"; import { discordIdentity } from "@/lib/discord-identity"; import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters"; @@ -80,8 +80,16 @@ export default async function AdminUserPage({ .orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username), recentEventsQuery, db - .select() + .select({ + id: ipObservations.id, + ipAddress: ipObservations.ipAddress, + source: ipObservations.source, + classification: ipObservations.classification, + observedAt: ipObservations.observedAt, + intelligence: ipIntelligence.rawResponse, + }) .from(ipObservations) + .leftJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress)) .where(eq(ipObservations.userId, user.id)) .orderBy(desc(ipObservations.observedAt)) .limit(100), @@ -95,9 +103,7 @@ export default async function AdminUserPage({ 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 addressGroups = groupAccessAddresses(observations); const primary = accounts.find((account) => account.isPrimary); const nickname = user.firstName ? formatManagedDiscordNickname(user.firstName, primary?.username ?? null) @@ -215,16 +221,20 @@ export default async function AdminUserPage({

Recent addresses

Grouped by IPv4 /24 or IPv6 /64 network across the 100 most recent observations.

- {addressGroups.map((group) => ( -
-
-

{group.network}

- ×{group.count} + {addressGroups.map((group) => { + const details = accessAddressDetails(group); + return ( +
+
+

{group.network}

+ ×{group.count} +
+

{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}

+

Latest {group.latestAddress}

+

{details.location} · {details.classification}

-

{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}

-

Latest {group.latestAddress}

-
- ))} + ); + })} {!addressGroups.length &&

No addresses recorded.

}
diff --git a/apps/web/src/lib/access-address-groups.test.ts b/apps/web/src/lib/access-address-groups.test.ts index a3bb96d..26da516 100644 --- a/apps/web/src/lib/access-address-groups.test.ts +++ b/apps/web/src/lib/access-address-groups.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { groupAccessAddresses } from "./access-address-groups"; +import { accessAddressDetails, groupAccessAddresses } from "./access-address-groups"; describe("groupAccessAddresses", () => { it("collapses repeated observations from the same network into one recent summary", () => { @@ -20,4 +20,19 @@ describe("groupAccessAddresses", () => { }); expect(groups[0]?.latestObservedAt.toISOString()).toBe("2026-08-01T12:00:00.000Z"); }); + + it("presents the latest enriched location and classification with observation fallbacks", () => { + expect(accessAddressDetails({ + classification: "vpn", + intelligence: { + classification: "vpn", + location: { city: "Toronto", region: "Ontario", countryCode: "CA" }, + }, + })).toEqual({ location: "Toronto, Ontario, CA", classification: "vpn" }); + + expect(accessAddressDetails({ classification: "hosting", intelligence: null })).toEqual({ + location: "Location unavailable", + classification: "hosting", + }); + }); }); diff --git a/apps/web/src/lib/access-address-groups.ts b/apps/web/src/lib/access-address-groups.ts index c71374f..98d5e8c 100644 --- a/apps/web/src/lib/access-address-groups.ts +++ b/apps/web/src/lib/access-address-groups.ts @@ -1,4 +1,5 @@ import { addressGroup } from "@minecraft-account-manager/network"; +import { intelligenceSummary } from "./event-ip-summary"; type AccessObservation = { id: string; @@ -9,6 +10,14 @@ type AccessObservation = { intelligence: Record | null; }; +export function accessAddressDetails(observation: Pick) { + const summary = intelligenceSummary(observation.intelligence); + return { + location: summary.location ?? "Location unavailable", + classification: summary.classification ?? observation.classification, + }; +} + export type AccessAddressGroup = { network: string; latestAddress: string; diff --git a/design/log.md b/design/log.md index 36fc765..d919340 100644 --- a/design/log.md +++ b/design/log.md @@ -2,6 +2,7 @@ ## 2026-08-07 +* **Extend**: Show each grouped recent address's latest approximate location and network classification on administrator user records. * **Refine**: Select each admin map marker from the user's latest coordinate-bearing clear or hosting observation while keeping VPN, proxy, and Tor activity in the network-risk view. ## 2026-08-02 diff --git a/design/us-013-admin-user-management.md b/design/us-013-admin-user-management.md index 92cb39d..f5e54e1 100644 --- a/design/us-013-admin-user-management.md +++ b/design/us-013-admin-user-management.md @@ -3,7 +3,7 @@ type: User Story title: Manage users as an administrator description: Authorized operators search users and maintain their names, linked accounts, primaries, and Discord nicknames. tags: [admin, users, minecraft, discord] -timestamp: 2026-08-02T15:03:59Z +timestamp: 2026-08-07T23:02:04Z story_id: US-013 status: verified --- @@ -17,6 +17,9 @@ As an administrator, I want to manage a user's identity and Minecraft accounts, - [x] Administrators can search by preferred name, Discord username or ID, Minecraft username, or UUID. - [x] Search results show onboarding state, primary username, and active account count. - [x] A user detail view shows Discord display name, username, guild nickname, immutable ID, active accounts, groups, recent events, and recent IP observations. +- [x] Each grouped recent address shows the latest observation's approximate location and classification, including clear, VPN, proxy, Tor, hosting, and unknown classifications. +- [x] Missing IP enrichment is labelled as location unavailable and falls back to the stored observation classification. +- [x] Address groups use the enrichment associated with their latest observation. - [x] Administrators can update the preferred name and synchronize Discord. - [x] Administrators can add Mojang-verified accounts or explicitly override an unverified username. - [x] Administrators can remove an account only after a visible confirmation step. @@ -43,7 +46,7 @@ As an administrator, I want to manage a user's identity and Minecraft accounts, # Validation -Nickname fallback behavior is tested in [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts). Privileged routes pass TypeScript, lint, Semgrep, and production build checks. +Nickname fallback behavior is tested in [`packages/minecraft/test/minecraft.test.ts`](../packages/minecraft/test/minecraft.test.ts). Latest-observation enrichment and classification fallback are covered by [`apps/web/src/lib/access-address-groups.test.ts`](../apps/web/src/lib/access-address-groups.test.ts). The full 107-test suite, TypeScript, lint, OKF validation, and the production build pass. # Related Stories