feat(admin): show recent address locations
CI / validate (push) Successful in 5m59s
Release / release (push) Successful in 7m38s

This commit is contained in:
dmg
2026-08-07 19:09:33 -04:00
parent 20dfc58d63
commit e43db34402
5 changed files with 56 additions and 18 deletions
@@ -1,10 +1,10 @@
import { resolveEffectiveGroup } from "@minecraft-account-manager/auth"; import { resolveEffectiveGroup } from "@minecraft-account-manager/auth";
import { formatManagedDiscordNickname } from "@minecraft-account-manager/minecraft"; 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 { 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 { accessAddressDetails, groupAccessAddresses } from "@/lib/access-address-groups";
import { db } from "@/lib/database"; import { db } from "@/lib/database";
import { discordIdentity } from "@/lib/discord-identity"; import { discordIdentity } from "@/lib/discord-identity";
import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters"; import { eventCategory, eventCategoryValues, normalizeEventCategory, normalizeSelectedEventTypes } from "@/lib/event-filters";
@@ -80,8 +80,16 @@ export default async function AdminUserPage({
.orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username), .orderBy(desc(minecraftAccounts.isPrimary), minecraftAccounts.username),
recentEventsQuery, recentEventsQuery,
db db
.select() .select({
id: ipObservations.id,
ipAddress: ipObservations.ipAddress,
source: ipObservations.source,
classification: ipObservations.classification,
observedAt: ipObservations.observedAt,
intelligence: ipIntelligence.rawResponse,
})
.from(ipObservations) .from(ipObservations)
.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(100), .limit(100),
@@ -95,9 +103,7 @@ export default async function AdminUserPage({
const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null; const explicitGroup = availableGroups.find((group) => !group.isDefault) ?? null;
const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null; const defaultGroup = availableGroups.find((group) => group.isDefault) ?? null;
const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup); const effectiveGroup = resolveEffectiveGroup(explicitGroup, defaultGroup);
const addressGroups = groupAccessAddresses( const addressGroups = groupAccessAddresses(observations);
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)
@@ -215,7 +221,9 @@ export default async function AdminUserPage({
<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> <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">
{addressGroups.map((group) => ( {addressGroups.map((group) => {
const details = accessAddressDetails(group);
return (
<div className="py-3" key={group.network}> <div className="py-3" key={group.network}>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<p className="font-mono text-xs font-bold">{group.network}</p> <p className="font-mono text-xs font-bold">{group.network}</p>
@@ -223,8 +231,10 @@ export default async function AdminUserPage({
</div> </div>
<p className="mt-1 font-mono text-[9px] text-muted">{group.sources.join(" + ")} · {group.latestObservedAt.toISOString()}</p> <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> <p className="mt-1 break-all font-mono text-[9px] text-muted">Latest {group.latestAddress}</p>
<p className="mt-1 text-xs text-muted">{details.location} · <span className="font-mono uppercase">{details.classification}</span></p>
</div> </div>
))} );
})}
{!addressGroups.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>} {!addressGroups.length && <p className="py-3 text-xs text-muted">No addresses recorded.</p>}
</div> </div>
</section> </section>
+16 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { groupAccessAddresses } from "./access-address-groups"; import { accessAddressDetails, groupAccessAddresses } from "./access-address-groups";
describe("groupAccessAddresses", () => { describe("groupAccessAddresses", () => {
it("collapses repeated observations from the same network into one recent summary", () => { 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"); 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",
});
});
}); });
@@ -1,4 +1,5 @@
import { addressGroup } from "@minecraft-account-manager/network"; import { addressGroup } from "@minecraft-account-manager/network";
import { intelligenceSummary } from "./event-ip-summary";
type AccessObservation = { type AccessObservation = {
id: string; id: string;
@@ -9,6 +10,14 @@ type AccessObservation = {
intelligence: Record<string, unknown> | null; intelligence: Record<string, unknown> | null;
}; };
export function accessAddressDetails(observation: Pick<AccessObservation, "classification" | "intelligence">) {
const summary = intelligenceSummary(observation.intelligence);
return {
location: summary.location ?? "Location unavailable",
classification: summary.classification ?? observation.classification,
};
}
export type AccessAddressGroup = { export type AccessAddressGroup = {
network: string; network: string;
latestAddress: string; latestAddress: string;
+1
View File
@@ -2,6 +2,7 @@
## 2026-08-07 ## 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. * **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 ## 2026-08-02
+5 -2
View File
@@ -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-02T15:03:59Z timestamp: 2026-08-07T23:02:04Z
story_id: US-013 story_id: US-013
status: verified 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] 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 display name, username, guild nickname, immutable ID, active accounts, groups, 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] 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 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.
@@ -43,7 +46,7 @@ As an administrator, I want to manage a user's identity and Minecraft accounts,
# Validation # 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 # Related Stories