sync
This commit is contained in:
@@ -2,6 +2,10 @@
|
|||||||
ADMIN_USERNAME=admin
|
ADMIN_USERNAME=admin
|
||||||
ADMIN_PASSWORD=change-me-to-a-strong-password
|
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).
|
# Optional: public site URL when reverse proxy does not send X-Forwarded-* (fixes post-login redirects).
|
||||||
# Example: https://kickkings.playpoolstudios.com
|
# Example: https://kickkings.playpoolstudios.com
|
||||||
# APP_ORIGIN=
|
# APP_ORIGIN=
|
||||||
|
|||||||
Generated
+207
-13
@@ -11,13 +11,15 @@
|
|||||||
"@supabase/supabase-js": "^2.102.1",
|
"@supabase/supabase-js": "^2.102.1",
|
||||||
"next": "^16.2.4",
|
"next": "^16.2.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"react-simple-maps": "^3.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/react-simple-maps": "^3.0.6",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.3",
|
"eslint-config-next": "16.2.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
@@ -68,7 +70,6 @@
|
|||||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/code-frame": "^7.29.0",
|
"@babel/code-frame": "^7.29.0",
|
||||||
"@babel/generator": "^7.29.0",
|
"@babel/generator": "^7.29.0",
|
||||||
@@ -1612,6 +1613,51 @@
|
|||||||
"tslib": "^2.4.0"
|
"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": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@@ -1619,6 +1665,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"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==",
|
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.2.2"
|
"csstype": "^3.2.2"
|
||||||
}
|
}
|
||||||
@@ -1663,6 +1715,19 @@
|
|||||||
"@types/react": "^19.2.0"
|
"@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": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -1717,7 +1782,6 @@
|
|||||||
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
|
"integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.58.1",
|
"@typescript-eslint/scope-manager": "8.58.1",
|
||||||
"@typescript-eslint/types": "8.58.1",
|
"@typescript-eslint/types": "8.58.1",
|
||||||
@@ -2243,7 +2307,6 @@
|
|||||||
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -2587,7 +2650,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.10.12",
|
"baseline-browser-mapping": "^2.10.12",
|
||||||
"caniuse-lite": "^1.0.30001782",
|
"caniuse-lite": "^1.0.30001782",
|
||||||
@@ -2725,6 +2787,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/concat-map": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||||
@@ -2761,6 +2829,102 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/damerau-levenshtein": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
|
||||||
@@ -3156,7 +3320,6 @@
|
|||||||
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.8.0",
|
"@eslint-community/eslint-utils": "^4.8.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -3342,7 +3505,6 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -4065,6 +4227,12 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"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",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -5535,7 +5702,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"scheduler": "^0.27.0"
|
"scheduler": "^0.27.0"
|
||||||
},
|
},
|
||||||
@@ -5550,6 +5716,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/reflect.getprototypeof": {
|
||||||
"version": "1.0.10",
|
"version": "1.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
|
||||||
@@ -6227,7 +6410,6 @@
|
|||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -6248,6 +6430,20 @@
|
|||||||
"node": ">=8.0"
|
"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": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@@ -6390,7 +6586,6 @@
|
|||||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -6686,7 +6881,6 @@
|
|||||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -12,13 +12,15 @@
|
|||||||
"@supabase/supabase-js": "^2.102.1",
|
"@supabase/supabase-js": "^2.102.1",
|
||||||
"next": "^16.2.4",
|
"next": "^16.2.4",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"react-simple-maps": "^3.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@types/react-simple-maps": "^3.0.6",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.2.3",
|
"eslint-config-next": "16.2.3",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
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 { applyCoinsDeltaToRcBalance } from "@/lib/coins-rc";
|
||||||
import {
|
import {
|
||||||
buildDashboardHref,
|
buildDashboardHref,
|
||||||
@@ -25,10 +24,7 @@ function parsePositiveCoins(raw: FormDataEntryValue | null): bigint | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function addSystemSupply(formData: FormData) {
|
export async function addSystemSupply(formData: FormData) {
|
||||||
const cookieStore = await cookies();
|
await requireWriteAccess();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabRaw = String(formData.get("tab") ?? "");
|
const tabRaw = String(formData.get("tab") ?? "");
|
||||||
const tab: AdminDashboardTab =
|
const tab: AdminDashboardTab =
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
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 { parseStoredCoins } from "@/lib/coins-rc";
|
||||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||||
|
|
||||||
@@ -38,10 +37,7 @@ function normalizeSettingValue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateSetting(formData: FormData) {
|
export async function updateSetting(formData: FormData) {
|
||||||
const cookieStore = await cookies();
|
await requireWriteAccess();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = String(formData.get("key") ?? "").trim();
|
const key = String(formData.get("key") ?? "").trim();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
@@ -71,10 +67,7 @@ export async function updateSetting(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function insertSetting(formData: FormData) {
|
export async function insertSetting(formData: FormData) {
|
||||||
const cookieStore = await cookies();
|
await requireWriteAccess();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = String(formData.get("newKey") ?? "").trim();
|
const key = String(formData.get("newKey") ?? "").trim();
|
||||||
const value = normalizeValue(formData.get("newValue"));
|
const value = normalizeValue(formData.get("newValue"));
|
||||||
@@ -102,10 +95,7 @@ export async function insertSetting(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSetting(formData: FormData) {
|
export async function deleteSetting(formData: FormData) {
|
||||||
const cookieStore = await cookies();
|
await requireWriteAccess();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const key = String(formData.get("key") ?? "").trim();
|
const key = String(formData.get("key") ?? "").trim();
|
||||||
if (!key) {
|
if (!key) {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
import { requireWriteAccess } from "@/lib/auth/require-session";
|
||||||
import {
|
import {
|
||||||
buildDashboardHref,
|
buildDashboardHref,
|
||||||
type AdminDashboardTab,
|
type AdminDashboardTab,
|
||||||
@@ -19,10 +18,7 @@ function parseScoreField(raw: FormDataEntryValue | null): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUserCcRc(formData: FormData) {
|
export async function updateUserCcRc(formData: FormData) {
|
||||||
const cookieStore = await cookies();
|
await requireWriteAccess();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const userId = Number(formData.get("userId"));
|
const userId = Number(formData.get("userId"));
|
||||||
const tabRaw = String(formData.get("tab") ?? "");
|
const tabRaw = String(formData.get("tab") ?? "");
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { getClientIp } from "@/lib/auth/client-ip";
|
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 {
|
import {
|
||||||
checkLoginRateLimit,
|
checkLoginRateLimit,
|
||||||
clearLoginAttempts,
|
clearLoginAttempts,
|
||||||
@@ -54,7 +57,8 @@ export async function POST(request: Request) {
|
|||||||
password = String(formData.get("password") ?? "");
|
password = String(formData.get("password") ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!verifyAdminCredentials(username, password)) {
|
const role = verifyCredentials(username, password);
|
||||||
|
if (!role) {
|
||||||
recordFailedLogin(clientIp);
|
recordFailedLogin(clientIp);
|
||||||
if (contentType.includes("application/json")) {
|
if (contentType.includes("application/json")) {
|
||||||
return invalidJson();
|
return invalidJson();
|
||||||
@@ -68,13 +72,13 @@ export async function POST(request: Request) {
|
|||||||
const res = NextResponse.json({ ok: true });
|
const res = NextResponse.json({ ok: true });
|
||||||
res.cookies.set(
|
res.cookies.set(
|
||||||
ADMIN_SESSION_COOKIE,
|
ADMIN_SESSION_COOKIE,
|
||||||
"1",
|
roleToSessionCookieValue(role),
|
||||||
getSessionCookieSetOptions(request),
|
getSessionCookieSetOptions(request),
|
||||||
);
|
);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = NextResponse.redirect(publicRequestUrl(request, "/"));
|
const res = NextResponse.redirect(publicRequestUrl(request, "/"));
|
||||||
applySessionCookie(res, request);
|
applySessionCookie(res, request, role);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { cookies } from "next/headers";
|
import { getSessionRole } from "@/lib/auth/require-session";
|
||||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
|
||||||
import {
|
import {
|
||||||
parseMatchIdParam,
|
parseMatchIdParam,
|
||||||
readMatchLogFile,
|
readMatchLogFile,
|
||||||
@@ -9,8 +8,8 @@ export async function GET(
|
|||||||
_request: Request,
|
_request: Request,
|
||||||
context: { params: Promise<{ matchId: string }> },
|
context: { params: Promise<{ matchId: string }> },
|
||||||
) {
|
) {
|
||||||
const cookieStore = await cookies();
|
const role = await getSessionRole();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
if (!role) {
|
||||||
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
return Response.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
import { cookies } from "next/headers";
|
import { getSessionRole } from "@/lib/auth/require-session";
|
||||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
|
||||||
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
|
import { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
|
||||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||||
|
|
||||||
const NO_STORE = { "Cache-Control": "no-store, max-age=0" };
|
const NO_STORE = { "Cache-Control": "no-store, max-age=0" };
|
||||||
|
|
||||||
export async function GET(request: Request) {
|
export async function GET(request: Request) {
|
||||||
const cookieStore = await cookies();
|
const role = await getSessionRole();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
if (!role) {
|
||||||
return Response.json(
|
return Response.json(
|
||||||
{ error: "Unauthorized" },
|
{ error: "Unauthorized" },
|
||||||
{ status: 401, headers: NO_STORE },
|
{ status: 401, headers: NO_STORE },
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Fragment } from "react";
|
import { Fragment } from "react";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { AdminHeader } from "@/components/admin-header";
|
import { AdminHeader } from "@/components/admin-header";
|
||||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
import { requireSession } from "@/lib/auth/require-session";
|
||||||
import {
|
import {
|
||||||
formatRcDecimalFromCoinsBigInt,
|
formatRcDecimalFromCoinsBigInt,
|
||||||
formatRcLabelFromCoinsBigInt,
|
formatRcLabelFromCoinsBigInt,
|
||||||
@@ -58,10 +57,7 @@ export default async function LedgerBookPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ from?: string | string[]; to?: string | string[] }>;
|
searchParams: Promise<{ from?: string | string[]; to?: string | string[] }>;
|
||||||
}) {
|
}) {
|
||||||
const cookieStore = await cookies();
|
await requireSession();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
let fromStr = firstSearchParam(sp.from);
|
let fromStr = firstSearchParam(sp.from);
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { cookies } from "next/headers";
|
import { notFound } from "next/navigation";
|
||||||
import { notFound, redirect } from "next/navigation";
|
import { requireSession } from "@/lib/auth/require-session";
|
||||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
|
||||||
import { MatchLogColoredBody } from "@/components/match-log-colored-body";
|
import { MatchLogColoredBody } from "@/components/match-log-colored-body";
|
||||||
import {
|
import {
|
||||||
parseMatchIdParam,
|
parseMatchIdParam,
|
||||||
@@ -25,10 +24,7 @@ export default async function MatchLogPage({
|
|||||||
}: {
|
}: {
|
||||||
params: Promise<{ matchId: string }>;
|
params: Promise<{ matchId: string }>;
|
||||||
}) {
|
}) {
|
||||||
const cookieStore = await cookies();
|
await requireSession();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const { matchId: raw } = await params;
|
const { matchId: raw } = await params;
|
||||||
const matchId = parseMatchIdParam(raw);
|
const matchId = parseMatchIdParam(raw);
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { redirect } from "next/navigation";
|
|
||||||
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
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 { parseMatchmakerLogParam } from "@/lib/matchmaker-log-source";
|
||||||
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
import { readMatchmakerLogFile } from "@/lib/matchmaker-logs-server";
|
||||||
|
|
||||||
@@ -17,10 +15,7 @@ export default async function MatchmakerLogsPage({
|
|||||||
}: {
|
}: {
|
||||||
searchParams: Promise<{ mklog?: string | string[] }>;
|
searchParams: Promise<{ mklog?: string | string[] }>;
|
||||||
}) {
|
}) {
|
||||||
const cookieStore = await cookies();
|
await requireSession();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const raw =
|
const raw =
|
||||||
|
|||||||
+27
-3
@@ -1,6 +1,8 @@
|
|||||||
import { AdminDashboard } from "@/components/admin-dashboard";
|
import { AdminDashboard } from "@/components/admin-dashboard";
|
||||||
import { AdminHeader } from "@/components/admin-header";
|
import { AdminHeader } from "@/components/admin-header";
|
||||||
import { EditUserCcRcOverlay } from "@/components/edit-user-cc-rc-overlay";
|
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 { loadDashboardStatsBundle } from "@/lib/dashboard-stats";
|
||||||
import {
|
import {
|
||||||
normalizeLedgerDateRange,
|
normalizeLedgerDateRange,
|
||||||
@@ -25,6 +27,10 @@ import {
|
|||||||
} from "@/lib/match-log-analysis-server";
|
} from "@/lib/match-log-analysis-server";
|
||||||
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
|
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
|
||||||
import { emptyMatchLogAnalysisResult } from "@/lib/match-log-parser";
|
import { emptyMatchLogAnalysisResult } from "@/lib/match-log-parser";
|
||||||
|
import {
|
||||||
|
loadGeolocationAnalytics,
|
||||||
|
type GeolocationAnalyticsResult,
|
||||||
|
} from "@/lib/geolocation-analytics";
|
||||||
import {
|
import {
|
||||||
loadPingAnalytics,
|
loadPingAnalytics,
|
||||||
type PingAnalyticsResult,
|
type PingAnalyticsResult,
|
||||||
@@ -78,6 +84,9 @@ export default async function Home({
|
|||||||
aplayers?: string | string[];
|
aplayers?: string | string[];
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
|
const role = await requireSession();
|
||||||
|
const readOnly = isReadOnlyRole(role);
|
||||||
|
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const tabParam = firstSearchParam(sp.tab);
|
const tabParam = firstSearchParam(sp.tab);
|
||||||
const tab: AdminDashboardTab =
|
const tab: AdminDashboardTab =
|
||||||
@@ -138,6 +147,11 @@ export default async function Home({
|
|||||||
byCountries: [],
|
byCountries: [],
|
||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
let geolocationAnalytics: GeolocationAnalyticsResult = {
|
||||||
|
userAccounts: [],
|
||||||
|
matches: [],
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null =
|
let statsBundle: Awaited<ReturnType<typeof loadDashboardStatsBundle>> | null =
|
||||||
null;
|
null;
|
||||||
|
|
||||||
@@ -272,7 +286,7 @@ export default async function Home({
|
|||||||
analysisFrom = range.from;
|
analysisFrom = range.from;
|
||||||
analysisTo = range.to;
|
analysisTo = range.to;
|
||||||
analysisPlayerIds = parseAnalysisPlayerIds(sp.aplayers);
|
analysisPlayerIds = parseAnalysisPlayerIds(sp.aplayers);
|
||||||
const [matchLogResult, pingResult] = await Promise.all([
|
const [matchLogResult, pingResult, geoResult] = await Promise.all([
|
||||||
analyzeMatchLogsForMatches(
|
analyzeMatchLogsForMatches(
|
||||||
matches,
|
matches,
|
||||||
analysisFrom,
|
analysisFrom,
|
||||||
@@ -280,20 +294,28 @@ export default async function Home({
|
|||||||
analysisPlayerIds,
|
analysisPlayerIds,
|
||||||
),
|
),
|
||||||
loadPingAnalytics(supabase, analysisFrom, analysisTo, analysisPlayerIds),
|
loadPingAnalytics(supabase, analysisFrom, analysisTo, analysisPlayerIds),
|
||||||
|
loadGeolocationAnalytics(
|
||||||
|
supabase,
|
||||||
|
users,
|
||||||
|
analysisFrom,
|
||||||
|
analysisTo,
|
||||||
|
analysisPlayerIds,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
matchAnalysis = matchLogResult;
|
matchAnalysis = matchLogResult;
|
||||||
pingAnalytics = pingResult;
|
pingAnalytics = pingResult;
|
||||||
|
geolocationAnalytics = geoResult;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let editUser: DbUser | null = null;
|
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;
|
editUser = users.find((u) => Number(u.id) === editIdNum) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
|
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
|
||||||
<AdminHeader />
|
<AdminHeader readOnly={readOnly} />
|
||||||
{configError ? (
|
{configError ? (
|
||||||
<div className="px-6 pt-8">
|
<div className="px-6 pt-8">
|
||||||
<div
|
<div
|
||||||
@@ -335,6 +357,8 @@ export default async function Home({
|
|||||||
analysisPlayerIds={analysisPlayerIds}
|
analysisPlayerIds={analysisPlayerIds}
|
||||||
matchAnalysis={matchAnalysis}
|
matchAnalysis={matchAnalysis}
|
||||||
pingAnalytics={pingAnalytics}
|
pingAnalytics={pingAnalytics}
|
||||||
|
geolocationAnalytics={geolocationAnalytics}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
{editUser ? (
|
{editUser ? (
|
||||||
<EditUserCcRcOverlay
|
<EditUserCcRcOverlay
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { cookies } from "next/headers";
|
import { requireSession } from "@/lib/auth/require-session";
|
||||||
import { redirect } from "next/navigation";
|
import { isReadOnlyRole } from "@/lib/auth/roles";
|
||||||
import { AdminHeader } from "@/components/admin-header";
|
import { AdminHeader } from "@/components/admin-header";
|
||||||
import { AdminSettingsEditor } from "@/components/admin-settings-editor";
|
import { AdminSettingsEditor } from "@/components/admin-settings-editor";
|
||||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
|
||||||
import { createAdminSupabase } from "@/lib/supabase/admin";
|
import { createAdminSupabase } from "@/lib/supabase/admin";
|
||||||
import type { DbSetting } from "@/types/database";
|
import type { DbSetting } from "@/types/database";
|
||||||
|
|
||||||
@@ -31,10 +30,8 @@ export default async function SettingsPage({
|
|||||||
addError?: string | string[];
|
addError?: string | string[];
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const cookieStore = await cookies();
|
const role = await requireSession();
|
||||||
if (cookieStore.get(ADMIN_SESSION_COOKIE)?.value !== "1") {
|
const readOnly = isReadOnlyRole(role);
|
||||||
redirect("/login");
|
|
||||||
}
|
|
||||||
|
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const saveError = firstSearchParam(sp.saveError) === "1";
|
const saveError = firstSearchParam(sp.saveError) === "1";
|
||||||
@@ -63,7 +60,7 @@ export default async function SettingsPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-full flex-1 flex-col bg-zinc-50 dark:bg-zinc-950">
|
<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">
|
<main className="flex-1 px-6 py-8">
|
||||||
<div className="mx-auto mb-8 max-w-[900px]">
|
<div className="mx-auto mb-8 max-w-[900px]">
|
||||||
<h1 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
<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}
|
rows={rows}
|
||||||
saveError={saveError}
|
saveError={saveError}
|
||||||
addError={addError}
|
addError={addError}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { MatchHistoryBattleCard } from "@/components/match-history-battle-card";
|
|||||||
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
|
import type { DashboardStatsBundle, LeaderboardRow } from "@/lib/dashboard-stats";
|
||||||
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
|
import { formatRcBalanceWithCoins } from "@/lib/coins-rc";
|
||||||
import { AdminMatchAnalysis } from "@/components/admin-match-analysis";
|
import { AdminMatchAnalysis } from "@/components/admin-match-analysis";
|
||||||
|
import type { GeolocationAnalyticsResult } from "@/lib/geolocation-analytics";
|
||||||
import type { PingAnalyticsResult } from "@/lib/ping-analytics";
|
import type { PingAnalyticsResult } from "@/lib/ping-analytics";
|
||||||
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
import { MatchmakerLogTerminal } from "@/components/matchmaker-log-terminal";
|
||||||
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
|
import type { MatchLogAnalysisResult } from "@/lib/match-log-parser";
|
||||||
@@ -225,6 +226,8 @@ type Props = {
|
|||||||
analysisPlayerIds: number[];
|
analysisPlayerIds: number[];
|
||||||
matchAnalysis: MatchLogAnalysisResult;
|
matchAnalysis: MatchLogAnalysisResult;
|
||||||
pingAnalytics: PingAnalyticsResult;
|
pingAnalytics: PingAnalyticsResult;
|
||||||
|
geolocationAnalytics: GeolocationAnalyticsResult;
|
||||||
|
readOnly: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
function StatCard({
|
function StatCard({
|
||||||
@@ -280,6 +283,8 @@ export function AdminDashboard({
|
|||||||
analysisPlayerIds,
|
analysisPlayerIds,
|
||||||
matchAnalysis,
|
matchAnalysis,
|
||||||
pingAnalytics,
|
pingAnalytics,
|
||||||
|
geolocationAnalytics,
|
||||||
|
readOnly,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [hideNoWinner, setHideNoWinner] = useState(true);
|
const [hideNoWinner, setHideNoWinner] = useState(true);
|
||||||
const [playersSearch, setPlayersSearch] = useState("");
|
const [playersSearch, setPlayersSearch] = useState("");
|
||||||
@@ -735,13 +740,15 @@ export function AdminDashboard({
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 whitespace-nowrap">
|
<td className="px-4 py-2 whitespace-nowrap">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Link
|
{!readOnly ? (
|
||||||
href={editHref(u)}
|
<Link
|
||||||
scroll={false}
|
href={editHref(u)}
|
||||||
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"
|
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>
|
Edit
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
<Link
|
<Link
|
||||||
href={`/?tab=matches&participant=${u.id}`}
|
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"
|
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
|
<AdminMatchAnalysis
|
||||||
analysis={matchAnalysis}
|
analysis={matchAnalysis}
|
||||||
pingAnalytics={pingAnalytics}
|
pingAnalytics={pingAnalytics}
|
||||||
|
geolocationAnalytics={geolocationAnalytics}
|
||||||
users={users}
|
users={users}
|
||||||
analysisFrom={analysisFrom}
|
analysisFrom={analysisFrom}
|
||||||
analysisTo={analysisTo}
|
analysisTo={analysisTo}
|
||||||
@@ -831,6 +839,7 @@ export function AdminDashboard({
|
|||||||
ledgerFrom={ledgerFrom}
|
ledgerFrom={ledgerFrom}
|
||||||
ledgerTo={ledgerTo}
|
ledgerTo={ledgerTo}
|
||||||
supplyError={supplyError}
|
supplyError={supplyError}
|
||||||
|
readOnly={readOnly}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
export function AdminHeader() {
|
type Props = {
|
||||||
|
readOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AdminHeader({ readOnly = false }: Props) {
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await fetch("/api/auth/logout", { method: "POST" });
|
await fetch("/api/auth/logout", { method: "POST" });
|
||||||
window.location.href = "/login";
|
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">
|
<h1 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">
|
||||||
Kick Kings Admin Dashboard
|
Kick Kings Admin Dashboard
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ type Props = {
|
|||||||
ledgerFrom: string;
|
ledgerFrom: string;
|
||||||
ledgerTo: string;
|
ledgerTo: string;
|
||||||
supplyError: boolean;
|
supplyError: boolean;
|
||||||
|
readOnly: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AdminLedger({
|
export function AdminLedger({
|
||||||
@@ -110,6 +111,7 @@ export function AdminLedger({
|
|||||||
ledgerFrom,
|
ledgerFrom,
|
||||||
ledgerTo,
|
ledgerTo,
|
||||||
supplyError,
|
supplyError,
|
||||||
|
readOnly,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const usernameById = useMemo(() => {
|
const usernameById = useMemo(() => {
|
||||||
const m = new Map<number, string | null>();
|
const m = new Map<number, string | null>();
|
||||||
@@ -316,13 +318,15 @@ export function AdminLedger({
|
|||||||
All transactions
|
All transactions
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
{!readOnly ? (
|
||||||
type="button"
|
<button
|
||||||
onClick={() => setSupplyModalOpen(true)}
|
type="button"
|
||||||
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"
|
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>
|
Add supply
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-sm tabular-nums text-zinc-900 dark:text-zinc-100">
|
<div className="mt-2 text-sm tabular-nums text-zinc-900 dark:text-zinc-100">
|
||||||
<p className="font-mono">
|
<p className="font-mono">
|
||||||
@@ -999,7 +1003,7 @@ export function AdminLedger({
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{supplyModalOpen ? (
|
{supplyModalOpen && !readOnly ? (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/40 p-4"
|
className="fixed inset-0 z-[9998] flex items-center justify-center bg-black/40 p-4"
|
||||||
role="presentation"
|
role="presentation"
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { useMemo, useState } from "react";
|
|||||||
import { ClickableUserId } from "@/components/clickable-user-id";
|
import { ClickableUserId } from "@/components/clickable-user-id";
|
||||||
import { AnalysisMatchAlertBox } from "@/components/analysis-match-alert-box";
|
import { AnalysisMatchAlertBox } from "@/components/analysis-match-alert-box";
|
||||||
import { MatchAnalysisEmoteChart } from "@/components/match-analysis-emote-chart";
|
import { MatchAnalysisEmoteChart } from "@/components/match-analysis-emote-chart";
|
||||||
|
import { AnalysisGeolocationMap } from "@/components/analysis-geolocation-map";
|
||||||
import { MatchAnalysisForceChart } from "@/components/match-analysis-force-chart";
|
import { MatchAnalysisForceChart } from "@/components/match-analysis-force-chart";
|
||||||
import { buildDashboardHref } from "@/lib/dashboard-search-url";
|
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 { MatchLogAnalysisResult, NumericAggregate } from "@/lib/match-log-parser";
|
||||||
import type { PingAnalyticsResult, PingGroupRow } from "@/lib/ping-analytics";
|
import type { PingAnalyticsResult, PingGroupRow } from "@/lib/ping-analytics";
|
||||||
import type { DbUser } from "@/types/database";
|
import type { DbUser } from "@/types/database";
|
||||||
@@ -95,6 +97,7 @@ function Stat({ label, value }: { label: string; value: string }) {
|
|||||||
type Props = {
|
type Props = {
|
||||||
analysis: MatchLogAnalysisResult;
|
analysis: MatchLogAnalysisResult;
|
||||||
pingAnalytics: PingAnalyticsResult;
|
pingAnalytics: PingAnalyticsResult;
|
||||||
|
geolocationAnalytics: GeolocationAnalyticsResult;
|
||||||
users: DbUser[];
|
users: DbUser[];
|
||||||
analysisFrom: string;
|
analysisFrom: string;
|
||||||
analysisTo: string;
|
analysisTo: string;
|
||||||
@@ -106,6 +109,7 @@ type Props = {
|
|||||||
export function AdminMatchAnalysis({
|
export function AdminMatchAnalysis({
|
||||||
analysis,
|
analysis,
|
||||||
pingAnalytics,
|
pingAnalytics,
|
||||||
|
geolocationAnalytics,
|
||||||
users,
|
users,
|
||||||
analysisFrom,
|
analysisFrom,
|
||||||
analysisTo,
|
analysisTo,
|
||||||
@@ -554,6 +558,12 @@ export function AdminMatchAnalysis({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<AnalysisGeolocationMap
|
||||||
|
userAccounts={geolocationAnalytics.userAccounts}
|
||||||
|
matches={geolocationAnalytics.matches}
|
||||||
|
error={geolocationAnalytics.error}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,22 @@ type Props = {
|
|||||||
rows: DbSetting[];
|
rows: DbSetting[];
|
||||||
saveError: boolean;
|
saveError: boolean;
|
||||||
addError: string | null;
|
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 }) {
|
function DefaultSettingRow({ row, index }: { row: DbSetting; index: number }) {
|
||||||
const [value, setValue] = useState(row.value ?? "");
|
const [value, setValue] = useState(row.value ?? "");
|
||||||
const saveDisabled = value.trim() === "";
|
const saveDisabled = value.trim() === "";
|
||||||
@@ -271,10 +285,52 @@ export function AdminSettingsEditor({
|
|||||||
rows,
|
rows,
|
||||||
saveError,
|
saveError,
|
||||||
addError,
|
addError,
|
||||||
|
readOnly = false,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [newKey, setNewKey] = useState("");
|
const [newKey, setNewKey] = useState("");
|
||||||
const [newValue, setNewValue] = 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 (
|
return (
|
||||||
<div className="mx-auto w-full max-w-[900px] space-y-8">
|
<div className="mx-auto w-full max-w-[900px] space-y-8">
|
||||||
{saveError ? (
|
{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 { timingSafeEqual } from "node:crypto";
|
||||||
|
import type { AdminRole } from "@/lib/auth/roles";
|
||||||
|
|
||||||
function safeEqual(a: string, b: string): boolean {
|
function safeEqual(a: string, b: string): boolean {
|
||||||
const aBuf = Buffer.from(a);
|
const aBuf = Buffer.from(a);
|
||||||
@@ -16,15 +17,36 @@ export function getAdminPassword(): string | null {
|
|||||||
return password || 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,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
): boolean {
|
): AdminRole | null {
|
||||||
const expectedUsername = getAdminUsername();
|
const adminPassword = getAdminPassword();
|
||||||
const expectedPassword = getAdminPassword();
|
if (
|
||||||
if (!expectedPassword) return false;
|
adminPassword &&
|
||||||
return (
|
safeEqual(username, getAdminUsername()) &&
|
||||||
safeEqual(username, expectedUsername) &&
|
safeEqual(password, adminPassword)
|
||||||
safeEqual(password, expectedPassword)
|
) {
|
||||||
);
|
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 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 { 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://. */
|
/** Use real HTTPS (or X-Forwarded-Proto) — not NODE_ENV — so cookies work on http://. */
|
||||||
function isHttpsRequest(request: Request): boolean {
|
function isHttpsRequest(request: Request): boolean {
|
||||||
try {
|
try {
|
||||||
@@ -8,10 +14,20 @@ function isHttpsRequest(request: Request): boolean {
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* 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 === "https") return true;
|
||||||
if (xf === "http") return false;
|
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) {
|
export function getSessionCookieSetOptions(request: Request) {
|
||||||
@@ -35,10 +51,14 @@ export function getSessionCookieClearOptions(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Attach session cookie to a response (works reliably with redirects). */
|
/** 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(
|
res.cookies.set(
|
||||||
ADMIN_SESSION_COOKIE,
|
ADMIN_SESSION_COOKIE,
|
||||||
"1",
|
roleToSessionCookieValue(role),
|
||||||
getSessionCookieSetOptions(request),
|
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;
|
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): {
|
function parseForwardedHeader(value: string | null): {
|
||||||
host?: string;
|
host?: string;
|
||||||
proto?: string;
|
proto?: string;
|
||||||
@@ -83,7 +97,9 @@ export function getRequestOrigin(request: Request): string {
|
|||||||
|
|
||||||
const xfProto = request.headers.get("x-forwarded-proto");
|
const xfProto = request.headers.get("x-forwarded-proto");
|
||||||
const protoRaw = firstUrlLine(
|
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";
|
const proto = protoRaw === "https" || protoRaw === "http" ? protoRaw : "http";
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -1,10 +1,11 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
|
import { parseSessionRole } from "@/lib/auth/roles";
|
||||||
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
import { ADMIN_SESSION_COOKIE } from "@/lib/auth/session";
|
||||||
import { publicRequestUrl } from "@/lib/request-public-url";
|
import { publicRequestUrl } from "@/lib/request-public-url";
|
||||||
|
|
||||||
function hasValidSession(request: NextRequest): boolean {
|
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) {
|
export function proxy(request: NextRequest) {
|
||||||
|
|||||||
Reference in New Issue
Block a user