From e324a9562512c8e45bce859a7009565a16929aa2 Mon Sep 17 00:00:00 2001 From: NextJS Date: Sun, 12 Jul 2026 17:55:44 +0000 Subject: [PATCH] sync --- .env.example | 4 + package-lock.json | 220 +++++++++++- package.json | 4 +- src/app/actions/add-system-supply.ts | 8 +- src/app/actions/settings-actions.ts | 18 +- src/app/actions/update-user-cc-rc.ts | 8 +- src/app/api/auth/login/route.ts | 12 +- src/app/api/match-logs/[matchId]/route.ts | 7 +- src/app/api/matchmaker-logs/route.ts | 7 +- src/app/ledger-book/page.tsx | 8 +- src/app/match-logs/[matchId]/page.tsx | 10 +- src/app/matchmaker-logs/page.tsx | 9 +- src/app/page.tsx | 30 +- src/app/settings/page.tsx | 14 +- src/components/admin-dashboard.tsx | 23 +- src/components/admin-header.tsx | 11 +- src/components/admin-ledger.tsx | 20 +- src/components/admin-match-analysis.tsx | 10 + src/components/admin-settings-editor.tsx | 56 +++ src/components/analysis-geolocation-map.tsx | 360 ++++++++++++++++++++ src/lib/auth/credentials.ts | 40 ++- src/lib/auth/require-session.ts | 30 ++ src/lib/auth/roles.ts | 25 ++ src/lib/auth/session-cookie.ts | 28 +- src/lib/geolocation-analytics.ts | 178 ++++++++++ src/lib/heatmap-color.ts | 40 +++ src/lib/request-public-url.ts | 18 +- src/proxy.ts | 3 +- 28 files changed, 1087 insertions(+), 114 deletions(-) create mode 100644 src/components/analysis-geolocation-map.tsx create mode 100644 src/lib/auth/require-session.ts create mode 100644 src/lib/auth/roles.ts create mode 100644 src/lib/geolocation-analytics.ts create mode 100644 src/lib/heatmap-color.ts diff --git a/.env.example b/.env.example index 930c1a8..594ab76 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,10 @@ ADMIN_USERNAME=admin ADMIN_PASSWORD=change-me-to-a-strong-password +# Read-only supervisor login (server only; can view all data but cannot modify) +SUPERVISOR_USERNAME=supervisor +SUPERVISOR_PASSWORD=change-me-to-a-strong-password + # Optional: public site URL when reverse proxy does not send X-Forwarded-* (fixes post-login redirects). # Example: https://kickkings.playpoolstudios.com # APP_ORIGIN= diff --git a/package-lock.json b/package-lock.json index a68337a..4db65e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,13 +11,15 @@ "@supabase/supabase-js": "^2.102.1", "next": "^16.2.4", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "react-simple-maps": "^3.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/react-simple-maps": "^3.0.6", "eslint": "^9", "eslint-config-next": "16.2.3", "tailwindcss": "^4", @@ -68,7 +70,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1612,6 +1613,51 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/d3-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-2.0.6.tgz", + "integrity": "sha512-tbaFGDmJWHqnenvk3QGSvD3RVwr631BjKRD7Sc7VLRgrdX5mk5hTyoeBL6rXZaeoXzmZwIl1D2HPogEdt1rHBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-geo": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-geo/-/d3-geo-2.0.7.tgz", + "integrity": "sha512-RIXlxPdxvX+LAZFv+t78CuYpxYag4zuw9mZc+AwfB8tZpKU90rMEn2il2ADncmeZlb7nER9dDsJpRisA3lRvjA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-2.0.5.tgz", + "integrity": "sha512-UINE41RDaUMbulp+bxQMDnhOi51rh5lA2dG+dWZU0UY/IwQiG/u2x8TfnWYU9+xwGdXsJoAvrBYUEQl0r91atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-color": "^2" + } + }, + "node_modules/@types/d3-selection": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-2.0.5.tgz", + "integrity": "sha512-71BorcY0yXl12S7lvb01JdaN9TpeUHBDb4RRhSq8U8BEkX/nIk5p7Byho+ZRTsx5nYLMpAbY3qt5EhqFzfGJlw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/d3-zoom": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-2.0.7.tgz", + "integrity": "sha512-JWke4E8ZyrKUQ68ESTWSK16fVb0OYnaiJ+WXJRYxKLn4aXU0o4CLYxMWBEiouUfO3TTCoyroOrGPcBG6u1aAxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "^2", + "@types/d3-selection": "^2" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1619,6 +1665,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", @@ -1648,7 +1701,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1663,6 +1715,19 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/react-simple-maps": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/react-simple-maps/-/react-simple-maps-3.0.6.tgz", + "integrity": "sha512-hR01RXt6VvsE41FxDd+Bqm1PPGdKbYjCYVtCgh38YeBPt46z3SwmWPWu2L3EdCAP6bd6VYEgztucihRw1C0Klg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/d3-geo": "^2", + "@types/d3-zoom": "^2", + "@types/geojson": "*", + "@types/react": "*" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -1717,7 +1782,6 @@ "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.58.1", "@typescript-eslint/types": "8.58.1", @@ -2243,7 +2307,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2587,7 +2650,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2725,6 +2787,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", @@ -2761,6 +2829,102 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz", + "integrity": "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==", + "license": "BSD-3-Clause", + "dependencies": { + "internmap": "^1.0.0" + } + }, + "node_modules/d3-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-2.0.0.tgz", + "integrity": "sha512-SPXi0TSKPD4g9tw0NMZFnR95XVgUZiBH+uUTqQuDu1OsE2zomHU7ho0FISciaPvosimixwHFl3WHLGabv6dDgQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-dispatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-2.0.0.tgz", + "integrity": "sha512-S/m2VsXI7gAti2pBoLClFFTMOO1HTtT0j99AuXLoGFKO6deHDdnv6ZGTxSTTUTgO1zVcv82fCOtDjYK4EECmWA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-drag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-2.0.0.tgz", + "integrity": "sha512-g9y9WbMnF5uqB9qKqwIIa/921RYWzlUDv9Jl1/yONQwxbOfszAWTCm8u7HOTgJgRDXiRZN56cHT9pd24dmXs8w==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-dispatch": "1 - 2", + "d3-selection": "2" + } + }, + "node_modules/d3-ease": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-2.0.0.tgz", + "integrity": "sha512-68/n9JWarxXkOWMshcT5IcjbB+agblQUaIsbnXmrzejn2O82n3p2A9R2zEB9HIEFWKFwPAEDDN8gR0VdSAyyAQ==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-geo": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-2.0.2.tgz", + "integrity": "sha512-8pM1WGMLGFuhq9S+FpPURxic+gKzjluCD/CHTuUF3mXMeiCo0i6R0tO1s4+GArRFde96SLcW/kOFRjoAosPsFA==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-array": "^2.5.0" + } + }, + "node_modules/d3-interpolate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-2.0.1.tgz", + "integrity": "sha512-c5UhwwTs/yybcmTpAVqwSFl6vrQ8JZJoT5F7xNFK9pymv5C0Ymcc9/LIJHtYIggg/yS9YHw8i8O8tgb9pupjeQ==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-color": "1 - 2" + } + }, + "node_modules/d3-selection": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-2.0.0.tgz", + "integrity": "sha512-XoGGqhLUN/W14NmaqcO/bb1nqjDAw5WtSYb2X8wiuQWvSZUsUVYsOSkOybUrNvcBjaywBdYPy03eXHMXjk9nZA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-timer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-2.0.0.tgz", + "integrity": "sha512-TO4VLh0/420Y/9dO3+f9abDEFYeCUr2WZRlxJvbp4HPTQcSylXNiL6yZa9FIUvV1yRiFufl1bszTCLDqv9PWNA==", + "license": "BSD-3-Clause" + }, + "node_modules/d3-transition": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-2.0.0.tgz", + "integrity": "sha512-42ltAGgJesfQE3u9LuuBHNbGrI/AJjNL2OAUdclE70UE6Vy239GCBEYD38uBPoLeNsOhFStGpPI0BAOV+HMxog==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-color": "1 - 2", + "d3-dispatch": "1 - 2", + "d3-ease": "1 - 2", + "d3-interpolate": "1 - 2", + "d3-timer": "1 - 2" + }, + "peerDependencies": { + "d3-selection": "2" + } + }, + "node_modules/d3-zoom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-2.0.0.tgz", + "integrity": "sha512-fFg7aoaEm9/jf+qfstak0IYpnesZLiMX6GZvXtUSdv8RH2o4E2qeelgdU09eKS6wGuiGMfcnMI0nTIqWzRHGpw==", + "license": "BSD-3-Clause", + "dependencies": { + "d3-dispatch": "1 - 2", + "d3-drag": "2", + "d3-interpolate": "1 - 2", + "d3-selection": "2", + "d3-transition": "2" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -3156,7 +3320,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3342,7 +3505,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -4065,6 +4227,12 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz", + "integrity": "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==", + "license": "ISC" + }, "node_modules/is-array-buffer": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", @@ -5525,7 +5693,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5535,7 +5702,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5550,6 +5716,23 @@ "dev": true, "license": "MIT" }, + "node_modules/react-simple-maps": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/react-simple-maps/-/react-simple-maps-3.0.0.tgz", + "integrity": "sha512-vKNFrvpPG8Vyfdjnz5Ne1N56rZlDfHXv5THNXOVZMqbX1rWZA48zQuYT03mx6PAKanqarJu/PDLgshIZAfHHqw==", + "license": "MIT", + "dependencies": { + "d3-geo": "^2.0.2", + "d3-selection": "^2.0.0", + "d3-zoom": "^2.0.0", + "topojson-client": "^3.1.0" + }, + "peerDependencies": { + "prop-types": "^15.7.2", + "react": "^16.8.0 || 17.x || 18.x", + "react-dom": "^16.8.0 || 17.x || 18.x" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -6227,7 +6410,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6248,6 +6430,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", @@ -6390,7 +6586,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6686,7 +6881,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 8078c42..dfa0c39 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,15 @@ "@supabase/supabase-js": "^2.102.1", "next": "^16.2.4", "react": "19.2.4", - "react-dom": "19.2.4" + "react-dom": "19.2.4", + "react-simple-maps": "^3.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/react-simple-maps": "^3.0.6", "eslint": "^9", "eslint-config-next": "16.2.3", "tailwindcss": "^4", diff --git a/src/app/actions/add-system-supply.ts b/src/app/actions/add-system-supply.ts index f1a6301..6f48bb0 100644 --- a/src/app/actions/add-system-supply.ts +++ b/src/app/actions/add-system-supply.ts @@ -1,8 +1,7 @@ "use server"; -import { cookies } from "next/headers"; import { redirect } from "next/navigation"; -import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session"; +import { requireWriteAccess } from "@/lib/auth/require-session"; import { applyCoinsDeltaToRcBalance } from "@/lib/coins-rc"; import { buildDashboardHref, @@ -25,10 +24,7 @@ function parsePositiveCoins(raw: FormDataEntryValue | null): bigint | null { } export async function addSystemSupply(formData: FormData) { - const cookieStore = await cookies(); - if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") { - redirect("/login"); - } + await requireWriteAccess(); const tabRaw = String(formData.get("tab") ?? ""); const tab: AdminDashboardTab = diff --git a/src/app/actions/settings-actions.ts b/src/app/actions/settings-actions.ts index 0b6e9a3..ed5ca5e 100644 --- a/src/app/actions/settings-actions.ts +++ b/src/app/actions/settings-actions.ts @@ -1,8 +1,7 @@ "use server"; -import { cookies } from "next/headers"; import { redirect } from "next/navigation"; -import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session"; +import { requireWriteAccess } from "@/lib/auth/require-session"; import { parseStoredCoins } from "@/lib/coins-rc"; import { createAdminSupabase } from "@/lib/supabase/admin"; @@ -38,10 +37,7 @@ function normalizeSettingValue( } export async function updateSetting(formData: FormData) { - const cookieStore = await cookies(); - if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") { - redirect("/login"); - } + await requireWriteAccess(); const key = String(formData.get("key") ?? "").trim(); if (!key) { @@ -71,10 +67,7 @@ export async function updateSetting(formData: FormData) { } export async function insertSetting(formData: FormData) { - const cookieStore = await cookies(); - if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") { - redirect("/login"); - } + await requireWriteAccess(); const key = String(formData.get("newKey") ?? "").trim(); const value = normalizeValue(formData.get("newValue")); @@ -102,10 +95,7 @@ export async function insertSetting(formData: FormData) { } export async function deleteSetting(formData: FormData) { - const cookieStore = await cookies(); - if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") { - redirect("/login"); - } + await requireWriteAccess(); const key = String(formData.get("key") ?? "").trim(); if (!key) { diff --git a/src/app/actions/update-user-cc-rc.ts b/src/app/actions/update-user-cc-rc.ts index 7c7afb4..a786129 100644 --- a/src/app/actions/update-user-cc-rc.ts +++ b/src/app/actions/update-user-cc-rc.ts @@ -1,8 +1,7 @@ "use server"; -import { cookies } from "next/headers"; import { redirect } from "next/navigation"; -import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session"; +import { requireWriteAccess } from "@/lib/auth/require-session"; import { buildDashboardHref, type AdminDashboardTab, @@ -19,10 +18,7 @@ function parseScoreField(raw: FormDataEntryValue | null): number | null { } export async function updateUserCcRc(formData: FormData) { - const cookieStore = await cookies(); - if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") { - redirect("/login"); - } + await requireWriteAccess(); const userId = Number(formData.get("userId")); const tabRaw = String(formData.get("tab") ?? ""); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index d01072d..c6aa0e2 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,6 +1,9 @@ import { NextResponse } from "next/server"; import { getClientIp } from "@/lib/auth/client-ip"; -import { verifyAdminCredentials } from "@/lib/auth/credentials"; +import { verifyCredentials } from "@/lib/auth/credentials"; +import { + roleToSessionCookieValue, +} from "@/lib/auth/roles"; import { checkLoginRateLimit, clearLoginAttempts, @@ -54,7 +57,8 @@ export async function POST(request: Request) { password = String(formData.get("password") ?? ""); } - if (!verifyAdminCredentials(username, password)) { + const role = verifyCredentials(username, password); + if (!role) { recordFailedLogin(clientIp); if (contentType.includes("application/json")) { return invalidJson(); @@ -68,13 +72,13 @@ export async function POST(request: Request) { const res = NextResponse.json({ ok: true }); res.cookies.set( ADMIN_SESSION_COOKIE, - "1", + roleToSessionCookieValue(role), getSessionCookieSetOptions(request), ); return res; } const res = NextResponse.redirect(publicRequestUrl(request, "/")); - applySessionCookie(res, request); + applySessionCookie(res, request, role); return res; } diff --git a/src/app/api/match-logs/[matchId]/route.ts b/src/app/api/match-logs/[matchId]/route.ts index 45a9934..6e6c233 100644 --- a/src/app/api/match-logs/[matchId]/route.ts +++ b/src/app/api/match-logs/[matchId]/route.ts @@ -1,5 +1,4 @@ -import { cookies } from "next/headers"; -import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session"; +import { getSessionRole } from "@/lib/auth/require-session"; import { parseMatchIdParam, readMatchLogFile, @@ -9,8 +8,8 @@ export async function GET( _request: Request, context: { params: Promise<{ matchId: string }> }, ) { - const cookieStore = await cookies(); - if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") { + const role = await getSessionRole(); + if (!role) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/src/app/api/matchmaker-logs/route.ts b/src/app/api/matchmaker-logs/route.ts index 8cc67dd..236133f 100644 --- a/src/app/api/matchmaker-logs/route.ts +++ b/src/app/api/matchmaker-logs/route.ts @@ -1,13 +1,12 @@ -import { cookies } from "next/headers"; -import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session"; +import { getSessionRole } from "@/lib/auth/require-session"; import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source"; import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server"; const NO_STORE = { "Cache-Control": "no-store, max-age=0" }; export async function GET(request: Request) { - const cookieStore = await cookies(); - if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") { + const role = await getSessionRole(); + if (!role) { return Response.json( { error: "Unauthorized" }, { status: 401, headers: NO_STORE }, diff --git a/src/app/ledger-book/page.tsx b/src/app/ledger-book/page.tsx index f16c635..3d868de 100644 --- a/src/app/ledger-book/page.tsx +++ b/src/app/ledger-book/page.tsx @@ -1,10 +1,9 @@ import { Fragment } from "react"; import type { Metadata } from "next"; import Link from "next/link"; -import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import { AdminHeader } from "@/components/admin-header"; -import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session"; +import { requireSession } from "@/lib/auth/require-session"; import { formatRcDecimalFromCoinsBigInt, formatRcLabelFromCoinsBigInt, @@ -58,10 +57,7 @@ export default async function LedgerBookPage({ }: { searchParams: Promise<{ from?: string | string[]; to?: string | string[] }>; }) { - const cookieStore = await cookies(); - if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") { - redirect("/login"); - } + await requireSession(); const sp = await searchParams; let fromStr = firstSearchParam(sp.from); diff --git a/src/app/match-logs/[matchId]/page.tsx b/src/app/match-logs/[matchId]/page.tsx index 17bc4ea..c10e78b 100644 --- a/src/app/match-logs/[matchId]/page.tsx +++ b/src/app/match-logs/[matchId]/page.tsx @@ -1,8 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; -import { cookies } from "next/headers"; -import { notFound, redirect } from "next/navigation"; -import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session"; +import { notFound } from "next/navigation"; +import { requireSession } from "@/lib/auth/require-session"; import { MatchLogColoredBody } from "@/components/match-log-colored-body"; import { parseMatchIdParam, @@ -25,10 +24,7 @@ export default async function MatchLogPage({ }: { params: Promise<{ matchId: string }>; }) { - const cookieStore = await cookies(); - if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") { - redirect("/login"); - } + await requireSession(); const { matchId: raw } = await params; const matchId = parseMatchIdParam(raw); diff --git a/src/app/matchmaker-logs/page.tsx b/src/app/matchmaker-logs/page.tsx index a21e20d..e10922c 100644 --- a/src/app/matchmaker-logs/page.tsx +++ b/src/app/matchmaker-logs/page.tsx @@ -1,8 +1,6 @@ import type { Metadata } from "next"; -import { cookies } from "next/headers"; -import { redirect } from "next/navigation"; import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal"; -import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session"; +import { requireSession } from "@/lib/auth/require-session"; import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source"; import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server"; @@ -17,10 +15,7 @@ export default async function MatchmakerLogsPage({ }: { searchParams: Promise<{ mklog?: string | string[] }>; }) { - const cookieStore = await cookies(); - if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") { - redirect("/login"); - } + await requireSession(); const sp = await searchParams; const raw = diff --git a/src/app/page.tsx b/src/app/page.tsx index bd5811e..4cefac1 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,8 @@ import { AdminDashboard } from "@/components/admin-dashboard"; import { AdminHeader } from "@/components/admin-header"; import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay"; +import { isReadOnlyRole } from "@/lib/auth/roles"; +import { requireSession } from "@/lib/auth/require-session"; import { loadDashboardStatsBundle } from "@/lib/dashboard-stats"; import { normalizeLedgerDateRange, @@ -25,6 +27,10 @@ import { } from "@/lib/match-log-analysis-server"; import type { MatchLogAnalysisResult } from "@/lib/match-log-parser"; import { emptyMatchLogAnalysisResult } from "@/lib/match-log-parser"; +import { + loadGeolocationAnalytics, + type GeolocationAnalyticsResult, +} from "@/lib/geolocation-analytics"; import { loadPingAnalytics, type PingAnalyticsResult, @@ -78,6 +84,9 @@ export default async function Home({ aplayers?: string | string[]; }>; }) { + const role = await requireSession(); + const readOnly = isReadOnlyRole(role); + const sp = await searchParams; const tabParam = firstSearchParam(sp.tab); const tab: AdminDashboardTab = @@ -138,6 +147,11 @@ export default async function Home({ byCountries: [], error: null, }; + let geolocationAnalytics: GeolocationAnalyticsResult = { + userAccounts: [], + matches: [], + error: null, + }; let statsBundle: Awaited> | null = null; @@ -272,7 +286,7 @@ export default async function Home({ analysisFrom = range.from; analysisTo = range.to; analysisPlayerIds = parseAnalysisPlayerIds(sp.aplayers); - const [matchLogResult, pingResult] = await Promise.all([ + const [matchLogResult, pingResult, geoResult] = await Promise.all([ analyzeMatchLogsForMatches( matches, analysisFrom, @@ -280,20 +294,28 @@ export default async function Home({ analysisPlayerIds, ), loadPingAnalytics(supabase, analysisFrom, analysisTo, analysisPlayerIds), + loadGeolocationAnalytics( + supabase, + users, + analysisFrom, + analysisTo, + analysisPlayerIds, + ), ]); matchAnalysis = matchLogResult; pingAnalytics = pingResult; + geolocationAnalytics = geoResult; } } let editUser: DbUser | null = null; - if (Number.isInteger(editIdNum) && editIdNum >= 1) { + if (!readOnly && Number.isInteger(editIdNum) && editIdNum >= 1) { editUser = users.find((u) => Number(u.id) === editIdNum) ?? null; } return (
- + {configError ? (
{editUser ? ( ; }) { - const cookieStore = await cookies(); - if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") { - redirect("/login"); - } + const role = await requireSession(); + const readOnly = isReadOnlyRole(role); const sp = await searchParams; const saveError = firstSearchParam(sp.saveError) === "1"; @@ -63,7 +60,7 @@ export default async function SettingsPage({ return (
- +

@@ -96,6 +93,7 @@ export default async function SettingsPage({ rows={rows} saveError={saveError} addError={addError} + readOnly={readOnly} /> )}

diff --git a/src/components/admin-dashboard.tsx b/src/components/admin-dashboard.tsx index b70407e..0272a60 100644 --- a/src/components/admin-dashboard.tsx +++ b/src/components/admin-dashboard.tsx @@ -8,6 +8,7 @@ import { MatchHistoryBattleCard } from "@/components/match-history-battle-card"; import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats"; import { formatRcBalanceWithCoins } from "@/lib/coins-rc"; import { AdminMatchAnalysis } from "@/components/admin-match-analysis"; +import type { GeolocationAnalyticsResult } from "@/lib/geolocation-analytics"; import type { PingAnalyticsResult } from "@/lib/ping-analytics"; import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal"; import type { MatchLogAnalysisResult } from "@/lib/match-log-parser"; @@ -225,6 +226,8 @@ type Props = { analysisPlayerIds: number[]; matchAnalysis: MatchLogAnalysisResult; pingAnalytics: PingAnalyticsResult; + geolocationAnalytics: GeolocationAnalyticsResult; + readOnly: boolean; }; function StatCard({ @@ -280,6 +283,8 @@ export function AdminDashboard({ analysisPlayerIds, matchAnalysis, pingAnalytics, + geolocationAnalytics, + readOnly, }: Props) { const [hideNoWinner, setHideNoWinner] = useState(true); const [playersSearch, setPlayersSearch] = useState(""); @@ -735,13 +740,15 @@ export function AdminDashboard({
- - Edit - + {!readOnly ? ( + + Edit + + ) : null} ) : (
diff --git a/src/components/admin-header.tsx b/src/components/admin-header.tsx index 3d663b1..fe0806c 100644 --- a/src/components/admin-header.tsx +++ b/src/components/admin-header.tsx @@ -1,6 +1,10 @@ "use client"; -export function AdminHeader() { +type Props = { + readOnly?: boolean; +}; + +export function AdminHeader({ readOnly = false }: Props) { async function logout() { await fetch("/api/auth/logout", { method: "POST" }); window.location.href = "/login"; @@ -12,6 +16,11 @@ export function AdminHeader() {

Kick Kings Admin Dashboard

+ {readOnly ? ( +

+ Read-only access +

+ ) : null}
- + {!readOnly ? ( + + ) : null}

@@ -999,7 +1003,7 @@ export function AdminLedger({

) : null} - {supplyModalOpen ? ( + {supplyModalOpen && !readOnly ? (
)} + + ); } diff --git a/src/components/admin-settings-editor.tsx b/src/components/admin-settings-editor.tsx index 57250e5..fe903f6 100644 --- a/src/components/admin-settings-editor.tsx +++ b/src/components/admin-settings-editor.tsx @@ -18,8 +18,22 @@ type Props = { rows: DbSetting[]; saveError: boolean; addError: string | null; + readOnly?: boolean; }; +function ReadOnlySettingRow({ row }: { row: DbSetting }) { + return ( +
+

+ {row.key} +

+

+ {row.value?.trim() ? row.value : "—"} +

+
+ ); +} + function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) { const [value, setValue] = useState(row.value ?? ""); const saveDisabled = value.trim() === ""; @@ -271,10 +285,52 @@ export function AdminSettingsEditor({ rows, saveError, addError, + readOnly = false, }: Props) { const [newKey, setNewKey] = useState(""); const [newValue, setNewValue] = useState(""); + if (readOnly) { + return ( +
+
+ Settings are view-only for your account. Contact an admin to make + changes. +
+ +
+

+ Existing keys +

+ {rows.length === 0 ? ( +

+ No settings rows yet. +

+ ) : ( +
+ {rows.map((row) => ( + + ))} +
+ )} +
+ +

+ + Back to dashboard + +

+
+ ); + } + return (
{saveError ? ( diff --git a/src/components/analysis-geolocation-map.tsx b/src/components/analysis-geolocation-map.tsx new file mode 100644 index 0000000..857d051 --- /dev/null +++ b/src/components/analysis-geolocation-map.tsx @@ -0,0 +1,360 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { + ComposableMap, + Geographies, + Geography, + ZoomableGroup, +} from "react-simple-maps"; +import { + countryDisplayName, + heatmapColorForCount, + NO_DATA_FILL, + NO_DATA_FILL_DARK, +} from "@/lib/heatmap-color"; +import type { GeolocationCountryStat } from "@/lib/geolocation-analytics"; + +const GEO_URL = + "https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson"; + +type MapMode = "userAccounts" | "matches"; + +type Props = { + userAccounts: GeolocationCountryStat[]; + matches: GeolocationCountryStat[]; + error: string | null; +}; + +type TooltipState = { + countryCode: string; + x: number; + y: number; +} | null; + +function countryCodeFromGeo(geo: { + properties?: Record; + id?: string | number; +}): string | null { + const props = geo.properties ?? {}; + const candidates = [ + props.ISO_A2, + props.iso_a2, + props.ISO3166_1_Alpha_2, + props["ISO3166-1-Alpha-2"], + geo.id, + ]; + for (const candidate of candidates) { + const code = String(candidate ?? "") + .trim() + .toUpperCase(); + if (/^[A-Z]{2}$/.test(code)) return code; + } + return null; +} + +function buildCountMap(rows: GeolocationCountryStat[]): Map { + const map = new Map(); + for (const row of rows) { + map.set(row.countryCode.toUpperCase(), row); + } + return map; +} + +function countRange(rows: GeolocationCountryStat[]): { + min: number; + max: number; +} { + if (rows.length === 0) return { min: 0, max: 0 }; + const counts = rows.map((r) => r.count); + return { min: Math.min(...counts), max: Math.max(...counts) }; +} + +export function AnalysisGeolocationMap({ + userAccounts, + matches, + error, +}: Props) { + const [mode, setMode] = useState("userAccounts"); + const [tooltip, setTooltip] = useState(null); + + const activeRows = mode === "userAccounts" ? userAccounts : matches; + const countByCountry = useMemo(() => buildCountMap(activeRows), [activeRows]); + const { min: minCount, max: maxCount } = useMemo( + () => countRange(activeRows), + [activeRows], + ); + + const totalCount = activeRows.reduce((acc, row) => acc + row.count, 0); + const countriesWithData = activeRows.length; + + const tooltipStat = tooltip + ? countByCountry.get(tooltip.countryCode.toUpperCase()) + : null; + + return ( +
+
+
+

+ Player geolocation +

+

+ World heatmap by country — blue is fewest, red is most. Gray means no + players in that country. +

+
+
+ + +
+
+ + {error ? ( +
+ Failed to load geolocation data: {error} +
+ ) : null} + +
+ + + 0 + ? `${minCount.toLocaleString("en-US")}–${maxCount.toLocaleString("en-US")}` + : "—" + } + /> +
+ +
+ + + + {({ geographies }) => + geographies.map((geo) => { + const countryCode = countryCodeFromGeo(geo); + const stat = countryCode + ? countByCountry.get(countryCode) + : undefined; + const count = stat?.count ?? 0; + const fill = + count > 0 + ? heatmapColorForCount(count, minCount, maxCount) + : undefined; + + return ( + 0 ? "brightness(1.08)" : "none", + cursor: countryCode ? "pointer" : "default", + }, + pressed: { outline: "none" }, + }} + onMouseEnter={(event) => { + if (!countryCode) return; + setTooltip({ + countryCode, + x: event.clientX, + y: event.clientY, + }); + }} + onMouseMove={(event) => { + if (!countryCode) return; + setTooltip({ + countryCode, + x: event.clientX, + y: event.clientY, + }); + }} + onMouseLeave={() => setTooltip(null)} + /> + ); + }) + } + + + + + {tooltip && tooltipStat ? ( +
+

+ {countryDisplayName(tooltip.countryCode)}{" "} + ({tooltip.countryCode}) +

+

+ {mode === "userAccounts" ? "Users" : "Matches"}:{" "} + + {tooltipStat.count.toLocaleString("en-US")} + +

+ {mode === "matches" && tooltipStat.avgPing != null ? ( +

+ Avg ping:{" "} + + {Math.round(tooltipStat.avgPing).toLocaleString("en-US")} ms + +

+ ) : null} +
+ ) : tooltip ? ( +
+

+ {countryDisplayName(tooltip.countryCode)}{" "} + ({tooltip.countryCode}) +

+

No data

+
+ ) : null} +
+ +
+ Low +
+ High + + + No players + +
+ + {activeRows.length > 0 ? ( +
+ + + + + + {mode === "matches" ? ( + + ) : null} + + + + {activeRows.slice(0, 12).map((row) => ( + + + + {mode === "matches" ? ( + + ) : null} + + ))} + +
+ Country + + {mode === "userAccounts" ? "Users" : "Matches"} + + Avg ping +
+ {countryDisplayName(row.countryCode)}{" "} + + ({row.countryCode}) + + + {row.count.toLocaleString("en-US")} + + {row.avgPing != null + ? `${Math.round(row.avgPing).toLocaleString("en-US")} ms` + : "—"} +
+
+ ) : ( +

+ No geolocation data for this mode and filter. +

+ )} +
+ ); +} + +function StatPill({ label, value }: { label: string; value: string }) { + return ( +
+

+ {label} +

+

+ {value} +

+
+ ); +} diff --git a/src/lib/auth/credentials.ts b/src/lib/auth/credentials.ts index bd8d4ea..a30c026 100644 --- a/src/lib/auth/credentials.ts +++ b/src/lib/auth/credentials.ts @@ -1,4 +1,5 @@ import { timingSafeEqual } from "node:crypto"; +import type { AdminRole } from "@/lib/auth/roles"; function safeEqual(a: string, b: string): boolean { const aBuf = Buffer.from(a); @@ -16,15 +17,36 @@ export function getAdminPassword(): string | null { return password || null; } -export function verifyAdminCredentials( +export function getSupervisorUsername(): string { + return process.env.SUPERVISOR_USERNAME?.trim() || "supervisor"; +} + +export function getSupervisorPassword(): string | null { + const password = process.env.SUPERVISOR_PASSWORD?.trim(); + return password || null; +} + +export function verifyCredentials( username: string, password: string, -): boolean { - const expectedUsername = getAdminUsername(); - const expectedPassword = getAdminPassword(); - if (!expectedPassword) return false; - return ( - safeEqual(username, expectedUsername) && - safeEqual(password, expectedPassword) - ); +): AdminRole | null { + const adminPassword = getAdminPassword(); + if ( + adminPassword && + safeEqual(username, getAdminUsername()) && + safeEqual(password, adminPassword) + ) { + return "admin"; + } + + const supervisorPassword = getSupervisorPassword(); + if ( + supervisorPassword && + safeEqual(username, getSupervisorUsername()) && + safeEqual(password, supervisorPassword) + ) { + return "supervisor"; + } + + return null; } diff --git a/src/lib/auth/require-session.ts b/src/lib/auth/require-session.ts new file mode 100644 index 0000000..5b8609a --- /dev/null +++ b/src/lib/auth/require-session.ts @@ -0,0 +1,30 @@ +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; +import { + isReadOnlyRole, + parseSessionRole, + type AdminRole, +} from "@/lib/auth/roles"; +import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session"; + +export async function getSessionRole(): Promise { + const cookieStore = await cookies(); + return parseSessionRole(cookieStore.get(ADMIN_SESSION_COOKIE)?.value); +} + +export async function requireSession(): Promise { + const role = await getSessionRole(); + if (!role) { + redirect("/login"); + } + return role; +} + +/** For server actions and routes that modify database state. */ +export async function requireWriteAccess(): Promise { + const role = await requireSession(); + if (isReadOnlyRole(role)) { + redirect("/"); + } + return role; +} diff --git a/src/lib/auth/roles.ts b/src/lib/auth/roles.ts new file mode 100644 index 0000000..68b7001 --- /dev/null +++ b/src/lib/auth/roles.ts @@ -0,0 +1,25 @@ +export type AdminRole = "admin" | "supervisor"; + +/** Legacy session cookie value; treated as admin. */ +export const LEGACY_ADMIN_SESSION_VALUE = "1"; + +export function roleToSessionCookieValue(role: AdminRole): string { + return role; +} + +export function parseSessionRole( + value: string | undefined, +): AdminRole | null { + if (!value) return null; + if (value === LEGACY_ADMIN_SESSION_VALUE || value === "admin") { + return "admin"; + } + if (value === "supervisor") { + return "supervisor"; + } + return null; +} + +export function isReadOnlyRole(role: AdminRole): boolean { + return role === "supervisor"; +} diff --git a/src/lib/auth/session-cookie.ts b/src/lib/auth/session-cookie.ts index 6e37e44..abc2c4f 100644 --- a/src/lib/auth/session-cookie.ts +++ b/src/lib/auth/session-cookie.ts @@ -1,6 +1,12 @@ import type { NextResponse } from "next/server"; +import { + roleToSessionCookieValue, + type AdminRole, +} from "@/lib/auth/roles"; import { ADMIN_SESSION_COOKIE, ADMIN_SESSION_MAX_AGE } from "@/lib/auth/session"; +import { getRequestOrigin } from "@/lib/request-public-url"; + /** Use real HTTPS (or X-Forwarded-Proto) — not NODE_ENV — so cookies work on http://. */ function isHttpsRequest(request: Request): boolean { try { @@ -8,10 +14,20 @@ function isHttpsRequest(request: Request): boolean { } catch { /* ignore */ } - const xf = request.headers.get("x-forwarded-proto"); + const xf = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim(); if (xf === "https") return true; if (xf === "http") return false; - return false; + const fwd = request.headers.get("forwarded"); + if (fwd) { + const m = /(?:^|[;,]\s*)proto=(https?)/i.exec(fwd); + if (m?.[1] === "https") return true; + if (m?.[1] === "http") return false; + } + try { + return getRequestOrigin(request).startsWith("https://"); + } catch { + return false; + } } export function getSessionCookieSetOptions(request: Request) { @@ -35,10 +51,14 @@ export function getSessionCookieClearOptions(request: Request) { } /** Attach session cookie to a response (works reliably with redirects). */ -export function applySessionCookie(res: NextResponse, request: Request) { +export function applySessionCookie( + res: NextResponse, + request: Request, + role: AdminRole, +) { res.cookies.set( ADMIN_SESSION_COOKIE, - "1", + roleToSessionCookieValue(role), getSessionCookieSetOptions(request), ); } diff --git a/src/lib/geolocation-analytics.ts b/src/lib/geolocation-analytics.ts new file mode 100644 index 0000000..4f66334 --- /dev/null +++ b/src/lib/geolocation-analytics.ts @@ -0,0 +1,178 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; +import { countryCodeFromIp } from "@/lib/ip-geolocation"; +import type { DbUser } from "@/types/database"; + +export type GeolocationCountryStat = { + countryCode: string; + count: number; + avgPing: number | null; +}; + +export type GeolocationAnalyticsResult = { + userAccounts: GeolocationCountryStat[]; + matches: GeolocationCountryStat[]; + error: string | null; +}; + +const EMPTY_RESULT: GeolocationAnalyticsResult = { + userAccounts: [], + matches: [], + error: null, +}; + +async function resolveUniqueIps( + ips: Iterable, +): Promise> { + const unique = new Set(); + for (const ip of ips) { + const trimmed = (ip ?? "").trim(); + if (trimmed) unique.add(trimmed); + } + + const entries = await Promise.all( + [...unique].map(async (ip) => [ip, await countryCodeFromIp(ip)] as const), + ); + return new Map(entries); +} + +function isValidCountryCode(code: string | null | undefined): code is string { + const cc = (code ?? "").trim().toUpperCase(); + return /^[A-Z]{2}$/.test(cc); +} + +export async function loadUserGeolocationStats( + users: DbUser[], + playerIds: number[], +): Promise { + let filtered = users; + if (playerIds.length > 0) { + const allowed = new Set(playerIds); + filtered = users.filter((u) => allowed.has(u.id)); + } + + const ips = filtered.map((u) => u.ip_address); + const countryByIp = await resolveUniqueIps(ips); + + const byCountry = new Map(); + for (const user of filtered) { + const ip = user.ip_address?.trim(); + if (!ip) continue; + const country = countryByIp.get(ip); + if (!isValidCountryCode(country)) continue; + byCountry.set(country, (byCountry.get(country) ?? 0) + 1); + } + + return [...byCountry.entries()] + .map(([countryCode, count]) => ({ countryCode, count, avgPing: null })) + .sort((a, b) => b.count - a.count || a.countryCode.localeCompare(b.countryCode)); +} + +type PingReportRow = { + match_id: number; + ip_address: string | null; + ping: number; +}; + +export async function loadMatchGeolocationStats( + supabase: SupabaseClient, + from: string, + to: string, + playerIds: number[], +): Promise<{ stats: GeolocationCountryStat[]; error: string | null }> { + const rangeStartIso = `${from}T00:00:00.000Z`; + const rangeEndIso = `${to}T23:59:59.999Z`; + + let query = supabase + .from("ping_reports") + .select("match_id, ip_address, ping") + .gte("created_at", rangeStartIso) + .lte("created_at", rangeEndIso) + .limit(10000); + + if (playerIds.length > 0) { + query = query.in("user_id", playerIds); + } + + const { data, error } = await query; + if (error) { + return { stats: [], error: error.message }; + } + + const rows = (data ?? []) as PingReportRow[]; + const countryByIp = await resolveUniqueIps(rows.map((r) => r.ip_address)); + + const byCountry = new Map< + string, + { matchIds: Set; pings: number[] } + >(); + + for (const row of rows) { + const ip = row.ip_address?.trim(); + if (!ip) continue; + const country = countryByIp.get(ip); + if (!isValidCountryCode(country)) continue; + + const matchId = Number(row.match_id); + const ping = Number(row.ping); + if (!Number.isFinite(matchId) || !Number.isFinite(ping)) continue; + + const state = byCountry.get(country) ?? { + matchIds: new Set(), + pings: [], + }; + state.matchIds.add(matchId); + state.pings.push(ping); + byCountry.set(country, state); + } + + const stats = [...byCountry.entries()] + .map(([countryCode, state]) => { + const pings = state.pings; + const avgPing = + pings.length > 0 + ? pings.reduce((acc, value) => acc + value, 0) / pings.length + : null; + return { + countryCode, + count: state.matchIds.size, + avgPing, + }; + }) + .sort((a, b) => b.count - a.count || a.countryCode.localeCompare(b.countryCode)); + + return { stats, error: null }; +} + +export async function loadGeolocationAnalytics( + supabase: SupabaseClient, + users: DbUser[], + from: string, + to: string, + playerIds: number[], +): Promise { + try { + const [userAccounts, matchResult] = await Promise.all([ + loadUserGeolocationStats(users, playerIds), + loadMatchGeolocationStats(supabase, from, to, playerIds), + ]); + + if (matchResult.error) { + return { + userAccounts, + matches: [], + error: matchResult.error, + }; + } + + return { + userAccounts, + matches: matchResult.stats, + error: null, + }; + } catch (err) { + return { + ...EMPTY_RESULT, + error: err instanceof Error ? err.message : "Failed to load geolocation data", + }; + } +} diff --git a/src/lib/heatmap-color.ts b/src/lib/heatmap-color.ts new file mode 100644 index 0000000..b5fc160 --- /dev/null +++ b/src/lib/heatmap-color.ts @@ -0,0 +1,40 @@ +/** Blue (low) → green (mid) → red (high) heatmap fill for ratio in [0, 1]. */ +export function heatmapColor(ratio: number): string { + const t = Math.max(0, Math.min(1, ratio)); + let hue: number; + if (t <= 0.5) { + hue = 220 + (120 - 220) * (t / 0.5); + } else { + hue = 120 + (0 - 120) * ((t - 0.5) / 0.5); + } + return `hsl(${hue.toFixed(1)}, 68%, 42%)`; +} + +export function heatmapColorForCount( + count: number, + minCount: number, + maxCount: number, +): string { + if (count <= 0 || maxCount <= 0) { + return NO_DATA_FILL; + } + if (maxCount === minCount) { + return heatmapColor(0.5); + } + const ratio = (count - minCount) / (maxCount - minCount); + return heatmapColor(ratio); +} + +export const NO_DATA_FILL = "#d4d4d8"; +export const NO_DATA_FILL_DARK = "#3f3f46"; + +export function countryDisplayName(countryCode: string): string { + try { + const name = new Intl.DisplayNames(["en"], { type: "region" }).of( + countryCode, + ); + return name ?? countryCode; + } catch { + return countryCode; + } +} diff --git a/src/lib/request-public-url.ts b/src/lib/request-public-url.ts index cdd22f0..b913a44 100644 --- a/src/lib/request-public-url.ts +++ b/src/lib/request-public-url.ts @@ -34,6 +34,20 @@ function normalizeMergedComLoginHost(hostWithOptionalPort: string): string { return fixed + portPart; } +function isLocalHost(host: string): boolean { + const h = host.split(":")[0]?.replace(/^\[|\]$/g, "").toLowerCase() ?? ""; + return h === "localhost" || h === "127.0.0.1" || h === "::1"; +} + +/** Infer HTTPS when TLS terminates at a reverse proxy that omits X-Forwarded-Proto. */ +function defaultProtoForHost(host: string, requestProtocol: string): "http" | "https" { + if (requestProtocol === "https:") return "https"; + if (process.env.NODE_ENV === "production" && host && !isLocalHost(host)) { + return "https"; + } + return "http"; +} + function parseForwardedHeader(value: string | null): { host?: string; proto?: string; @@ -83,7 +97,9 @@ export function getRequestOrigin(request: Request): string { const xfProto = request.headers.get("x-forwarded-proto"); const protoRaw = firstUrlLine( - fwd.proto ?? xfProto?.split(",")[0]?.trim() ?? (url.protocol === "https:" ? "https" : "http"), + fwd.proto ?? + xfProto?.split(",")[0]?.trim() ?? + defaultProtoForHost(hostRaw, url.protocol), ); const proto = protoRaw === "https" || protoRaw === "http" ? protoRaw : "http"; diff --git a/src/proxy.ts b/src/proxy.ts index 9a44667..a5dcbc9 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1,10 +1,11 @@ import { NextResponse } from "next/server"; import type { NextRequest } from "next/server"; +import { parseSessionRole } from "@/lib/auth/roles"; import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session"; import { publicRequestUrl } from "@/lib/request-public-url"; function hasValidSession(request: NextRequest): boolean { - return request.cookies.get(ADMIN_SESSION_COOKIE)?.value === "1"; + return parseSessionRole(request.cookies.get(ADMIN_SESSION_COOKIE)?.value) != null; } export function proxy(request: NextRequest) {