feat(admin): show recent address locations
This commit is contained in:
@@ -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,16 +221,20 @@ 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) => {
|
||||||
<div className="py-3" key={group.network}>
|
const details = accessAddressDetails(group);
|
||||||
<div className="flex items-center justify-between gap-3">
|
return (
|
||||||
<p className="font-mono text-xs font-bold">{group.network}</p>
|
<div className="py-3" key={group.network}>
|
||||||
<span className="font-mono text-[9px] text-muted">×{group.count}</span>
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="font-mono text-xs font-bold">{group.network}</p>
|
||||||
|
<span className="font-mono text-[9px] text-muted">×{group.count}</span>
|
||||||
|
</div>
|
||||||
|
<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 text-xs text-muted">{details.location} · <span className="font-mono uppercase">{details.classification}</span></p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
})}
|
||||||
</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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user