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