This commit is contained in:
NextJS
2026-07-12 17:55:44 +00:00
parent 4ed4a10eae
commit e324a95625
28 changed files with 1087 additions and 114 deletions
+4
View File
@@ -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=
+207 -13
View File
@@ -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"
}
+3 -1
View File
@@ -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",
+2 -6
View File
@@ -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 =
+4 -14
View File
@@ -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) {
+2 -6
View File
@@ -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") ?? "");
+8 -4
View File
@@ -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;
}
+3 -4
View File
@@ -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 });
}
+3 -4
View File
@@ -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 },
+2 -6
View File
@@ -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);
+3 -7
View File
@@ -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);
+2 -7
View File
@@ -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 =
+27 -3
View File
@@ -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<ReturnType<typeof loadDashboardStatsBundle>> | 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 (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
<AdminHeader />
<AdminHeader readOnly={readOnly} />
{configError ? (
<div className="px-6 pt-8">
<div
@@ -335,6 +357,8 @@ export default async function Home({
analysisPlayerIds={analysisPlayerIds}
matchAnalysis={matchAnalysis}
pingAnalytics={pingAnalytics}
geolocationAnalytics={geolocationAnalytics}
readOnly={readOnly}
/>
{editUser ? (
<EditUserCcRcOverlay
+6 -8
View File
@@ -1,9 +1,8 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import { requireSession } from "@/lib/auth/require-session";
import { isReadOnlyRole } from "@/lib/auth/roles";
import { AdminHeader } from "@/components/admin-header";
import { AdminSettingsEditor } from "@/components/admin-settings-editor";
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
import { createAdminSupabase } from "@/lib/supabase/admin";
import type { DbSetting } from "@/types/database";
@@ -31,10 +30,8 @@ export default async function SettingsPage({
addError?: string | string[];
}>;
}) {
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 (
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
<AdminHeader />
<AdminHeader readOnly={readOnly} />
<main className="flex-1 px-6 py-8">
<div className="mx-auto mb-8 max-w-[900px]">
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
@@ -96,6 +93,7 @@ export default async function SettingsPage({
rows={rows}
saveError={saveError}
addError={addError}
readOnly={readOnly}
/>
)}
</main>
+16 -7
View File
@@ -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({
</td>
<td className="px-4 py-2 whitespace-nowrap">
<div className="flex flex-wrap items-center gap-2">
<Link
href={editHref(u)}
scroll={false}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Edit
</Link>
{!readOnly ? (
<Link
href={editHref(u)}
scroll={false}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
>
Edit
</Link>
) : null}
<Link
href={`/?tab=matches&participant=${u.id}`}
className="rounded-md border border-zinc-300 bg-white px-3 py-1.5 text-xs font-medium text-zinc-800 shadow-sm transition hover:bg-zinc-50 dark:border-zinc-600 dark:bg-zinc-950 dark:text-zinc-100 dark:hover:bg-zinc-800"
@@ -806,6 +813,7 @@ export function AdminDashboard({
<AdminMatchAnalysis
analysis={matchAnalysis}
pingAnalytics={pingAnalytics}
geolocationAnalytics={geolocationAnalytics}
users={users}
analysisFrom={analysisFrom}
analysisTo={analysisTo}
@@ -831,6 +839,7 @@ export function AdminDashboard({
ledgerFrom={ledgerFrom}
ledgerTo={ledgerTo}
supplyError={supplyError}
readOnly={readOnly}
/>
) : (
<section className="space-y-3">
+10 -1
View File
@@ -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() {
<h1 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
Kick Kings Admin Dashboard
</h1>
{readOnly ? (
<p className="mt-0.5 text-xs font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
Read-only access
</p>
) : null}
</div>
<button
type="button"
+12 -8
View File
@@ -91,6 +91,7 @@ type Props = {
ledgerFrom: string;
ledgerTo: string;
supplyError: boolean;
readOnly: boolean;
};
export function AdminLedger({
@@ -110,6 +111,7 @@ export function AdminLedger({
ledgerFrom,
ledgerTo,
supplyError,
readOnly,
}: Props) {
const usernameById = useMemo(() => {
const m = new Map<number, string | null>();
@@ -316,13 +318,15 @@ export function AdminLedger({
All transactions
</p>
</div>
<button
type="button"
onClick={() => setSupplyModalOpen(true)}
className="shrink-0 rounded-md border border-zinc-300 bg-white/90 px-2.5 py-1 text-xs font-medium text-zinc-800 shadow-sm hover:bg-white dark:border-zinc-600 dark:bg-zinc-900/90 dark:text-zinc-100 dark:hover:bg-zinc-900"
>
Add supply
</button>
{!readOnly ? (
<button
type="button"
onClick={() => setSupplyModalOpen(true)}
className="shrink-0 rounded-md border border-zinc-300 bg-white/90 px-2.5 py-1 text-xs font-medium text-zinc-800 shadow-sm hover:bg-white dark:border-zinc-600 dark:bg-zinc-900/90 dark:text-zinc-100 dark:hover:bg-zinc-900"
>
Add supply
</button>
) : null}
</div>
<div className="mt-2 text-sm tabular-nums text-zinc-900 dark:text-zinc-100">
<p className="font-mono">
@@ -999,7 +1003,7 @@ export function AdminLedger({
</div>
) : null}
{supplyModalOpen ? (
{supplyModalOpen && !readOnly ? (
<div
className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/40 p-4"
role="presentation"
+10
View File
@@ -5,8 +5,10 @@ import { useMemo, useState } from "react";
import { ClickableUserId } from "@/components/clickable-user-id";
import { AnalysisMatchAlertBox } from "@/components/analysis-match-alert-box";
import { MatchAnalysisEmoteChart } from "@/components/match-analysis-emote-chart";
import { AnalysisGeolocationMap } from "@/components/analysis-geolocation-map";
import { MatchAnalysisForceChart } from "@/components/match-analysis-force-chart";
import { buildDashboardHref } from "@/lib/dashboard-search-url";
import type { GeolocationAnalyticsResult } from "@/lib/geolocation-analytics";
import type { MatchLogAnalysisResult, NumericAggregate } from "@/lib/match-log-parser";
import type { PingAnalyticsResult, PingGroupRow } from "@/lib/ping-analytics";
import type { DbUser } from "@/types/database";
@@ -95,6 +97,7 @@ function Stat({ label, value }: { label: string; value: string }) {
type Props = {
analysis: MatchLogAnalysisResult;
pingAnalytics: PingAnalyticsResult;
geolocationAnalytics: GeolocationAnalyticsResult;
users: DbUser[];
analysisFrom: string;
analysisTo: string;
@@ -106,6 +109,7 @@ type Props = {
export function AdminMatchAnalysis({
analysis,
pingAnalytics,
geolocationAnalytics,
users,
analysisFrom,
analysisTo,
@@ -554,6 +558,12 @@ export function AdminMatchAnalysis({
</>
)}
</section>
<AnalysisGeolocationMap
userAccounts={geolocationAnalytics.userAccounts}
matches={geolocationAnalytics.matches}
error={geolocationAnalytics.error}
/>
</section>
);
}
+56
View File
@@ -18,8 +18,22 @@ type Props = {
rows: DbSetting[];
saveError: boolean;
addError: string | null;
readOnly?: boolean;
};
function ReadOnlySettingRow({ row }: { row: DbSetting }) {
return (
<div className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<p className="font-mono text-sm text-zinc-900 dark:text-zinc-100">
{row.key}
</p>
<p className="mt-2 break-all text-sm text-zinc-700 dark:text-zinc-300">
{row.value?.trim() ? row.value : "—"}
</p>
</div>
);
}
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 (
<div className="mx-auto w-full max-w-[900px] space-y-8">
<div
className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-950 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100"
role="status"
>
Settings are view-only for your account. Contact an admin to make
changes.
</div>
<section className="space-y-3">
<h2 className="text-sm font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Existing keys
</h2>
{rows.length === 0 ? (
<p className="rounded-xl border border-zinc-200 bg-white px-4 py-8 text-center text-sm text-zinc-500 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400">
No settings rows yet.
</p>
) : (
<div className="space-y-3">
{rows.map((row) => (
<ReadOnlySettingRow key={row.key} row={row} />
))}
</div>
)}
</section>
<p className="text-center text-xs text-zinc-500 dark:text-zinc-400">
<Link
href="/"
className="font-medium text-sky-700 underline decoration-sky-400/60 underline-offset-2 hover:text-sky-900 dark:text-sky-300 dark:hover:text-sky-100"
scroll={false}
>
Back to dashboard
</Link>
</p>
</div>
);
}
return (
<div className="mx-auto w-full max-w-[900px] space-y-8">
{saveError ? (
+360
View File
@@ -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<string, unknown>;
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<string, GeolocationCountryStat> {
const map = new Map<string, GeolocationCountryStat>();
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<MapMode>("userAccounts");
const [tooltip, setTooltip] = useState<TooltipState>(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 (
<section className="space-y-4 rounded-xl border border-zinc-200 bg-white p-4 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-xs font-semibold uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
Player geolocation
</h3>
<p className="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
World heatmap by country blue is fewest, red is most. Gray means no
players in that country.
</p>
</div>
<div className="inline-flex items-center gap-1 rounded-md border border-zinc-300 bg-white p-1 dark:border-zinc-700 dark:bg-zinc-900">
<button
type="button"
onClick={() => setMode("userAccounts")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
mode === "userAccounts"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
User accounts
</button>
<button
type="button"
onClick={() => setMode("matches")}
className={[
"rounded px-2.5 py-1 text-xs font-medium transition",
mode === "matches"
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "text-zinc-700 hover:bg-zinc-100 dark:text-zinc-300 dark:hover:bg-zinc-800",
].join(" ")}
>
Matches
</button>
</div>
</div>
{error ? (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-100">
Failed to load geolocation data: {error}
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-3">
<StatPill
label={mode === "userAccounts" ? "Users mapped" : "Matches mapped"}
value={totalCount.toLocaleString("en-US")}
/>
<StatPill
label="Countries"
value={countriesWithData.toLocaleString("en-US")}
/>
<StatPill
label="Range per country"
value={
maxCount > 0
? `${minCount.toLocaleString("en-US")}${maxCount.toLocaleString("en-US")}`
: "—"
}
/>
</div>
<div className="relative overflow-hidden rounded-lg border border-zinc-200 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-950/40">
<ComposableMap
projection="geoEqualEarth"
projectionConfig={{ scale: 145 }}
width={800}
height={420}
style={{ width: "100%", height: "auto" }}
>
<ZoomableGroup center={[0, 10]} zoom={1} minZoom={1} maxZoom={4}>
<Geographies geography={GEO_URL}>
{({ 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 (
<Geography
key={geo.rsmKey}
geography={geo}
fill={fill ?? NO_DATA_FILL}
stroke="#a1a1aa"
strokeWidth={0.25}
style={{
default: {
outline: "none",
transition: "fill 150ms ease",
},
hover: {
outline: "none",
fill:
fill ??
(typeof document !== "undefined" &&
document.documentElement.classList.contains("dark")
? NO_DATA_FILL_DARK
: NO_DATA_FILL),
filter: count > 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)}
/>
);
})
}
</Geographies>
</ZoomableGroup>
</ComposableMap>
{tooltip && tooltipStat ? (
<div
className="pointer-events-none fixed z-50 rounded-md border border-zinc-200 bg-white px-3 py-2 text-xs shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
style={{
left: tooltip.x + 12,
top: tooltip.y + 12,
}}
>
<p className="font-semibold text-zinc-900 dark:text-zinc-100">
{countryDisplayName(tooltip.countryCode)}{" "}
<span className="font-mono text-zinc-500">({tooltip.countryCode})</span>
</p>
<p className="mt-1 text-zinc-700 dark:text-zinc-300">
{mode === "userAccounts" ? "Users" : "Matches"}:{" "}
<span className="font-mono tabular-nums">
{tooltipStat.count.toLocaleString("en-US")}
</span>
</p>
{mode === "matches" && tooltipStat.avgPing != null ? (
<p className="text-zinc-700 dark:text-zinc-300">
Avg ping:{" "}
<span className="font-mono tabular-nums">
{Math.round(tooltipStat.avgPing).toLocaleString("en-US")} ms
</span>
</p>
) : null}
</div>
) : tooltip ? (
<div
className="pointer-events-none fixed z-50 rounded-md border border-zinc-200 bg-white px-3 py-2 text-xs shadow-lg dark:border-zinc-700 dark:bg-zinc-900"
style={{
left: tooltip.x + 12,
top: tooltip.y + 12,
}}
>
<p className="font-semibold text-zinc-900 dark:text-zinc-100">
{countryDisplayName(tooltip.countryCode)}{" "}
<span className="font-mono text-zinc-500">({tooltip.countryCode})</span>
</p>
<p className="mt-1 text-zinc-500">No data</p>
</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-zinc-600 dark:text-zinc-400">
<span>Low</span>
<div
className="h-2.5 flex-1 min-w-[8rem] rounded-full"
style={{
background:
"linear-gradient(to right, hsl(220, 68%, 42%), hsl(120, 68%, 42%), hsl(0, 68%, 42%))",
}}
/>
<span>High</span>
<span className="inline-flex items-center gap-1.5">
<span
className="inline-block h-3 w-3 rounded-sm border border-zinc-300 dark:border-zinc-600"
style={{ backgroundColor: NO_DATA_FILL }}
/>
No players
</span>
</div>
{activeRows.length > 0 ? (
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-700">
<table className="min-w-full text-left text-xs">
<thead className="bg-zinc-50 text-zinc-500 dark:bg-zinc-950/60 dark:text-zinc-400">
<tr>
<th className="px-3 py-2 font-semibold uppercase tracking-wide">
Country
</th>
<th className="px-3 py-2 font-semibold uppercase tracking-wide">
{mode === "userAccounts" ? "Users" : "Matches"}
</th>
{mode === "matches" ? (
<th className="px-3 py-2 font-semibold uppercase tracking-wide">
Avg ping
</th>
) : null}
</tr>
</thead>
<tbody>
{activeRows.slice(0, 12).map((row) => (
<tr
key={row.countryCode}
className="border-t border-zinc-200 dark:border-zinc-800"
>
<td className="px-3 py-2 text-zinc-800 dark:text-zinc-200">
{countryDisplayName(row.countryCode)}{" "}
<span className="font-mono text-zinc-500">
({row.countryCode})
</span>
</td>
<td className="px-3 py-2 font-mono tabular-nums">
{row.count.toLocaleString("en-US")}
</td>
{mode === "matches" ? (
<td className="px-3 py-2 font-mono tabular-nums">
{row.avgPing != null
? `${Math.round(row.avgPing).toLocaleString("en-US")} ms`
: "—"}
</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="text-sm text-zinc-500 dark:text-zinc-400">
No geolocation data for this mode and filter.
</p>
)}
</section>
);
}
function StatPill({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-lg border border-zinc-200 bg-zinc-50/60 px-3 py-2 dark:border-zinc-700 dark:bg-zinc-950/40">
<p className="text-[11px] font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
{label}
</p>
<p className="mt-0.5 font-mono text-sm font-semibold tabular-nums text-zinc-900 dark:text-zinc-100">
{value}
</p>
</div>
);
}
+31 -9
View File
@@ -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;
}
+30
View File
@@ -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<AdminRole | null> {
const cookieStore = await cookies();
return parseSessionRole(cookieStore.get(ADMIN_SESSION_COOKIE)?.value);
}
export async function requireSession(): Promise<AdminRole> {
const role = await getSessionRole();
if (!role) {
redirect("/login");
}
return role;
}
/** For server actions and routes that modify database state. */
export async function requireWriteAccess(): Promise<AdminRole> {
const role = await requireSession();
if (isReadOnlyRole(role)) {
redirect("/");
}
return role;
}
+25
View File
@@ -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";
}
+24 -4
View File
@@ -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),
);
}
+178
View File
@@ -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<string | null | undefined>,
): Promise<Map<string, string | null>> {
const unique = new Set<string>();
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<GeolocationCountryStat[]> {
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<string, number>();
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<number>; 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<number>(),
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<GeolocationAnalyticsResult> {
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",
};
}
}
+40
View File
@@ -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;
}
}
+17 -1
View File
@@ -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";
+2 -1
View File
@@ -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) {