feat(dashboard): map latest user locations
This commit is contained in:
@@ -76,7 +76,7 @@ The token is displayed once and stored only as a SHA-256 hash.
|
||||
|
||||
- PostgreSQL and Drizzle ORM
|
||||
- Keycloak OIDC for admin access with the `minecraft-account-manager-admin` role
|
||||
- Admin user search, account management, event exploration, operational metrics, and automatic Discord nickname synchronization
|
||||
- Admin user search, account management, event exploration, operational metrics, an open-data user-location world map, and automatic Discord nickname synchronization
|
||||
- Exclusive group admission: unassigned users fall back to protected `everyone`, and only the effective group's access setting applies
|
||||
- Deployment-managed Discord guild ID and invite URL
|
||||
- discord.js bot with `/register` and `/account`
|
||||
|
||||
@@ -17,17 +17,22 @@
|
||||
"@minecraft-account-manager/logging": "*",
|
||||
"@minecraft-account-manager/minecraft": "*",
|
||||
"@minecraft-account-manager/network": "*",
|
||||
"d3-geo": "^3.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"next": "^16.2.1",
|
||||
"next-auth": "^4.24.13",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3"
|
||||
"react-dom": "^19.2.3",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/d3-geo": "^3.1.1",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.2.1",
|
||||
"tailwindcss": "^4.2.1",
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { events, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
|
||||
import { events, ipIntelligence, ipObservations, minecraftAccounts, users } from "@minecraft-account-manager/database";
|
||||
import { and, count, countDistinct, desc, eq, gte, inArray, isNotNull, sql } from "drizzle-orm";
|
||||
import Link from "next/link";
|
||||
import { UserWorldMap, type UserMapLocation } from "@/components/user-world-map";
|
||||
import { db } from "@/lib/database";
|
||||
import { fillDailySeries, type DailyCount } from "@/lib/admin-metrics";
|
||||
import { parseUserLocation } from "@/lib/user-location-map";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -13,7 +15,7 @@ export default async function AdminDashboardPage() {
|
||||
fourteenDaysAgo.setUTCHours(0, 0, 0, 0);
|
||||
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1_000);
|
||||
|
||||
const [registrationRows, [totals], [monthlyActive], riskyActivity, [recentDenials]] = await Promise.all([
|
||||
const [registrationRows, [totals], [monthlyActive], locationRows, riskyActivity, [recentDenials]] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
day: sql<string>`to_char(date_trunc('day', ${users.createdAt} at time zone 'UTC'), 'YYYY-MM-DD')`,
|
||||
@@ -31,6 +33,25 @@ export default async function AdminDashboardPage() {
|
||||
gte(ipObservations.observedAt, thirtyDaysAgo),
|
||||
isNotNull(ipObservations.userId),
|
||||
)),
|
||||
db
|
||||
.selectDistinctOn([ipObservations.userId], {
|
||||
userId: ipObservations.userId,
|
||||
name: users.firstName,
|
||||
discordUsername: users.discordUsername,
|
||||
classification: ipObservations.classification,
|
||||
source: ipObservations.source,
|
||||
observedAt: ipObservations.observedAt,
|
||||
intelligence: ipIntelligence.rawResponse,
|
||||
})
|
||||
.from(ipObservations)
|
||||
.innerJoin(users, eq(users.id, ipObservations.userId))
|
||||
.innerJoin(ipIntelligence, eq(ipIntelligence.ipAddress, ipObservations.ipAddress))
|
||||
.where(and(
|
||||
isNotNull(ipObservations.userId),
|
||||
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'latitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'latitude')::double precision between -90 and 90 else false end`,
|
||||
sql`case when jsonb_typeof(${ipIntelligence.rawResponse}->'location'->'longitude') = 'number' then (${ipIntelligence.rawResponse}->'location'->>'longitude')::double precision between -180 and 180 else false end`,
|
||||
))
|
||||
.orderBy(ipObservations.userId, desc(ipObservations.observedAt), desc(ipObservations.id)),
|
||||
db
|
||||
.select({
|
||||
id: ipObservations.id,
|
||||
@@ -54,6 +75,21 @@ export default async function AdminDashboardPage() {
|
||||
)),
|
||||
]);
|
||||
const registrations = fillDailySeries(registrationRows as DailyCount[], now, 14);
|
||||
const locations = locationRows.flatMap((row): UserMapLocation[] => {
|
||||
const parsed = parseUserLocation(row.intelligence);
|
||||
if (!parsed || !row.userId) return [];
|
||||
return [{
|
||||
userId: row.userId,
|
||||
name: row.name ?? row.discordUsername,
|
||||
discordUsername: row.discordUsername,
|
||||
latitude: parsed.latitude,
|
||||
longitude: parsed.longitude,
|
||||
location: parsed.label,
|
||||
classification: row.classification,
|
||||
source: row.source,
|
||||
observedAt: row.observedAt,
|
||||
}];
|
||||
});
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-6xl px-6 py-14">
|
||||
@@ -63,7 +99,9 @@ export default async function AdminDashboardPage() {
|
||||
<p className="mt-5 max-w-2xl text-sm leading-6 text-muted">Live, server-rendered registration, activity, and network-risk signals from the account registry.</p>
|
||||
</header>
|
||||
|
||||
<section aria-label="Key metrics" className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<UserWorldMap locations={locations} unavailableCount={Math.max(0, (totals?.users ?? 0) - locations.length)} />
|
||||
|
||||
<section aria-label="Key metrics" className="mt-10 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Metric label="Registered users" value={totals?.users ?? 0} detail="All time" />
|
||||
<Metric label="Monthly active users" value={monthlyActive?.users ?? 0} detail="Distinct users · 30 days" />
|
||||
<Metric label="Active Minecraft accounts" value={monthlyActive?.accounts ?? 0} detail="Distinct accounts · 30 days" />
|
||||
|
||||
@@ -58,6 +58,12 @@ body {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
svg a:hover .map-marker,
|
||||
svg a:focus .map-marker {
|
||||
stroke: var(--ink);
|
||||
stroke-width: 6px;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent);
|
||||
color: var(--panel);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { UserWorldMap } from "./user-world-map";
|
||||
|
||||
describe("UserWorldMap", () => {
|
||||
it("renders an accessible linked marker, text fallback, and open-data attribution", () => {
|
||||
const markup = renderToStaticMarkup(<UserWorldMap locations={[{
|
||||
userId: "11111111-1111-4111-8111-111111111111",
|
||||
name: "Dani",
|
||||
discordUsername: "dani",
|
||||
latitude: 37.4056,
|
||||
longitude: -122.0775,
|
||||
location: "Mountain View, California, US",
|
||||
classification: "clear",
|
||||
source: "game",
|
||||
observedAt: new Date("2026-08-01T12:00:00Z"),
|
||||
}]} unavailableCount={2} />);
|
||||
|
||||
expect(markup).toContain('role="group"');
|
||||
expect(markup).toContain('class="map-marker-target"');
|
||||
expect(markup).toContain("Latest approximate location for registered users");
|
||||
expect(markup).toContain('href="/admin/users/11111111-1111-4111-8111-111111111111"');
|
||||
expect(markup).toContain("Mountain View, California, US");
|
||||
expect(markup).toContain("Natural Earth, public domain");
|
||||
expect(markup).toContain("2 without coordinates");
|
||||
|
||||
const countryPaths = [...markup.matchAll(/<path d="([^"]+)"/g)].map((match) => match[1] ?? "");
|
||||
expect(countryPaths.length).toBeGreaterThan(100);
|
||||
for (const path of countryPaths) {
|
||||
const subpaths = path.split("M").slice(1);
|
||||
for (const subpath of subpaths) {
|
||||
const xCoordinates = [...subpath.matchAll(/(?:^|L)(-?\d+(?:\.\d+)?),/g)].map((match) => Number(match[1]));
|
||||
for (let index = 1; index < xCoordinates.length; index += 1) {
|
||||
expect(Math.abs(xCoordinates[index]! - xCoordinates[index - 1]!)).toBeLessThan(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { FeatureCollection } from "geojson";
|
||||
import type { GeometryCollection, Topology } from "topojson-specification";
|
||||
import { geoEquirectangular, geoPath } from "d3-geo";
|
||||
import { feature } from "topojson-client";
|
||||
import countriesTopologyJson from "world-atlas/countries-110m.json";
|
||||
import Link from "next/link";
|
||||
|
||||
const WIDTH = 1_000;
|
||||
const HEIGHT = 500;
|
||||
const topology = countriesTopologyJson as unknown as Topology<{ countries: GeometryCollection }>;
|
||||
const countries = feature(topology, topology.objects.countries) as FeatureCollection;
|
||||
const projection = geoEquirectangular().fitExtent([[1, 1], [WIDTH - 1, HEIGHT - 1]], { type: "Sphere" });
|
||||
const countryPath = geoPath(projection);
|
||||
|
||||
export interface UserMapLocation {
|
||||
userId: string;
|
||||
name: string;
|
||||
discordUsername: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
location: string;
|
||||
classification: string;
|
||||
source: string;
|
||||
observedAt: Date;
|
||||
}
|
||||
|
||||
export function UserWorldMap({ locations, unavailableCount }: { locations: UserMapLocation[]; unavailableCount: number }) {
|
||||
return (
|
||||
<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>
|
||||
<p className="font-mono text-[9px] font-bold uppercase tracking-widest text-muted">Latest known location</p>
|
||||
<h2 className="mt-2 font-display text-3xl font-black uppercase">Community world</h2>
|
||||
</div>
|
||||
<p className="max-w-sm text-xs leading-5 text-muted">{locations.length} mapped · {unavailableCount} without coordinates. Locations are approximate IP intelligence, not precise device positions.</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 overflow-hidden border border-line bg-[#b9d4d1]">
|
||||
<svg aria-labelledby="user-world-map-title user-world-map-description" className="h-auto w-full" role="group" viewBox={`0 0 ${WIDTH} ${HEIGHT}`}>
|
||||
<title id="user-world-map-title">Latest approximate location for registered users</title>
|
||||
<desc id="user-world-map-description">An open-data world map with one linked marker for every user whose latest geolocated observation has valid coordinates. A complete text list follows.</desc>
|
||||
<rect fill="#b9d4d1" height={HEIGHT} width={WIDTH} />
|
||||
<g aria-hidden="true" fill="var(--canvas)" stroke="var(--line)" strokeWidth="0.7">
|
||||
{countries.features.map((country, index) => {
|
||||
const path = countryPath(country);
|
||||
return path ? <path d={path} key={country.id ?? index} /> : null;
|
||||
})}
|
||||
</g>
|
||||
<g>
|
||||
{locations.map((user) => {
|
||||
const projected = projection([user.longitude, user.latitude]);
|
||||
if (!projected) return null;
|
||||
const x = Math.min(WIDTH - 14, Math.max(14, projected[0]));
|
||||
const y = Math.min(HEIGHT - 14, Math.max(14, projected[1]));
|
||||
return (
|
||||
<a aria-label={`${user.name}, ${user.location}, last seen ${user.observedAt.toISOString()}`} href={`/admin/users/${user.userId}`} key={user.userId}>
|
||||
<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" cx={x} cy={y} fill="var(--accent)" pointerEvents="none" r="7" stroke="var(--panel)" strokeWidth="3">
|
||||
<title>{user.name} · {user.location} · {user.classification}</title>
|
||||
</circle>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<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">
|
||||
<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">
|
||||
<table className="w-full min-w-[680px] border-collapse text-left text-xs">
|
||||
<caption className="sr-only">Latest approximate registered-user locations</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>
|
||||
<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.name}</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.length && <tr><td className="py-6 text-muted" colSpan={5}>No user observations currently include valid coordinates.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseUserLocation, projectWorldPoint } from "./user-location-map";
|
||||
|
||||
describe("user location map", () => {
|
||||
it("extracts a valid approximate location from cached IP intelligence", () => {
|
||||
expect(parseUserLocation({
|
||||
classification: "clear",
|
||||
location: {
|
||||
city: "Mountain View",
|
||||
region: "California",
|
||||
countryCode: "US",
|
||||
latitude: 37.4056,
|
||||
longitude: -122.0775,
|
||||
},
|
||||
})).toEqual({
|
||||
latitude: 37.4056,
|
||||
longitude: -122.0775,
|
||||
label: "Mountain View, California, US",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing and out-of-range coordinates", () => {
|
||||
expect(parseUserLocation({ location: { latitude: 91, longitude: 0 } })).toBeNull();
|
||||
expect(parseUserLocation({ location: { city: "Unknown" } })).toBeNull();
|
||||
});
|
||||
|
||||
it("projects longitude and latitude into an equirectangular SVG", () => {
|
||||
expect(projectWorldPoint(0, 0, 800, 400)).toEqual({ x: 400, y: 200 });
|
||||
expect(projectWorldPoint(90, 180, 800, 400)).toEqual({ x: 800, y: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
type UnknownMap = Record<string, unknown>;
|
||||
|
||||
function objectValue(value: unknown): UnknownMap | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? value as UnknownMap
|
||||
: null;
|
||||
}
|
||||
|
||||
function coordinate(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface ParsedUserLocation {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export function parseUserLocation(value: unknown): ParsedUserLocation | null {
|
||||
const intelligence = objectValue(value);
|
||||
const location = objectValue(intelligence?.location);
|
||||
if (!location) return null;
|
||||
const latitude = coordinate(location.latitude);
|
||||
const longitude = coordinate(location.longitude);
|
||||
if (latitude === null || longitude === null || latitude < -90 || latitude > 90 || longitude < -180 || longitude > 180) {
|
||||
return null;
|
||||
}
|
||||
const label = [location.city, location.region, location.countryCode ?? location.country]
|
||||
.filter((part): part is string => typeof part === "string" && part.trim().length > 0)
|
||||
.join(", ");
|
||||
return { latitude, longitude, label: label || "Approximate location unavailable" };
|
||||
}
|
||||
|
||||
export function projectWorldPoint(latitude: number, longitude: number, width: number, height: number) {
|
||||
return {
|
||||
x: ((longitude + 180) / 360) * width,
|
||||
y: ((90 - latitude) / 180) * height,
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
## 2026-08-01
|
||||
|
||||
* **Extend**: Plot each user's latest approximate location on an accessible, server-rendered Natural Earth world map in the operations dashboard.
|
||||
* **Refine**: Make group assignment exclusive with default fallback, add group deletion, automatically synchronize Discord nicknames with status notices, expose filterable event details, add an SSR operations dashboard, and improve accessibility.
|
||||
* **Extend**: Add SoMC Portal branding, live Discord identity details, admin guild configuration visibility, and fail-closed group-based Minecraft admission.
|
||||
* **Refine**: Group repeated access networks, confirm linked Discord nickname changes before mutation, and add DMG Games sponsorship attribution.
|
||||
|
||||
@@ -3,7 +3,7 @@ type: User Story
|
||||
title: Monitor community account activity
|
||||
description: Administrators use a server-rendered dashboard to review registrations, monthly activity, denials, and risky networks.
|
||||
tags: [admin, dashboard, metrics, security, ssr]
|
||||
timestamp: 2026-08-01T23:10:59Z
|
||||
timestamp: 2026-08-01T23:32:54Z
|
||||
story_id: US-018
|
||||
status: verified
|
||||
---
|
||||
@@ -15,6 +15,9 @@ As an administrator, I want an operational dashboard of account and game activit
|
||||
# Acceptance Criteria
|
||||
|
||||
- [x] The administrator landing page is a dashboard rather than a settings form.
|
||||
- [x] An open-data world map plots each user's latest observation with valid approximate coordinates.
|
||||
- [x] Map markers link to user records and have an accessible text-table equivalent.
|
||||
- [x] Natural Earth boundaries are bundled and server-rendered without disclosing map or location requests to a third party.
|
||||
- [x] The dashboard graphs new registered users by UTC day for the previous 14 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 linked accounts observed in the previous 30 days.
|
||||
@@ -29,10 +32,13 @@ As an administrator, I want an operational dashboard of account and game activit
|
||||
- [`apps/web/src/app/admin/(console)/page.tsx`](../apps/web/src/app/admin/%28console%29/page.tsx)
|
||||
- [`apps/web/src/app/admin/(console)/settings/page.tsx`](../apps/web/src/app/admin/%28console%29/settings/page.tsx)
|
||||
- [`apps/web/src/lib/admin-metrics.ts`](../apps/web/src/lib/admin-metrics.ts)
|
||||
- [`apps/web/src/components/user-world-map.tsx`](../apps/web/src/components/user-world-map.tsx)
|
||||
- [`apps/web/src/lib/user-location-map.ts`](../apps/web/src/lib/user-location-map.ts)
|
||||
|
||||
# Validation
|
||||
|
||||
- Missing-day chart behavior is 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.
|
||||
- The Next.js production build reports the dashboard and database-backed console pages as dynamic server-rendered routes.
|
||||
|
||||
# Related Stories
|
||||
|
||||
@@ -18,6 +18,7 @@ Player account management, administrator navigation, dashboard metrics and chart
|
||||
- Added semantic `time` elements for audit and security activity timestamps.
|
||||
- Made event JSON keyboard-focusable so horizontally overflowing content can be reviewed without a pointer.
|
||||
- Added an accessible title, description, per-point labels, and textual values to the registration chart.
|
||||
- Added labelled, keyboard-linked world-map markers plus a complete semantic table equivalent for approximate user locations.
|
||||
- Added explicit new-tab context to the external Discord invite link.
|
||||
- Kept destructive account and group actions behind native keyboard-operable `details` confirmation disclosures.
|
||||
- Allowed administrator navigation to wrap at narrow viewport widths instead of overflowing.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### Web application
|
||||
|
||||
The Next.js application owns user onboarding, account management, admin configuration and metrics, server-side Minecraft profile validation, sessions, and the HTTP API used by Discord and Velocity integrations. Database-backed portal and console pages are dynamic React Server Components: authentication, queries, filtering, and dashboard aggregation execute on the server and return rendered HTML.
|
||||
The Next.js application owns user onboarding, account management, admin configuration and metrics, server-side Minecraft profile validation, sessions, and the HTTP API used by Discord and Velocity integrations. Database-backed portal and console pages are dynamic React Server Components: authentication, queries, filtering, dashboard aggregation, and the Natural Earth user-location map execute on the server and return rendered HTML. Map boundaries are bundled open data, so rendering does not disclose administrator or user location requests to a map provider.
|
||||
|
||||
User authentication begins with an opaque, short-lived, single-use token created for a Discord user. Only a cryptographic hash of the token is persisted. Admin authentication is a separate Keycloak OIDC flow and requires the `minecraft-account-manager-admin` role.
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ Next.js portal and APIs, Discord bot, PostgreSQL persistence, Keycloak admin aut
|
||||
- Registered players require an enabled effective group; explicit assignments replace rather than combine with the protected, disabled-by-default `everyone` fallback.
|
||||
- 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.
|
||||
- The administrator-only location map uses bundled Natural Earth boundaries and approximate cached IP intelligence; it sends no coordinates or map requests to third parties.
|
||||
- ORM-parameterized queries are used throughout.
|
||||
- CSP, clickjacking, MIME-sniffing, referrer, and browser-permission headers are configured.
|
||||
- Forwarded IP headers are ignored unless `TRUST_PROXY=true` is explicitly configured.
|
||||
|
||||
Generated
+103
-1
@@ -42,17 +42,22 @@
|
||||
"@minecraft-account-manager/logging": "*",
|
||||
"@minecraft-account-manager/minecraft": "*",
|
||||
"@minecraft-account-manager/network": "*",
|
||||
"d3-geo": "^3.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"next": "^16.2.1",
|
||||
"next-auth": "^4.24.13",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3"
|
||||
"react-dom": "^19.2.3",
|
||||
"topojson-client": "^3.1.0",
|
||||
"world-atlas": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@types/d3-geo": "^3.1.1",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/topojson-client": "^3.1.5",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.2.1",
|
||||
"tailwindcss": "^4.2.1",
|
||||
@@ -2724,6 +2729,16 @@
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
@@ -2738,6 +2753,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -2781,6 +2803,27 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/topojson-client": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/topojson-client/-/topojson-client-3.1.5.tgz",
|
||||
"integrity": "sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*",
|
||||
"@types/topojson-specification": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/topojson-specification": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/topojson-specification/-/topojson-specification-1.0.5.tgz",
|
||||
"integrity": "sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
@@ -4084,6 +4127,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "2.20.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
|
||||
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
@@ -4129,6 +4178,30 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-geo": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
|
||||
"integrity": "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.5.0 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/damerau-levenshtein": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||
@@ -5718,6 +5791,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
|
||||
@@ -8337,6 +8419,20 @@
|
||||
"node": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/topojson-client": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
|
||||
"integrity": "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"commander": "2"
|
||||
},
|
||||
"bin": {
|
||||
"topo2geo": "bin/topo2geo",
|
||||
"topomerge": "bin/topomerge",
|
||||
"topoquantize": "bin/topoquantize"
|
||||
}
|
||||
},
|
||||
"node_modules/ts-api-utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||
@@ -9261,6 +9357,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/world-atlas": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/world-atlas/-/world-atlas-2.0.2.tgz",
|
||||
"integrity": "sha512-IXfV0qwlKXpckz1FhwXVwKRjiIhOnWttOskm5CtxMsjgE/MXAYRHWJqgXOpM8IkcPBoXnyTU5lFHcYa5ChG0LQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
|
||||
Reference in New Issue
Block a user