sync
This commit is contained in:
@@ -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=
|
||||
|
||||
Generated
+207
-13
@@ -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
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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") ?? "");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,6 +740,7 @@ export function AdminDashboard({
|
||||
</td>
|
||||
<td className="px-4 py-2 whitespace-nowrap">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{!readOnly ? (
|
||||
<Link
|
||||
href={editHref(u)}
|
||||
scroll={false}
|
||||
@@ -742,6 +748,7 @@ export function AdminDashboard({
|
||||
>
|
||||
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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,6 +318,7 @@ export function AdminLedger({
|
||||
All transactions
|
||||
</p>
|
||||
</div>
|
||||
{!readOnly ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSupplyModalOpen(true)}
|
||||
@@ -323,6 +326,7 @@ export function AdminLedger({
|
||||
>
|
||||
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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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,11 +14,21 @@ 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;
|
||||
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) {
|
||||
return {
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user