Compare commits

...
2 Commits
Author SHA1 Message Date
dmg 24808b0f8c fix(dashboard): show enriched network details
CI / validate (push) Successful in 5m39s
Release / release (push) Successful in 7m15s
2026-08-02 08:05:57 -04:00
dmg aa0b757814 fix(map): group collocated user markers
CI / validate (push) Successful in 5m47s
Release / release (push) Successful in 7m55s
2026-08-01 21:24:49 -04:00
14 changed files with 278 additions and 41 deletions
+7 -2
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map"; import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map";
import { db } from "@/lib/database"; import { db } from "@/lib/database";
import { fillDailySeries, mergeRiskActivity, type DailyCount } from "@/lib/admin-metrics"; import { fillDailySeries, mergeRiskActivity, type DailyCount } from "@/lib/admin-metrics";
import { parseUserLocation } from "@/lib/user-location-map"; import { parseUserLocation, parseUserNetwork } from "@/lib/user-location-map";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -43,7 +43,7 @@ export default async function AdminDashboardPage() {
name: users.firstName, name: users.firstName,
discordUsername: users.discordUsername, discordUsername: users.discordUsername,
primaryUsername: minecraftAccounts.username, primaryUsername: minecraftAccounts.username,
classification: ipObservations.classification, classification: ipIntelligence.classification,
source: ipObservations.source, source: ipObservations.source,
observedAt: ipObservations.observedAt, observedAt: ipObservations.observedAt,
intelligence: ipIntelligence.rawResponse, intelligence: ipIntelligence.rawResponse,
@@ -108,6 +108,7 @@ export default async function AdminDashboardPage() {
const locations = locationRows.flatMap((row): UserMapLocation[] => { const locations = locationRows.flatMap((row): UserMapLocation[] => {
const parsed = parseUserLocation(row.intelligence); const parsed = parseUserLocation(row.intelligence);
if (!parsed || !row.userId) return []; if (!parsed || !row.userId) return [];
const network = parseUserNetwork(row.intelligence);
return [{ return [{
userId: row.userId, userId: row.userId,
name: row.name ?? row.discordUsername, name: row.name ?? row.discordUsername,
@@ -117,6 +118,10 @@ export default async function AdminDashboardPage() {
longitude: parsed.longitude, longitude: parsed.longitude,
location: parsed.label, location: parsed.label,
classification: row.classification, classification: row.classification,
networkProvider: network.provider,
networkAsn: network.asn,
connectionType: network.connectionType,
proxy: network.proxy,
source: row.source, source: row.source,
observedAt: row.observedAt, observedAt: row.observedAt,
}]; }];
+13
View File
@@ -74,6 +74,19 @@ svg a:focus .map-marker {
opacity: 1; opacity: 1;
} }
.map-user-cluster {
display: grid !important;
place-items: center;
border: 3px solid var(--panel);
border-radius: 999px;
background: var(--accent);
color: var(--panel);
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 700;
box-shadow: 0 0 0 1px var(--ink);
}
::selection { ::selection {
background: var(--accent); background: var(--accent);
color: var(--panel); color: var(--panel);
+53 -9
View File
@@ -2,6 +2,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { groupMapLocations } from "@/lib/user-location-map";
import type { UserMapLocation } from "./user-world-map"; import type { UserMapLocation } from "./user-world-map";
export function MapViewToggle({ locations, children }: { locations: UserMapLocation[]; children: ReactNode }) { export function MapViewToggle({ locations, children }: { locations: UserMapLocation[]; children: ReactNode }) {
@@ -40,8 +41,20 @@ function InteractiveMap({ locations }: { locations: UserMapLocation[] }) {
}).addTo(map); }).addTo(map);
const bounds: [number, number][] = []; const bounds: [number, number][] = [];
for (const user of locations) { for (const group of groupMapLocations(locations)) {
const marker = leaflet.circleMarker([user.latitude, user.longitude], { const firstUser = group.locations[0]!;
const isGrouped = group.count > 1;
const marker = isGrouped
? leaflet.marker([group.latitude, group.longitude], {
icon: leaflet.divIcon({
className: "map-user-cluster",
html: `<span aria-hidden="true">${group.count}</span>`,
iconAnchor: [18, 18],
iconSize: [36, 36],
}),
keyboard: true,
}).addTo(map)
: leaflet.circleMarker([group.latitude, group.longitude], {
radius: 8, radius: 8,
color: "#eee8d8", color: "#eee8d8",
weight: 3, weight: 3,
@@ -49,24 +62,55 @@ function InteractiveMap({ locations }: { locations: UserMapLocation[] }) {
fillOpacity: 1, fillOpacity: 1,
}).addTo(map); }).addTo(map);
const tooltip = document.createElement("span"); const tooltip = document.createElement("span");
tooltip.textContent = `${user.nickname} · ${user.location}`; tooltip.textContent = isGrouped
? `${group.count} users · ${group.nicknames.join(" · ")}`
: `${firstUser.nickname} · ${firstUser.location}`;
marker.bindTooltip(tooltip, { direction: "top" }); marker.bindTooltip(tooltip, { direction: "top" });
const userPath = `/admin/users/${user.userId}`;
marker.on("click", () => window.location.assign(userPath)); if (isGrouped) {
const popup = document.createElement("div");
const heading = document.createElement("strong");
heading.textContent = `${group.count} users near ${firstUser.location}`;
popup.append(heading);
const list = document.createElement("ul");
for (const user of group.locations) {
const item = document.createElement("li");
const link = document.createElement("a");
link.href = `/admin/users/${user.userId}`;
link.textContent = user.nickname;
item.append(link);
list.append(item);
}
popup.append(list);
marker.bindPopup(popup);
} else {
marker.on("click", () => window.location.assign(`/admin/users/${firstUser.userId}`));
}
const element = marker.getElement(); const element = marker.getElement();
element?.setAttribute("aria-label", `${user.nickname}, ${user.location}`); const label = isGrouped
element?.setAttribute("role", "link"); ? `${group.count} users near ${firstUser.location}: ${group.nicknames.join(", ")}`
: `${firstUser.nickname}, ${firstUser.location}`;
element?.setAttribute("aria-label", label);
element?.setAttribute("role", isGrouped ? "button" : "link");
element?.setAttribute("tabindex", "0"); element?.setAttribute("tabindex", "0");
if (isGrouped) {
element?.setAttribute("aria-haspopup", "dialog");
element?.setAttribute("aria-expanded", "false");
marker.on("popupopen", () => element?.setAttribute("aria-expanded", "true"));
marker.on("popupclose", () => element?.setAttribute("aria-expanded", "false"));
}
element?.addEventListener("focus", () => marker.openTooltip()); element?.addEventListener("focus", () => marker.openTooltip());
element?.addEventListener("blur", () => marker.closeTooltip()); element?.addEventListener("blur", () => marker.closeTooltip());
element?.addEventListener("keydown", (event) => { element?.addEventListener("keydown", (event) => {
const keyboardEvent = event as KeyboardEvent; const keyboardEvent = event as KeyboardEvent;
if (keyboardEvent.key === "Enter" || keyboardEvent.key === " ") { if (keyboardEvent.key === "Enter" || keyboardEvent.key === " ") {
keyboardEvent.preventDefault(); keyboardEvent.preventDefault();
window.location.assign(userPath); if (isGrouped) marker.openPopup();
else window.location.assign(`/admin/users/${firstUser.userId}`);
} }
}); });
bounds.push([user.latitude, user.longitude]); bounds.push([group.latitude, group.longitude]);
} }
if (bounds.length) map.fitBounds(bounds, { padding: [40, 40], maxZoom: 6 }); if (bounds.length) map.fitBounds(bounds, { padding: [40, 40], maxZoom: 6 });
cleanup = () => map.remove(); cleanup = () => map.remove();
@@ -13,8 +13,27 @@ describe("UserWorldMap", () => {
longitude: -122.0775, longitude: -122.0775,
location: "Mountain View, California, US", location: "Mountain View, California, US",
classification: "clear", classification: "clear",
networkProvider: "Comcast Cable Communications, LLC",
networkAsn: "AS7922",
connectionType: "Residential",
proxy: false,
source: "game", source: "game",
observedAt: new Date("2026-08-01T12:00:00Z"), observedAt: new Date("2026-08-01T12:00:00Z"),
}, {
userId: "22222222-2222-4222-8222-222222222222",
name: "Alex",
discordUsername: "alex",
nickname: "Alex (AlexMC)",
latitude: 37.4057,
longitude: -122.0774,
location: "Mountain View, California, US",
classification: "vpn",
networkProvider: "Proton AG",
networkAsn: "AS62371",
connectionType: "VPN",
proxy: true,
source: "web",
observedAt: new Date("2026-08-01T13:00:00Z"),
}]} unavailableCount={2} />); }]} unavailableCount={2} />);
expect(markup).toContain('role="group"'); expect(markup).toContain('role="group"');
@@ -22,7 +41,18 @@ describe("UserWorldMap", () => {
expect(markup).toContain("Latest approximate location for registered users"); expect(markup).toContain("Latest approximate location for registered users");
expect(markup).toContain('href="/admin/users/11111111-1111-4111-8111-111111111111"'); expect(markup).toContain('href="/admin/users/11111111-1111-4111-8111-111111111111"');
expect(markup).toContain("Dani (Steve)"); expect(markup).toContain("Dani (Steve)");
expect(markup).toContain("Alex (AlexMC)");
expect(markup).toContain("2 users near Mountain View, California, US");
expect(markup).toMatch(/<text[^>]*>2<\/text>/);
expect(markup).toContain('<details class="mt-5 border-t border-line pt-4" id="map-location-list" open="">');
expect(markup).toContain("Mountain View, California, US"); expect(markup).toContain("Mountain View, California, US");
expect(markup).toContain("Comcast Cable Communications, LLC");
expect(markup).toContain("AS7922");
expect(markup).toContain("Residential");
expect(markup).toContain("Proton AG");
expect(markup).toContain(">Proxy/VPN<");
expect(markup).toContain(">Yes<");
expect(markup).toContain(">No<");
expect(markup).toContain("World overview"); expect(markup).toContain("World overview");
expect(markup).toContain("Interactive OpenStreetMap"); expect(markup).toContain("Interactive OpenStreetMap");
expect(markup).toContain("OpenStreetMap, which receives your IP address"); expect(markup).toContain("OpenStreetMap, which receives your IP address");
+40 -18
View File
@@ -4,6 +4,7 @@ import { geoEquirectangular, geoPath } from "d3-geo";
import { feature } from "topojson-client"; import { feature } from "topojson-client";
import countriesTopologyJson from "world-atlas/countries-110m.json"; import countriesTopologyJson from "world-atlas/countries-110m.json";
import Link from "next/link"; import Link from "next/link";
import { groupMapLocations } from "@/lib/user-location-map";
import { MapViewToggle } from "./map-view-toggle"; import { MapViewToggle } from "./map-view-toggle";
const WIDTH = 1_000; const WIDTH = 1_000;
@@ -22,11 +23,16 @@ export interface UserMapLocation {
longitude: number; longitude: number;
location: string; location: string;
classification: string; classification: string;
networkProvider: string | null;
networkAsn: string | null;
connectionType: string | null;
proxy: boolean | null;
source: string; source: string;
observedAt: Date; observedAt: Date;
} }
export function UserWorldMap({ locations, unavailableCount }: { locations: UserMapLocation[]; unavailableCount: number }) { export function UserWorldMap({ locations, unavailableCount }: { locations: UserMapLocation[]; unavailableCount: number }) {
const locationGroups = groupMapLocations(locations);
return ( return (
<section className="mt-8 border border-line bg-panel p-5 shadow-[8px_8px_0_var(--color-shadow)] sm:p-7"> <section className="mt-8 border border-line bg-panel p-5 shadow-[8px_8px_0_var(--color-shadow)] sm:p-7">
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
@@ -50,23 +56,39 @@ export function UserWorldMap({ locations, unavailableCount }: { locations: UserM
})} })}
</g> </g>
<g> <g>
{locations.map((user) => { {locationGroups.map((group) => {
const projected = projection([user.longitude, user.latitude]); const projected = projection([group.longitude, group.latitude]);
if (!projected) return null; if (!projected) return null;
const x = Math.min(WIDTH - 14, Math.max(14, projected[0])); const x = Math.min(WIDTH - 16, Math.max(16, projected[0]));
const y = Math.min(HEIGHT - 14, Math.max(14, projected[1])); const y = Math.min(HEIGHT - 16, Math.max(16, projected[1]));
const tooltipWidth = Math.min(260, Math.max(110, user.nickname.length * 8 + 24)); const markerRadius = group.count > 1 ? 13 : 7;
const longestNickname = Math.max(...group.nicknames.map((nickname) => nickname.length));
const tooltipColumns = Math.ceil(group.nicknames.length / 10);
const tooltipRows = Math.ceil(group.nicknames.length / tooltipColumns);
const tooltipWidth = Math.min(WIDTH - 8, Math.max(110, longestNickname * 8 + 24) * tooltipColumns);
const tooltipColumnWidth = tooltipWidth / tooltipColumns;
const tooltipHeight = tooltipRows * 18 + 10;
const tooltipX = Math.min(WIDTH - tooltipWidth - 4, Math.max(4, x - tooltipWidth / 2)); const tooltipX = Math.min(WIDTH - tooltipWidth - 4, Math.max(4, x - tooltipWidth / 2));
const tooltipY = y > 46 ? y - 38 : y + 18; const preferredTooltipY = y > tooltipHeight + 18 ? y - tooltipHeight - 12 : y + 18;
const tooltipY = Math.min(HEIGHT - tooltipHeight - 4, Math.max(4, preferredTooltipY));
const firstUser = group.locations[0]!;
const label = group.count === 1
? `${firstUser.nickname}, ${firstUser.location}`
: `${group.count} users near ${firstUser.location}: ${group.nicknames.join(", ")}`;
return ( return (
<a aria-label={`${user.nickname}, ${user.location}, last seen ${user.observedAt.toISOString()}`} className="map-marker-link" href={`/admin/users/${user.userId}`} key={user.userId}> <a aria-label={label} className="map-marker-link" href={group.count === 1 ? `/admin/users/${firstUser.userId}` : "#map-location-list"} key={group.key}>
<circle className="map-marker-target" cx={x} cy={y} fill="none" pointerEvents="stroke" r="7" stroke="transparent" strokeWidth="24" vectorEffect="non-scaling-stroke"> <circle className="map-marker-target" cx={x} cy={y} fill="none" pointerEvents="stroke" r={markerRadius} stroke="transparent" strokeWidth="24" vectorEffect="non-scaling-stroke">
<title>{user.nickname} · {user.location} · {user.classification}</title> <title>{label}</title>
</circle> </circle>
<circle className="map-marker" cx={x} cy={y} fill="var(--accent)" pointerEvents="none" r="7" stroke="var(--panel)" strokeWidth="3" /> <circle className="map-marker" cx={x} cy={y} fill="var(--accent)" pointerEvents="none" r={markerRadius} stroke="var(--panel)" strokeWidth="3" />
{group.count > 1 && <text aria-hidden="true" dominantBaseline="middle" fill="var(--panel)" fontFamily="var(--font-mono)" fontSize="12" fontWeight="700" pointerEvents="none" textAnchor="middle" x={x} y={y}>{group.count}</text>}
<g aria-hidden="true" className="map-marker-tooltip" pointerEvents="none"> <g aria-hidden="true" className="map-marker-tooltip" pointerEvents="none">
<rect fill="var(--ink)" height="28" rx="2" width={tooltipWidth} x={tooltipX} y={tooltipY} /> <rect fill="var(--ink)" height={tooltipHeight} rx="2" width={tooltipWidth} x={tooltipX} y={tooltipY} />
<text dominantBaseline="middle" fill="var(--panel)" fontFamily="var(--font-mono)" fontSize="12" textAnchor="middle" x={tooltipX + tooltipWidth / 2} y={tooltipY + 14}>{user.nickname}</text> {group.nicknames.map((nickname, index) => {
const column = Math.floor(index / tooltipRows);
const row = index % tooltipRows;
return <text dominantBaseline="middle" fill="var(--panel)" fontFamily="var(--font-mono)" fontSize="12" key={`${nickname}-${index}`} textAnchor="middle" x={tooltipX + tooltipColumnWidth * (column + 0.5)} y={tooltipY + 14 + row * 18}>{nickname}</text>;
})}
</g> </g>
</a> </a>
); );
@@ -77,15 +99,15 @@ export function UserWorldMap({ locations, unavailableCount }: { locations: UserM
</MapViewToggle> </MapViewToggle>
<p className="mt-2 text-right font-mono text-[9px] text-muted">Map boundaries: Natural Earth, public domain</p> <p className="mt-2 text-right font-mono text-[9px] text-muted">Map boundaries: Natural Earth, public domain</p>
<details className="mt-5 border-t border-line pt-4"> <details className="mt-5 border-t border-line pt-4" id="map-location-list" open={locationGroups.some((group) => group.count > 1)}>
<summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase underline underline-offset-4">View accessible location list</summary> <summary className="w-fit cursor-pointer font-mono text-[10px] font-bold uppercase underline underline-offset-4">View accessible location list</summary>
<div className="mt-4 overflow-x-auto"> <div className="mt-4 overflow-x-auto">
<table className="w-full min-w-[680px] border-collapse text-left text-xs"> <table className="w-full min-w-[980px] border-collapse text-left text-xs">
<caption className="sr-only">Latest approximate registered-user locations</caption> <caption className="sr-only">Latest approximate registered-user locations and enriched network details</caption>
<thead className="border-b border-line font-mono text-[9px] uppercase tracking-wider text-muted"><tr><th className="py-3 pr-4" scope="col">User</th><th className="p-3" scope="col">Location</th><th className="p-3" scope="col">Network</th><th className="p-3" scope="col">Source</th><th className="py-3 pl-4" scope="col">Last observed</th></tr></thead> <thead className="border-b border-line font-mono text-[9px] uppercase tracking-wider text-muted"><tr><th className="py-3 pr-4" scope="col">User</th><th className="p-3" scope="col">Location</th><th className="p-3" scope="col">Network</th><th className="p-3" scope="col">Connection</th><th className="p-3" scope="col">Proxy/VPN</th><th className="p-3" scope="col">Risk</th><th className="p-3" scope="col">Source</th><th className="py-3 pl-4" scope="col">Last observed</th></tr></thead>
<tbody className="divide-y divide-line"> <tbody className="divide-y divide-line">
{locations.map((user) => <tr key={user.userId}><th className="py-3 pr-4 text-left" scope="row"><Link className="font-mono font-bold underline underline-offset-4" href={`/admin/users/${user.userId}`}>{user.nickname}</Link><span className="mt-1 block font-mono text-[9px] font-normal text-muted">@{user.discordUsername}</span></th><td className="p-3">{user.location}</td><td className="p-3 font-mono uppercase">{user.classification}</td><td className="p-3">{user.source}</td><td className="py-3 pl-4 font-mono text-[9px]"><time dateTime={user.observedAt.toISOString()}>{user.observedAt.toISOString()}</time></td></tr>)} {locations.map((user) => <tr key={user.userId}><th className="py-3 pr-4 text-left" scope="row"><Link className="font-mono font-bold underline underline-offset-4" href={`/admin/users/${user.userId}`}>{user.nickname}</Link><span className="mt-1 block font-mono text-[9px] font-normal text-muted">@{user.discordUsername}</span></th><td className="p-3">{user.location}</td><td className="p-3"><span className="block">{user.networkProvider ?? "Unknown"}</span>{user.networkAsn && <span className="mt-1 block font-mono text-[9px] text-muted">{user.networkAsn}</span>}</td><td className="p-3">{user.connectionType ?? "Unknown"}</td><td className="p-3 font-mono font-bold uppercase">{user.proxy === null ? "Unknown" : user.proxy ? "Yes" : "No"}</td><td className="p-3 font-mono uppercase">{user.classification}</td><td className="p-3">{user.source}</td><td className="py-3 pl-4 font-mono text-[9px]"><time dateTime={user.observedAt.toISOString()}>{user.observedAt.toISOString()}</time></td></tr>)}
{!locations.length && <tr><td className="py-6 text-muted" colSpan={5}>No user observations currently include valid coordinates.</td></tr>} {!locations.length && <tr><td className="py-6 text-muted" colSpan={8}>No user observations currently include valid coordinates.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>
+46 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { parseUserLocation, projectWorldPoint } from "./user-location-map"; import { groupMapLocations, parseUserLocation, parseUserNetwork, projectWorldPoint } from "./user-location-map";
describe("user location map", () => { describe("user location map", () => {
it("extracts a valid approximate location from cached IP intelligence", () => { it("extracts a valid approximate location from cached IP intelligence", () => {
@@ -19,11 +19,56 @@ describe("user location map", () => {
}); });
}); });
it("extracts enriched network fields from existing ProxyCheck cache entries", () => {
expect(parseUserNetwork({
network: { asn: "AS7922", provider: "Comcast Cable Communications, LLC" },
rawResponse: {
status: "ok",
"203.0.113.10": { type: "Residential", proxy: "no" },
},
})).toEqual({
asn: "AS7922",
provider: "Comcast Cable Communications, LLC",
connectionType: "Residential",
proxy: false,
});
});
it("prefers normalized network fields and preserves unavailable values", () => {
expect(parseUserNetwork({
network: { asn: "AS62371", provider: "Proton AG", connectionType: "VPN", proxy: true },
rawResponse: { "198.51.100.5": { type: "Residential", proxy: "no" } },
})).toEqual({ asn: "AS62371", provider: "Proton AG", connectionType: "VPN", proxy: true });
expect(parseUserNetwork({ network: {} })).toEqual({ asn: null, provider: null, connectionType: null, proxy: null });
});
it("rejects missing and out-of-range coordinates", () => { it("rejects missing and out-of-range coordinates", () => {
expect(parseUserLocation({ location: { latitude: 91, longitude: 0 } })).toBeNull(); expect(parseUserLocation({ location: { latitude: 91, longitude: 0 } })).toBeNull();
expect(parseUserLocation({ location: { city: "Unknown" } })).toBeNull(); expect(parseUserLocation({ location: { city: "Unknown" } })).toBeNull();
}); });
it("groups users sharing approximate coordinates without hiding their identities", () => {
const groups = groupMapLocations([
{ userId: "one", nickname: "Dani (Steve)", latitude: 37.4056, longitude: -122.0775 },
{ userId: "two", nickname: "Alex (AlexMC)", latitude: 37.4057, longitude: -122.0774 },
{ userId: "three", nickname: "Sam (Notch)", latitude: 51.5, longitude: -0.12 },
]);
expect(groups).toHaveLength(2);
expect(groups[0]).toMatchObject({ count: 2, nicknames: ["Alex (AlexMC)", "Dani (Steve)"] });
expect(groups[0]?.locations.map((location) => location.userId)).toEqual(["one", "two"]);
expect(groups[1]).toMatchObject({ count: 1, nicknames: ["Sam (Notch)"] });
});
it("normalizes signed zero and the antimeridian before grouping", () => {
const groups = groupMapLocations([
{ nickname: "West", latitude: -0.004, longitude: 180 },
{ nickname: "East", latitude: 0.004, longitude: -180 },
]);
expect(groups).toHaveLength(1);
expect(groups[0]).toMatchObject({ count: 2, key: "0:-180", latitude: 0, longitude: -180 });
});
it("projects longitude and latitude into an equirectangular SVG", () => { it("projects longitude and latitude into an equirectangular SVG", () => {
expect(projectWorldPoint(0, 0, 800, 400)).toEqual({ x: 400, y: 200 }); expect(projectWorldPoint(0, 0, 800, 400)).toEqual({ x: 400, y: 200 });
expect(projectWorldPoint(90, 180, 800, 400)).toEqual({ x: 800, y: 0 }); expect(projectWorldPoint(90, 180, 800, 400)).toEqual({ x: 800, y: 0 });
+59
View File
@@ -6,6 +6,17 @@ function objectValue(value: unknown): UnknownMap | null {
: null; : null;
} }
function stringValue(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function proxyValue(value: unknown) {
if (typeof value === "boolean") return value;
if (typeof value === "string" && value.toLowerCase() === "yes") return true;
if (typeof value === "string" && value.toLowerCase() === "no") return false;
return null;
}
function coordinate(value: unknown) { function coordinate(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value); if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
@@ -18,6 +29,28 @@ export interface ParsedUserLocation {
label: string; label: string;
} }
export interface ParsedUserNetwork {
asn: string | null;
provider: string | null;
connectionType: string | null;
proxy: boolean | null;
}
export function parseUserNetwork(value: unknown): ParsedUserNetwork {
const intelligence = objectValue(value);
const network = objectValue(intelligence?.network);
const providerResponse = objectValue(intelligence?.rawResponse);
const legacyDetails = Object.values(providerResponse ?? {})
.map(objectValue)
.find((details) => details && ("type" in details || "proxy" in details));
return {
asn: stringValue(network?.asn),
provider: stringValue(network?.provider),
connectionType: stringValue(network?.connectionType) ?? stringValue(legacyDetails?.type),
proxy: proxyValue(network?.proxy) ?? proxyValue(legacyDetails?.proxy),
};
}
export function parseUserLocation(value: unknown): ParsedUserLocation | null { export function parseUserLocation(value: unknown): ParsedUserLocation | null {
const intelligence = objectValue(value); const intelligence = objectValue(value);
const location = objectValue(intelligence?.location); const location = objectValue(intelligence?.location);
@@ -33,6 +66,32 @@ export function parseUserLocation(value: unknown): ParsedUserLocation | null {
return { latitude, longitude, label: label || "Approximate location unavailable" }; return { latitude, longitude, label: label || "Approximate location unavailable" };
} }
export function groupMapLocations<T extends {
latitude: number;
longitude: number;
nickname: string;
}>(locations: T[]) {
const grouped = new Map<string, { latitude: number; longitude: number; locations: T[] }>();
for (const location of locations) {
const roundedLatitude = Number(location.latitude.toFixed(2));
const latitude = roundedLatitude === 0 ? 0 : roundedLatitude;
const roundedLongitude = Number(location.longitude.toFixed(2));
const longitude = Math.abs(roundedLongitude) === 180 ? -180 : roundedLongitude;
const key = `${latitude}:${longitude}`;
const group = grouped.get(key);
if (group) group.locations.push(location);
else grouped.set(key, { latitude, longitude, locations: [location] });
}
return [...grouped.entries()].map(([key, group]) => ({
key,
latitude: group.latitude,
longitude: group.longitude,
count: group.locations.length,
nicknames: [...group.locations.map((location) => location.nickname)].sort((left, right) => left.localeCompare(right)),
locations: group.locations,
}));
}
export function projectWorldPoint(latitude: number, longitude: number, width: number, height: number) { export function projectWorldPoint(latitude: number, longitude: number, width: number, height: number) {
return { return {
x: ((longitude + 180) / 360) * width, x: ((longitude + 180) / 360) * width,
+2
View File
@@ -2,6 +2,8 @@
## 2026-08-02 ## 2026-08-02
* **Fix**: Replace the dashboard's pre-enrichment network label with enriched company, ASN, connection type, Proxy/VPN status, and risk fields.
* **Fix**: Group collocated map users into count-badged markers with complete nickname tooltips and per-user interactive-map links.
* **Refine**: Replace registration counts with daily active users, collapse enriched VPN activity per user, add opt-in OpenStreetMap zoom, show managed nickname tooltips, and measure active Minecraft accounts from confirmed Velocity connections. * **Refine**: Replace registration counts with daily active users, collapse enriched VPN activity per user, add opt-in OpenStreetMap zoom, show managed nickname tooltips, and measure active Minecraft accounts from confirmed Velocity connections.
* **Governance**: Require user review and explicit confirmation of relevant OKF story changes before future implementation work. * **Governance**: Require user review and explicit confirmation of relevant OKF story changes before future implementation work.
+10 -2
View File
@@ -3,7 +3,7 @@ type: User Story
title: Monitor community account activity title: Monitor community account activity
description: Administrators use a server-rendered dashboard to review daily activity, confirmed connections, locations, denials, and risky networks. description: Administrators use a server-rendered dashboard to review daily activity, confirmed connections, locations, denials, and risky networks.
tags: [admin, dashboard, metrics, security, maps, ssr] tags: [admin, dashboard, metrics, security, maps, ssr]
timestamp: 2026-08-02T00:12:32Z timestamp: 2026-08-02T12:05:27Z
story_id: US-018 story_id: US-018
status: verified status: verified
--- ---
@@ -19,6 +19,14 @@ As an administrator, I want an operational dashboard of account and game activit
- [x] Administrators can opt into a zoomable OpenStreetMap view without removing the default overview. - [x] Administrators can opt into a zoomable OpenStreetMap view without removing the default overview.
- [x] OpenStreetMap tiles load only after the administrator selects the interactive view and retain required attribution. - [x] OpenStreetMap tiles load only after the administrator selects the interactive view and retain required attribution.
- [x] Map markers show the managed Discord nickname on hover or keyboard focus, link to user records, and have an accessible text-table equivalent. - [x] Map markers show the managed Discord nickname on hover or keyboard focus, link to user records, and have an accessible text-table equivalent.
- [x] Users sharing approximate coordinates render as one grouped marker with a visible count in both map views.
- [x] Grouped-marker hover and keyboard focus list every managed Discord nickname at that location.
- [x] Interactive grouped markers open a popup with links to every corresponding user record.
- [x] Single-user markers retain their direct nickname tooltip and user-record link.
- [x] The location list identifies the enriched network company and ASN when available.
- [x] The location list shows ProxyCheck's connection type separately from its risk classification.
- [x] The location list shows the provider's proxy/VPN signal as an explicit Yes or No value.
- [x] Unknown is shown only for individual enriched fields that are unavailable, including existing cached responses.
- [x] The dashboard graphs distinct daily active users by UTC day for the previous 14 days with understandable date labels. - [x] The dashboard graphs distinct daily active users by UTC day for the previous 14 days with understandable date labels.
- [x] Monthly active users count distinct users observed through portal or game activity in the previous 30 days. - [x] Monthly active users count distinct users observed through portal or game activity in the previous 30 days.
- [x] Monthly active Minecraft accounts count distinct accounts with a confirmed Velocity post-login connection in the previous 30 days. - [x] Monthly active Minecraft accounts count distinct accounts with a confirmed Velocity post-login connection in the previous 30 days.
@@ -40,7 +48,7 @@ As an administrator, I want an operational dashboard of account and game activit
# Validation # Validation
- Missing-day chart behavior and per-user VPN collapsing are covered by [`apps/web/src/lib/admin-metrics.test.ts`](../apps/web/src/lib/admin-metrics.test.ts). - Missing-day chart behavior and per-user VPN collapsing are covered by [`apps/web/src/lib/admin-metrics.test.ts`](../apps/web/src/lib/admin-metrics.test.ts).
- Coordinate parsing, projection, linked markers, text fallback, and attribution are covered by the user-world-map tests. - Coordinate parsing, backward-compatible ProxyCheck network parsing, normalized location grouping, projection, count badges, complete grouped tooltips, linked markers, semantic network columns, text fallback, and attribution are covered by the user-world-map tests.
- The Next.js production build reports the dashboard and database-backed console pages as dynamic server-rendered routes. - The Next.js production build reports the dashboard and database-backed console pages as dynamic server-rendered routes.
# Related Stories # Related Stories
+2
View File
@@ -19,6 +19,8 @@ Player account management, administrator navigation, dashboard metrics and chart
- Made event JSON keyboard-focusable so horizontally overflowing content can be reviewed without a pointer. - Made event JSON keyboard-focusable so horizontally overflowing content can be reviewed without a pointer.
- Added an accessible title, description, date labels, per-point labels, and textual values to the daily-active-user chart. - Added an accessible title, description, date labels, per-point labels, and textual values to the daily-active-user chart.
- Added labelled, keyboard-linked world-map markers plus a complete semantic table equivalent for approximate user locations. - Added labelled, keyboard-linked world-map markers plus a complete semantic table equivalent for approximate user locations.
- Collocated users share a visible count badge; hover and focus tooltips announce every nickname, while interactive grouped markers expose per-user popup links.
- The semantic location table separates network company, connection type, Proxy/VPN status, and risk classification under explicit column headers.
- Added keyboard-operable tabs for the server-rendered overview and opt-in interactive OpenStreetMap view. - Added keyboard-operable tabs for the server-rendered overview and opt-in interactive OpenStreetMap view.
- Added explicit new-tab context to the external Discord invite link. - Added explicit new-tab context to the external Discord invite link.
- Kept destructive account and group actions behind native keyboard-operable `details` confirmation disclosures. - Kept destructive account and group actions behind native keyboard-operable `details` confirmation disclosures.
+1 -1
View File
@@ -38,7 +38,7 @@ The admission decision is fail closed. Unknown players, invalid responses, expir
## IP intelligence ## IP intelligence
ProxyCheck.io supplies approximate city/region/country, coordinates, timezone, ASN, provider, risk, and VPN/proxy/Tor classification. Results are cached in PostgreSQL for 48 hours by default. Portal and game login events are enriched when data is available; lookup failures do not deny login. User Minecraft-account additions fail closed for unknown, VPN, proxy, or Tor classifications and record denied attempts. Hosting-provider blocking is optional through `BLOCK_HOSTING_IPS=true`. Private and reserved addresses are never sent to ProxyCheck. ProxyCheck.io supplies approximate city/region/country, coordinates, timezone, ASN, network company, connection type, proxy signal, risk, and VPN/proxy/Tor classification. Results are cached in PostgreSQL for 48 hours by default. Portal and game login events are enriched when data is available; lookup failures do not deny login. User Minecraft-account additions fail closed for unknown, VPN, proxy, or Tor classifications and record denied attempts. Hosting-provider blocking is optional through `BLOCK_HOSTING_IPS=true`. Private and reserved addresses are never sent to ProxyCheck.
## Event naming ## Event naming
+2 -1
View File
@@ -28,11 +28,12 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
- Group and membership mutations re-check the Keycloak administrator role server-side; destructive group deletion and its audit event commit atomically. - Group and membership mutations re-check the Keycloak administrator role server-side; destructive group deletion and its audit event commit atomically.
- Event filters accept only event types already present in the ledger, and event detail routes remain role-protected. - Event filters accept only event types already present in the ledger, and event detail routes remain role-protected.
- The administrator-only map defaults to bundled Natural Earth boundaries. OpenStreetMap tile requests begin only after an explicit operator opt-in; marker coordinates are not transmitted as data, but the requested tiles disclose the viewed geographic extent along with the administrator's IP and portal origin. - The administrator-only map defaults to bundled Natural Earth boundaries. OpenStreetMap tile requests begin only after an explicit operator opt-in; marker coordinates are not transmitted as data, but the requested tiles disclose the viewed geographic extent along with the administrator's IP and portal origin.
- Grouped-map popup labels and links are created with DOM `textContent` and server-rendered React escaping rather than interpolated HTML.
- ORM-parameterized queries are used throughout. - ORM-parameterized queries are used throughout.
- CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured. - CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured.
- Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured. - Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured.
- Private and reserved addresses are not sent to ProxyCheck.io; lookup results are cached to reduce disclosure and API usage. - Private and reserved addresses are not sent to ProxyCheck.io; lookup results are cached to reduce disclosure and API usage.
- Portal and game login events include approximate network location and VPN/proxy classification when available. - Portal and game login events include approximate network location and VPN/proxy classification when available. The administrator-only location list also exposes enriched network company, ASN, connection type, and the provider's proxy signal.
- Structured Pino logging redacts credential fields, and secrets are excluded from logs and repository configuration. - Structured Pino logging redacts credential fields, and secrets are excluded from logs and repository configuration.
## Outstanding production requirements ## Outstanding production requirements
+6
View File
@@ -16,6 +16,8 @@ export interface IpLocation {
export interface IpNetwork { export interface IpNetwork {
asn: string | null; asn: string | null;
provider: string | null; provider: string | null;
connectionType?: string | null;
proxy?: boolean | null;
} }
export interface IpIntelligenceResult { export interface IpIntelligenceResult {
@@ -112,6 +114,10 @@ export class ProxyCheckProvider implements IpIntelligenceProvider {
network: { network: {
asn: stringValue(data.asn), asn: stringValue(data.asn),
provider: stringValue(data.provider) ?? stringValue(data.organisation), provider: stringValue(data.provider) ?? stringValue(data.organisation),
connectionType: stringValue(data.type),
proxy: String(data.proxy).toLowerCase() === "yes"
? true
: String(data.proxy).toLowerCase() === "no" ? false : null,
}, },
rawResponse: root, rawResponse: root,
}; };
+1 -1
View File
@@ -42,7 +42,7 @@ describe("ProxyCheck.io intelligence", () => {
longitude: -122.0775, longitude: -122.0775,
timezone: "America/Los_Angeles", timezone: "America/Los_Angeles",
}, },
network: { asn: "AS15169", provider: "Google LLC" }, network: { asn: "AS15169", provider: "Google LLC", connectionType: "Business", proxy: false },
}); });
expect(request).toHaveBeenCalledWith( expect(request).toHaveBeenCalledWith(
expect.stringContaining("https://proxycheck.io/v2/8.8.8.8"), expect.stringContaining("https://proxycheck.io/v2/8.8.8.8"),