feat(admin): show recent address locations
This commit is contained in:
@@ -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({
|
||||
<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>
|
||||
<div className="mt-4 divide-y divide-line">
|
||||
{addressGroups.map((group) => (
|
||||
<div className="py-3" key={group.network}>
|
||||
<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>
|
||||
{addressGroups.map((group) => {
|
||||
const details = accessAddressDetails(group);
|
||||
return (
|
||||
<div className="py-3" key={group.network}>
|
||||
<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>
|
||||
<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>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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 = {
|
||||
network: string;
|
||||
latestAddress: string;
|
||||
|
||||
Reference in New Issue
Block a user