updated rate limiting, added analytics dash, added tiers.

This commit is contained in:
seniorswe
2025-12-07 22:54:08 -05:00
parent ab0265fd5e
commit 890c2ef324
29 changed files with 2608 additions and 426 deletions
+395 -5
View File
@@ -13,7 +13,8 @@
"lucide-react": "^0.460.0",
"next": "^15.3.5",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"recharts": "^3.5.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
@@ -24,7 +25,7 @@
"eslint-config-next": "15.1.8",
"postcss": "^8",
"tailwindcss": "^3.4.1",
"typescript": "^5"
"typescript": "5.8.3"
},
"engines": {
"node": ">=20 <21",
@@ -989,6 +990,42 @@
"node": ">=14"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.0.tgz",
"integrity": "sha512-hBjYg0aaRL1O2Z0IqWhnTLytnjDIxekmRxm1snsHjHaKVmIF1HiImWqsq+PuEbn6zdMlkIj9WofK1vR8jjx+Xw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz",
"integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@@ -1003,6 +1040,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
@@ -1023,6 +1072,69 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz",
"integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz",
@@ -1058,7 +1170,7 @@
"version": "19.1.5",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.5.tgz",
"integrity": "sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -1074,6 +1186,12 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.32.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.32.1.tgz",
@@ -2184,9 +2302,130 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz",
"integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/damerau-levenshtein": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz",
@@ -2276,6 +2515,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2559,6 +2804,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-toolkit": {
"version": "1.42.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz",
"integrity": "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -2998,6 +3253,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3496,6 +3757,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -3538,6 +3809,15 @@
"node": ">= 0.4"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-array-buffer": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
@@ -4923,9 +5203,31 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true,
"license": "MIT"
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -4949,6 +5251,51 @@
"node": ">=8.10.0"
}
},
"node_modules/recharts": {
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.5.1.tgz",
"integrity": "sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -4993,6 +5340,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -5812,6 +6165,12 @@
"node": ">=0.8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.13.tgz",
@@ -6083,6 +6442,15 @@
"punycode": "^2.1.0"
}
},
"node_modules/use-sync-external-store": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
"license": "MIT",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -6090,6 +6458,28 @@
"dev": true,
"license": "MIT"
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+2 -1
View File
@@ -18,7 +18,8 @@
"lucide-react": "^0.460.0",
"next": "^15.3.5",
"react": "^19.0.0",
"react-dom": "^19.0.0"
"react-dom": "^19.0.0",
"recharts": "^3.5.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
+208 -10
View File
@@ -60,6 +60,14 @@ export default function CreditsPage() {
const [userWorking, setUserWorking] = useState(false)
const [userError, setUserError] = useState<string | null>(null)
const [userSuccess, setUserSuccess] = useState<string | null>(null)
const [showAssignModal, setShowAssignModal] = useState(false)
const [assignForm, setAssignForm] = useState({
username: '',
credit_group: '',
tier_name: '',
credits: 0
})
const [assigning, setAssigning] = useState(false)
type TierMeta = { credits: number; reset_frequency?: string }
const [defs, setDefs] = useState<Record<string, { [tier: string]: TierMeta }>>({})
@@ -131,6 +139,44 @@ export default function CreditsPage() {
}
}
const handleAssignCredits = async () => {
if (!assignForm.username.trim() || !assignForm.credit_group.trim() || !assignForm.tier_name.trim()) {
alert('Please fill in all fields')
return
}
try {
setAssigning(true)
await postJson(`${SERVER_URL}/platform/credit/user`, {
username: assignForm.username.trim(),
api_credit_group: assignForm.credit_group.trim(),
tier_name: assignForm.tier_name.trim(),
available_credits: assignForm.credits || undefined
})
setUserSuccess(`Credits assigned to ${assignForm.username}`)
setShowAssignModal(false)
setAssignForm({ username: '', credit_group: '', tier_name: '', credits: 0 })
await loadAllUserTokens()
} catch (e: any) {
alert(e?.message || 'Failed to assign credits')
} finally {
setAssigning(false)
}
}
const handleRemoveUserCredits = async (username: string, creditGroup: string) => {
if (!confirm(`Remove ${username} from credit group "${creditGroup}"?`)) return
try {
await delJson(`${SERVER_URL}/platform/credit/user/${encodeURIComponent(username)}/${encodeURIComponent(creditGroup)}`)
setUserSuccess(`Removed ${username} from ${creditGroup}`)
await loadAllUserTokens()
} catch (e: any) {
alert(e?.message || 'Failed to remove user credits')
}
}
useEffect(() => {
loadAllUserTokens()
loadDefs()
@@ -188,7 +234,18 @@ export default function CreditsPage() {
</div>
<div className="card">
<div className="card-header"><h3 className="card-title">User Credits</h3></div>
<div className="card-header flex items-center justify-between">
<h3 className="card-title">User Credits</h3>
<button
onClick={() => setShowAssignModal(true)}
className="btn btn-primary btn-sm"
>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Assign Credits
</button>
</div>
<div className="p-6 space-y-4">
{userError && <div className="text-sm text-error-600">{userError}</div>}
{userSuccess && <div className="text-sm text-success-600">{userSuccess}</div>}
@@ -199,7 +256,7 @@ export default function CreditsPage() {
) : (
<div className="overflow-x-auto">
<table className="table">
<thead><tr><th>Username</th><th>Groups</th><th>Total</th><th>Used</th><th>Left</th><th>Reset Freq</th><th>Reset Dates</th></tr></thead>
<thead><tr><th>Username</th><th>Groups</th><th>Total</th><th>Used</th><th>Left</th><th>Reset Freq</th><th>Reset Dates</th><th>Actions</th></tr></thead>
<tbody>
{userRows.map((row, idx) => {
let total = 0, available = 0
@@ -215,24 +272,44 @@ export default function CreditsPage() {
if (rd) dates.add(String(rd))
}
const used = total > 0 ? Math.max(total - available, 0) : 0
const groups = Object.keys(row.users_credits || {})
return (
<tr
key={idx}
className="hover:bg-gray-50 dark:hover:bg-dark-surfaceHover cursor-pointer"
onClick={() => router.push(`/credits/${encodeURIComponent(row.username)}`)}
>
<td className="font-medium">{row.username}</td>
<td className="text-sm text-gray-600">{Object.keys(row.users_credits || {}).join(', ') || '-'}</td>
<tr key={idx} className="hover:bg-gray-50 dark:hover:bg-dark-surfaceHover">
<td className="font-medium cursor-pointer" onClick={() => router.push(`/credits/${encodeURIComponent(row.username)}`)}>{row.username}</td>
<td className="text-sm text-gray-600">{groups.join(', ') || '-'}</td>
<td className="text-sm">{total || 0}</td>
<td className="text-sm">{used}</td>
<td className="text-sm">{available || 0}</td>
<td className="text-sm">{freqs.size ? Array.from(freqs).join(', ') : '-'}</td>
<td className="text-sm">{dates.size ? Array.from(dates).join(', ') : '-'}</td>
<td>
<div className="flex gap-2">
<button
onClick={() => router.push(`/credits/${encodeURIComponent(row.username)}`)}
className="btn btn-sm btn-outline"
title="View details"
>
View
</button>
{groups.length > 0 && (
<button
onClick={(e) => {
e.stopPropagation()
handleRemoveUserCredits(row.username, groups[0])
}}
className="btn btn-sm btn-outline text-error-600"
title="Remove from first group"
>
Remove
</button>
)}
</div>
</td>
</tr>
)
})}
{userRows.length === 0 && (
<tr><td colSpan={7} className="text-gray-500 text-center py-6">No user token records</td></tr>
<tr><td colSpan={8} className="text-gray-500 text-center py-6">No user token records</td></tr>
)}
</tbody>
</table>
@@ -251,6 +328,127 @@ export default function CreditsPage() {
</div>
</div>
{/* Assign Credits Modal */}
{showAssignModal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4">
<div className="fixed inset-0 bg-black/50" onClick={() => setShowAssignModal(false)} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Assign Credits to User
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Username *
</label>
<input
type="text"
value={assignForm.username}
onChange={(e) => setAssignForm({ ...assignForm, username: e.target.value })}
className="input"
placeholder="Enter username"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Credit Group *
</label>
<select
value={assignForm.credit_group}
onChange={(e) => setAssignForm({ ...assignForm, credit_group: e.target.value, tier_name: '' })}
className="input"
>
<option value="">Select credit group</option>
{Object.keys(defs).map((group) => (
<option key={group} value={group}>{group}</option>
))}
</select>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Select from available credit definitions
</p>
</div>
{assignForm.credit_group && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tier *
</label>
<select
value={assignForm.tier_name}
onChange={(e) => {
const tierName = e.target.value
const tierMeta = defs[assignForm.credit_group]?.[tierName]
setAssignForm({
...assignForm,
tier_name: tierName,
credits: tierMeta?.credits || 0
})
}}
className="input"
>
<option value="">Select tier</option>
{Object.keys(defs[assignForm.credit_group] || {}).map((tier) => {
const meta = defs[assignForm.credit_group][tier]
return (
<option key={tier} value={tier}>
{tier} ({meta.credits} credits, {meta.reset_frequency || 'no reset'})
</option>
)
})}
</select>
</div>
)}
{assignForm.tier_name && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Initial Credits
</label>
<input
type="number"
value={assignForm.credits}
onChange={(e) => setAssignForm({ ...assignForm, credits: parseInt(e.target.value) || 0 })}
className="input"
min="0"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Leave as default or override initial credit amount
</p>
</div>
)}
</div>
<div className="flex gap-2 mt-6">
<button
onClick={handleAssignCredits}
disabled={assigning || !assignForm.username || !assignForm.credit_group || !assignForm.tier_name}
className="btn btn-primary flex-1"
>
{assigning ? (
<>
<div className="spinner mr-2"></div>
Assigning...
</>
) : (
'Assign Credits'
)}
</button>
<button
onClick={() => setShowAssignModal(false)}
className="btn btn-secondary"
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
</Layout>
</ProtectedRoute>
)
+30 -1
View File
@@ -21,6 +21,11 @@ interface Role {
manage_gateway?: boolean
manage_subscriptions?: boolean
manage_security?: boolean
manage_tiers?: boolean
manage_rate_limits?: boolean
manage_credits?: boolean
manage_auth?: boolean
view_analytics?: boolean
view_logs?: boolean
export_logs?: boolean
}
@@ -107,7 +112,28 @@ const RoleDetailPage = () => {
setSaving(true)
setError(null)
await (await import('@/utils/api')).putJson(`${SERVER_URL}/platform/role/${encodeURIComponent(roleName)}`, editData)
// Ensure all permission values are proper booleans
const sanitizedData = {
...editData,
manage_users: Boolean(editData.manage_users),
manage_apis: Boolean(editData.manage_apis),
manage_endpoints: Boolean(editData.manage_endpoints),
manage_groups: Boolean(editData.manage_groups),
manage_roles: Boolean(editData.manage_roles),
manage_routings: Boolean(editData.manage_routings),
manage_gateway: Boolean(editData.manage_gateway),
manage_subscriptions: Boolean(editData.manage_subscriptions),
manage_security: Boolean(editData.manage_security),
manage_tiers: Boolean(editData.manage_tiers),
manage_rate_limits: Boolean(editData.manage_rate_limits),
manage_credits: Boolean(editData.manage_credits),
manage_auth: Boolean(editData.manage_auth),
view_analytics: Boolean(editData.view_analytics),
view_logs: Boolean(editData.view_logs),
export_logs: Boolean(editData.export_logs)
}
await (await import('@/utils/api')).putJson(`${SERVER_URL}/platform/role/${encodeURIComponent(roleName)}`, sanitizedData)
let rolePayload: any
try {
@@ -363,11 +389,14 @@ const RoleDetailPage = () => {
{ key: 'manage_groups', label: 'Manage Groups', description: 'Create, edit, and delete user groups' },
{ key: 'manage_roles', label: 'Manage Roles', description: 'Create, edit, and delete user roles' },
{ key: 'manage_routings', label: 'Manage Routings', description: 'Configure API routing and load balancing' },
{ key: 'manage_tiers', label: 'Manage Tiers', description: 'Create and manage pricing tiers and rate limit plans' },
{ key: 'manage_rate_limits', label: 'Manage Rate Limits', description: 'Configure rate limiting rules and IP restrictions' },
{ key: 'manage_gateway', label: 'Manage Gateway', description: 'Configure gateway settings and policies' },
{ key: 'manage_subscriptions', label: 'Manage Subscriptions', description: 'Manage API subscriptions and billing' },
{ key: 'manage_security', label: 'Manage Security', description: 'Manage security settings and memory dump policy' },
{ key: 'manage_credits', label: 'Manage Credits', description: 'Manage API credits and user credit balances' },
{ key: 'manage_auth', label: 'Manage Auth', description: 'Revoke tokens and enable/disable users' },
{ key: 'view_analytics', label: 'View Analytics', description: 'View analytics dashboard and usage metrics' },
{ key: 'view_logs', label: 'View Logs', description: 'View system logs and API requests' },
{ key: 'export_logs', label: 'Export Logs', description: 'Export logs in various formats' }
].map(({ key, label, description }) => (
+13
View File
@@ -20,6 +20,11 @@ interface CreateRoleData {
manage_gateway: boolean
manage_subscriptions: boolean
manage_security: boolean
manage_tiers: boolean
manage_rate_limits: boolean
manage_credits: boolean
manage_auth: boolean
view_analytics: boolean
view_logs: boolean
export_logs: boolean
}
@@ -40,6 +45,11 @@ const AddRolePage = () => {
manage_gateway: false,
manage_subscriptions: false,
manage_security: false,
manage_tiers: false,
manage_rate_limits: false,
manage_credits: false,
manage_auth: false,
view_analytics: false,
view_logs: false,
export_logs: false
})
@@ -81,11 +91,14 @@ const AddRolePage = () => {
{ key: 'manage_groups', label: 'Manage Groups', description: 'Create, edit, and delete user groups' },
{ key: 'manage_roles', label: 'Manage Roles', description: 'Create, edit, and delete user roles' },
{ key: 'manage_routings', label: 'Manage Routings', description: 'Configure API routing and load balancing' },
{ key: 'manage_tiers', label: 'Manage Tiers', description: 'Create and manage pricing tiers and rate limit plans' },
{ key: 'manage_rate_limits', label: 'Manage Rate Limits', description: 'Configure rate limiting rules and IP restrictions' },
{ key: 'manage_gateway', label: 'Manage Gateway', description: 'Configure gateway settings and policies' },
{ key: 'manage_subscriptions', label: 'Manage Subscriptions', description: 'Manage API subscriptions and billing' },
{ key: 'manage_security', label: 'Manage Security', description: 'Manage security settings and memory dump policy' },
{ key: 'manage_credits', label: 'Manage Credits', description: 'Manage API credits and user credit balances' },
{ key: 'manage_auth', label: 'Manage Auth', description: 'Revoke tokens and enable/disable users' },
{ key: 'view_analytics', label: 'View Analytics', description: 'View analytics dashboard and usage metrics' },
{ key: 'view_logs', label: 'View Logs', description: 'View system logs and API requests' },
{ key: 'export_logs', label: 'Export Logs', description: 'Export logs in various formats' }
]
+470
View File
@@ -0,0 +1,470 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useRouter, useParams } from 'next/navigation'
import Layout from '@/components/Layout'
import { ProtectedRoute } from '@/components/ProtectedRoute'
import { getJson, putJson } from '@/utils/api'
import { SERVER_URL } from '@/utils/config'
export default function EditTierPage() {
const router = useRouter()
const params = useParams()
const tierId = params.id as string
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [formData, setFormData] = useState({
tier_id: '',
display_name: '',
description: '',
price_monthly: '',
price_yearly: '',
is_default: false,
enabled: true,
// Limits
rate_limiting_enabled: true,
requests_per_minute: '',
requests_per_hour: '',
requests_per_day: '',
monthly_request_quota: '',
daily_request_quota: '',
// Throttling
enable_throttling: false,
max_queue_time_ms: '5000'
})
useEffect(() => {
fetchTier()
}, [tierId])
const fetchTier = async () => {
try {
setLoading(true)
const tier = await getJson(`${SERVER_URL}/platform/tiers/${tierId}`)
// Check if rate limiting is enabled (any limit is less than 999999)
const hasRateLimits = (
(tier.limits?.requests_per_minute && tier.limits.requests_per_minute < 999999) ||
(tier.limits?.requests_per_hour && tier.limits.requests_per_hour < 999999) ||
(tier.limits?.requests_per_day && tier.limits.requests_per_day < 999999) ||
(tier.limits?.monthly_request_quota && tier.limits.monthly_request_quota < 999999) ||
(tier.limits?.daily_request_quota && tier.limits.daily_request_quota < 999999)
)
setFormData({
tier_id: tier.tier_id,
display_name: tier.display_name,
description: tier.description || '',
price_monthly: tier.price_monthly?.toString() || '',
price_yearly: tier.price_yearly?.toString() || '',
is_default: tier.is_default,
enabled: tier.enabled,
rate_limiting_enabled: hasRateLimits,
requests_per_minute: (hasRateLimits && tier.limits?.requests_per_minute && tier.limits.requests_per_minute < 999999) ? tier.limits.requests_per_minute.toString() : '',
requests_per_hour: (hasRateLimits && tier.limits?.requests_per_hour && tier.limits.requests_per_hour < 999999) ? tier.limits.requests_per_hour.toString() : '',
requests_per_day: (hasRateLimits && tier.limits?.requests_per_day && tier.limits.requests_per_day < 999999) ? tier.limits.requests_per_day.toString() : '',
monthly_request_quota: (hasRateLimits && tier.limits?.monthly_request_quota && tier.limits.monthly_request_quota < 999999) ? tier.limits.monthly_request_quota.toString() : '',
daily_request_quota: (hasRateLimits && tier.limits?.daily_request_quota && tier.limits.daily_request_quota < 999999) ? tier.limits.daily_request_quota.toString() : '',
enable_throttling: tier.limits?.enable_throttling || false,
max_queue_time_ms: tier.limits?.max_queue_time_ms?.toString() || '5000'
})
setError(null)
} catch (err: any) {
console.error('Failed to fetch tier:', err)
setError(err.message || 'Failed to load tier')
} finally {
setLoading(false)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
setError(null)
try {
// If rate limiting is disabled, set all limits to 999999
const rateLimitingDisabled = !formData.rate_limiting_enabled
const unlimitedValue = 999999
const payload = {
display_name: formData.display_name,
description: formData.description || undefined,
price_monthly: formData.price_monthly ? parseFloat(formData.price_monthly) : undefined,
price_yearly: formData.price_yearly ? parseFloat(formData.price_yearly) : undefined,
is_default: formData.is_default,
enabled: formData.enabled,
features: [],
limits: {
requests_per_minute: rateLimitingDisabled ? unlimitedValue : (formData.requests_per_minute ? parseInt(formData.requests_per_minute) : undefined),
requests_per_hour: rateLimitingDisabled ? unlimitedValue : (formData.requests_per_hour ? parseInt(formData.requests_per_hour) : undefined),
requests_per_day: rateLimitingDisabled ? unlimitedValue : (formData.requests_per_day ? parseInt(formData.requests_per_day) : undefined),
monthly_request_quota: rateLimitingDisabled ? unlimitedValue : (formData.monthly_request_quota ? parseInt(formData.monthly_request_quota) : undefined),
daily_request_quota: rateLimitingDisabled ? unlimitedValue : (formData.daily_request_quota ? parseInt(formData.daily_request_quota) : undefined),
burst_per_second: 0,
burst_per_minute: 0,
burst_per_hour: 0,
enable_throttling: formData.enable_throttling,
max_queue_time_ms: parseInt(formData.max_queue_time_ms) || 5000
}
}
await putJson(`${SERVER_URL}/platform/tiers/${tierId}`, payload)
router.push('/tiers')
} catch (err: any) {
console.error('Failed to update tier:', err)
setError(err.message || 'Failed to update tier')
setSaving(false)
}
}
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => {
const { name, value, type } = e.target
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked : value
}))
}
if (loading) {
return (
<ProtectedRoute requiredPermission="manage_tiers">
<Layout>
<div className="flex items-center justify-center min-h-screen">
<div className="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
</div>
</Layout>
</ProtectedRoute>
)
}
return (
<ProtectedRoute requiredPermission="manage_tiers">
<Layout>
<div className="max-w-4xl mx-auto space-y-6">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Edit Tier</h1>
<p className="mt-2 text-gray-600 dark:text-gray-400">Update tier configuration</p>
</div>
{error && (
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Basic Information */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Basic Information
</h2>
<div className="grid grid-cols-1 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tier ID
</label>
<input
type="text"
value={formData.tier_id}
disabled
className="input w-full bg-gray-100 dark:bg-gray-800 cursor-not-allowed"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Tier ID cannot be changed
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tier Name *
</label>
<input
type="text"
name="display_name"
value={formData.display_name}
onChange={handleChange}
required
className="input w-full"
placeholder="e.g., Professional Plan"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Display name shown to users
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Description
</label>
<textarea
name="description"
value={formData.description}
onChange={handleChange}
rows={2}
className="input w-full"
placeholder="Brief description of this tier"
/>
</div>
</div>
</div>
{/* Pricing */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Pricing
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Monthly Price ($)
</label>
<input
type="number"
name="price_monthly"
value={formData.price_monthly}
onChange={handleChange}
step="0.01"
min="0"
className="input w-full"
placeholder="49.99"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Yearly Price ($)
</label>
<input
type="number"
name="price_yearly"
value={formData.price_yearly}
onChange={handleChange}
step="0.01"
min="0"
className="input w-full"
placeholder="499.99"
/>
</div>
</div>
</div>
{/* Rate Limits */}
<div className="card p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Rate Limits
</h2>
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
name="rate_limiting_enabled"
checked={formData.rate_limiting_enabled}
onChange={handleChange}
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">
Enable Rate Limiting
</span>
</label>
</div>
{!formData.rate_limiting_enabled && (
<div className="mb-4 p-3 rounded bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-300">
Rate limiting is disabled. This tier will have unlimited requests.
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Requests per Minute
</label>
<input
type="number"
name="requests_per_minute"
value={formData.requests_per_minute}
onChange={handleChange}
min="0"
disabled={!formData.rate_limiting_enabled}
className="input w-full disabled:bg-gray-100 disabled:cursor-not-allowed dark:disabled:bg-gray-800"
placeholder="100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Requests per Hour
</label>
<input
type="number"
name="requests_per_hour"
value={formData.requests_per_hour}
onChange={handleChange}
min="0"
disabled={!formData.rate_limiting_enabled}
className="input w-full disabled:bg-gray-100 disabled:cursor-not-allowed dark:disabled:bg-gray-800"
placeholder="5000"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Requests per Day
</label>
<input
type="number"
name="requests_per_day"
value={formData.requests_per_day}
onChange={handleChange}
min="0"
disabled={!formData.rate_limiting_enabled}
className="input w-full disabled:bg-gray-100 disabled:cursor-not-allowed dark:disabled:bg-gray-800"
placeholder="100000"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Monthly Request Quota
</label>
<input
type="number"
name="monthly_request_quota"
value={formData.monthly_request_quota}
onChange={handleChange}
min="0"
disabled={!formData.rate_limiting_enabled}
className="input w-full disabled:bg-gray-100 disabled:cursor-not-allowed dark:disabled:bg-gray-800"
placeholder="1000000"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Daily Request Quota
</label>
<input
type="number"
name="daily_request_quota"
value={formData.daily_request_quota}
onChange={handleChange}
min="0"
disabled={!formData.rate_limiting_enabled}
className="input w-full disabled:bg-gray-100 disabled:cursor-not-allowed dark:disabled:bg-gray-800"
placeholder="50000"
/>
</div>
</div>
</div>
{/* Throttling */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Throttling
</h2>
<div className="space-y-4">
<label className="flex items-start">
<input
type="checkbox"
name="enable_throttling"
checked={formData.enable_throttling}
onChange={handleChange}
className="mt-1 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<div className="ml-3">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Enable request throttling
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
When enabled, requests exceeding limits will be queued instead of immediately rejected. When disabled, requests return 429 errors.
</p>
</div>
</label>
{formData.enable_throttling && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Max Queue Time (ms)
</label>
<input
type="number"
name="max_queue_time_ms"
value={formData.max_queue_time_ms}
onChange={handleChange}
min="0"
step="100"
className="input w-full"
placeholder="5000"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Maximum time (in milliseconds) to queue a request before rejecting it
</p>
</div>
)}
</div>
</div>
{/* Settings */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Settings
</h2>
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
name="is_default"
checked={formData.is_default}
onChange={handleChange}
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">
Set as default tier
</span>
</label>
<label className="flex items-center">
<input
type="checkbox"
name="enabled"
checked={formData.enabled}
onChange={handleChange}
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-gray-700 dark:text-gray-300">
Enable tier
</span>
</label>
</div>
</div>
{/* Actions */}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => router.push('/tiers')}
className="btn btn-outline"
disabled={saving}
>
Cancel
</button>
<button
type="submit"
className="btn btn-primary"
disabled={saving}
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</Layout>
</ProtectedRoute>
)
}
@@ -0,0 +1,345 @@
'use client'
import React, { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import Layout from '@/components/Layout'
import { ProtectedRoute } from '@/components/ProtectedRoute'
import { getJson, postJson, delJson } from '@/utils/api'
import { SERVER_URL } from '@/utils/config'
interface TierAssignment {
user_id: string
tier_id: string
effective_from?: string
expiration_date?: string
notes?: string
}
interface Tier {
tier_id: string
name: string
display_name: string
requests_per_minute?: number
requests_per_hour?: number
monthly_request_quota?: number
}
export default function TierUsersPage() {
const params = useParams()
const router = useRouter()
const tierId = params.id as string
const [tier, setTier] = useState<Tier | null>(null)
const [assignments, setAssignments] = useState<TierAssignment[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showAssignModal, setShowAssignModal] = useState(false)
const [newUserId, setNewUserId] = useState('')
const [expirationDate, setExpirationDate] = useState('')
const [notes, setNotes] = useState('')
const [assigning, setAssigning] = useState(false)
useEffect(() => {
fetchData()
}, [tierId])
const fetchData = async () => {
try {
setLoading(true)
// Fetch tier details
const tierData = await getJson(`${SERVER_URL}/platform/tiers/${tierId}`)
setTier(tierData)
// Fetch users assigned to this tier
const assignmentsData = await getJson(`${SERVER_URL}/platform/tiers/${tierId}/users`)
setAssignments(assignmentsData || [])
setError(null)
} catch (err: any) {
console.error('Failed to fetch tier data:', err)
setError(err.message || 'Failed to load tier data')
} finally {
setLoading(false)
}
}
const handleAssignUser = async () => {
if (!newUserId.trim()) {
alert('Please enter a user ID')
return
}
try {
setAssigning(true)
await postJson(`${SERVER_URL}/platform/tiers/assignments`, {
user_id: newUserId.trim(),
tier_id: tierId,
expiration_date: expirationDate || undefined,
notes: notes || undefined
})
await fetchData()
setShowAssignModal(false)
setNewUserId('')
setExpirationDate('')
setNotes('')
} catch (err: any) {
alert(err.message || 'Failed to assign user to tier')
} finally {
setAssigning(false)
}
}
const handleRemoveUser = async (userId: string) => {
if (!confirm(`Remove ${userId} from this tier?`)) return
try {
await delJson(`${SERVER_URL}/platform/tiers/assignments/${userId}`)
await fetchData()
} catch (err: any) {
alert(err.message || 'Failed to remove user from tier')
}
}
if (loading) {
return (
<ProtectedRoute requiredPermission="manage_tiers">
<Layout>
<div className="flex items-center justify-center py-12">
<div className="text-center">
<div className="spinner mx-auto mb-4"></div>
<p className="text-gray-600 dark:text-gray-400">Loading tier users...</p>
</div>
</div>
</Layout>
</ProtectedRoute>
)
}
return (
<ProtectedRoute requiredPermission="manage_tiers">
<Layout>
<div className="space-y-6">
<div className="page-header">
<div>
<h1 className="page-title">
{tier?.display_name || tier?.name || 'Tier'} - Assigned Users
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Manage users assigned to this tier
</p>
</div>
<div className="flex gap-2">
<button
onClick={() => setShowAssignModal(true)}
className="btn btn-primary"
>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Assign User
</button>
<button
onClick={() => router.push('/tiers')}
className="btn btn-secondary"
>
<svg className="h-4 w-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Tiers
</button>
</div>
</div>
{error && (
<div className="rounded-lg bg-error-50 border border-error-200 p-4 dark:bg-error-900/20 dark:border-error-800">
<div className="flex">
<svg className="h-5 w-5 text-error-400 dark:text-error-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div className="ml-3">
<p className="text-sm text-error-700 dark:text-error-300">{error}</p>
</div>
</div>
</div>
)}
{/* Tier Info Card */}
{tier && (
<div className="card p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Tier Details
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Requests/Minute</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{tier.requests_per_minute?.toLocaleString() || 'Unlimited'}
</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Requests/Hour</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{tier.requests_per_hour?.toLocaleString() || 'Unlimited'}
</p>
</div>
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">Monthly Quota</p>
<p className="text-lg font-semibold text-gray-900 dark:text-white">
{tier.monthly_request_quota?.toLocaleString() || 'Unlimited'}
</p>
</div>
</div>
</div>
)}
{/* Assigned Users */}
<div className="card">
<div className="card-header">
<h3 className="card-title">
Assigned Users ({assignments.length})
</h3>
</div>
<div className="overflow-x-auto">
<table className="table">
<thead>
<tr>
<th>User ID</th>
<th>Effective From</th>
<th>Expiration Date</th>
<th>Notes</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{assignments.length > 0 ? (
assignments.map((assignment) => (
<tr key={assignment.user_id}>
<td className="font-medium">{assignment.user_id}</td>
<td>
{assignment.effective_from
? new Date(assignment.effective_from).toLocaleDateString()
: '-'}
</td>
<td>
{assignment.expiration_date ? (
<span className="badge badge-warning">
{new Date(assignment.expiration_date).toLocaleDateString()}
</span>
) : (
<span className="badge badge-success">Permanent</span>
)}
</td>
<td className="text-sm text-gray-600 dark:text-gray-400">
{assignment.notes || '-'}
</td>
<td>
<button
onClick={() => handleRemoveUser(assignment.user_id)}
className="btn btn-sm btn-error"
>
Remove
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="text-center text-gray-500 dark:text-gray-400 py-8">
No users assigned to this tier
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Assign User Modal */}
{showAssignModal && (
<div className="fixed inset-0 z-50 overflow-y-auto">
<div className="flex items-center justify-center min-h-screen px-4">
<div className="fixed inset-0 bg-black/50" onClick={() => setShowAssignModal(false)} />
<div className="relative bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full p-6">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Assign User to Tier
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
User ID *
</label>
<input
type="text"
value={newUserId}
onChange={(e) => setNewUserId(e.target.value)}
className="input"
placeholder="Enter user ID or email"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Expiration Date (Optional)
</label>
<input
type="date"
value={expirationDate}
onChange={(e) => setExpirationDate(e.target.value)}
className="input"
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Leave empty for permanent assignment
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Notes (Optional)
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="input"
rows={3}
placeholder="Add notes about this assignment"
/>
</div>
</div>
<div className="flex gap-2 mt-6">
<button
onClick={handleAssignUser}
disabled={assigning}
className="btn btn-primary flex-1"
>
{assigning ? (
<>
<div className="spinner mr-2"></div>
Assigning...
</>
) : (
'Assign User'
)}
</button>
<button
onClick={() => setShowAssignModal(false)}
className="btn btn-secondary"
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
</Layout>
</ProtectedRoute>
)
}
+107 -94
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import Layout from '@/components/Layout'
import { ProtectedRoute } from '@/components/ProtectedRoute'
import { postJson } from '@/utils/api'
import { SERVER_URL } from '@/utils/config'
export default function AddTierPage() {
const router = useRouter()
@@ -13,22 +14,22 @@ export default function AddTierPage() {
const [formData, setFormData] = useState({
tier_id: '',
name: 'free',
display_name: '',
description: '',
price_monthly: '',
price_yearly: '',
is_default: false,
enabled: true,
features: '',
// Limits
rate_limiting_enabled: true,
requests_per_minute: '',
requests_per_hour: '',
requests_per_day: '',
monthly_request_quota: '',
daily_request_quota: '',
burst_per_minute: '0',
burst_per_hour: '0'
// Throttling
enable_throttling: false,
max_queue_time_ms: '5000'
})
const handleSubmit = async (e: React.FormEvent) => {
@@ -37,29 +38,35 @@ export default function AddTierPage() {
setError(null)
try {
// If rate limiting is disabled, set all limits to 999999
const rateLimitingDisabled = !formData.rate_limiting_enabled
const unlimitedValue = 999999
const payload = {
tier_id: formData.tier_id,
name: formData.name,
name: formData.tier_id.toLowerCase().replace(/[^a-z0-9]/g, '_'),
display_name: formData.display_name,
description: formData.description || undefined,
price_monthly: formData.price_monthly ? parseFloat(formData.price_monthly) : undefined,
price_yearly: formData.price_yearly ? parseFloat(formData.price_yearly) : undefined,
is_default: formData.is_default,
enabled: formData.enabled,
features: formData.features ? formData.features.split('\n').filter(f => f.trim()) : [],
features: [],
limits: {
requests_per_minute: formData.requests_per_minute ? parseInt(formData.requests_per_minute) : undefined,
requests_per_hour: formData.requests_per_hour ? parseInt(formData.requests_per_hour) : undefined,
requests_per_day: formData.requests_per_day ? parseInt(formData.requests_per_day) : undefined,
monthly_request_quota: formData.monthly_request_quota ? parseInt(formData.monthly_request_quota) : undefined,
daily_request_quota: formData.daily_request_quota ? parseInt(formData.daily_request_quota) : undefined,
burst_per_minute: parseInt(formData.burst_per_minute) || 0,
burst_per_hour: parseInt(formData.burst_per_hour) || 0,
burst_per_second: 0
requests_per_minute: rateLimitingDisabled ? unlimitedValue : (formData.requests_per_minute ? parseInt(formData.requests_per_minute) : undefined),
requests_per_hour: rateLimitingDisabled ? unlimitedValue : (formData.requests_per_hour ? parseInt(formData.requests_per_hour) : undefined),
requests_per_day: rateLimitingDisabled ? unlimitedValue : (formData.requests_per_day ? parseInt(formData.requests_per_day) : undefined),
monthly_request_quota: rateLimitingDisabled ? unlimitedValue : (formData.monthly_request_quota ? parseInt(formData.monthly_request_quota) : undefined),
daily_request_quota: rateLimitingDisabled ? unlimitedValue : (formData.daily_request_quota ? parseInt(formData.daily_request_quota) : undefined),
burst_per_second: 0,
burst_per_minute: 0,
burst_per_hour: 0,
enable_throttling: formData.enable_throttling,
max_queue_time_ms: parseInt(formData.max_queue_time_ms) || 5000
}
}
await postJson('/platform/tiers', payload)
await postJson(`${SERVER_URL}/platform/tiers`, payload)
router.push('/tiers')
} catch (err: any) {
console.error('Failed to create tier:', err)
@@ -97,7 +104,7 @@ export default function AddTierPage() {
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Basic Information
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="grid grid-cols-1 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tier ID *
@@ -109,31 +116,17 @@ export default function AddTierPage() {
onChange={handleChange}
required
className="input w-full"
placeholder="e.g., tier_pro"
placeholder="e.g., pro_tier"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Unique identifier for this tier (lowercase, no spaces)
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tier Name *
</label>
<select
name="name"
value={formData.name}
onChange={handleChange}
className="input w-full"
>
<option value="free">Free</option>
<option value="pro">Pro</option>
<option value="enterprise">Enterprise</option>
<option value="custom">Custom</option>
</select>
</div>
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Display Name *
</label>
<input
type="text"
name="display_name"
@@ -141,11 +134,14 @@ export default function AddTierPage() {
onChange={handleChange}
required
className="input w-full"
placeholder="e.g., Pro Plan"
placeholder="e.g., Professional Plan"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Display name shown to users
</p>
</div>
<div className="md:col-span-2">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Description
</label>
@@ -153,9 +149,9 @@ export default function AddTierPage() {
name="description"
value={formData.description}
onChange={handleChange}
rows={3}
rows={2}
className="input w-full"
placeholder="Tier description"
placeholder="Brief description of this tier"
/>
</div>
</div>
@@ -203,9 +199,32 @@ export default function AddTierPage() {
{/* Rate Limits */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Rate Limits
</h2>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
Rate Limits
</h2>
<label className="flex items-center cursor-pointer">
<input
type="checkbox"
name="rate_limiting_enabled"
checked={formData.rate_limiting_enabled}
onChange={handleChange}
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
<span className="ml-2 text-sm font-medium text-gray-700 dark:text-gray-300">
Enable Rate Limiting
</span>
</label>
</div>
{!formData.rate_limiting_enabled && (
<div className="mb-4 p-3 rounded bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800">
<p className="text-sm text-blue-800 dark:text-blue-300">
Rate limiting is disabled. This tier will have unlimited requests.
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
@@ -217,7 +236,8 @@ export default function AddTierPage() {
value={formData.requests_per_minute}
onChange={handleChange}
min="0"
className="input w-full"
disabled={!formData.rate_limiting_enabled}
className="input w-full disabled:bg-gray-100 disabled:cursor-not-allowed dark:disabled:bg-gray-800"
placeholder="100"
/>
</div>
@@ -232,7 +252,8 @@ export default function AddTierPage() {
value={formData.requests_per_hour}
onChange={handleChange}
min="0"
className="input w-full"
disabled={!formData.rate_limiting_enabled}
className="input w-full disabled:bg-gray-100 disabled:cursor-not-allowed dark:disabled:bg-gray-800"
placeholder="5000"
/>
</div>
@@ -247,7 +268,8 @@ export default function AddTierPage() {
value={formData.requests_per_day}
onChange={handleChange}
min="0"
className="input w-full"
disabled={!formData.rate_limiting_enabled}
className="input w-full disabled:bg-gray-100 disabled:cursor-not-allowed dark:disabled:bg-gray-800"
placeholder="100000"
/>
</div>
@@ -262,7 +284,8 @@ export default function AddTierPage() {
value={formData.monthly_request_quota}
onChange={handleChange}
min="0"
className="input w-full"
disabled={!formData.rate_limiting_enabled}
className="input w-full disabled:bg-gray-100 disabled:cursor-not-allowed dark:disabled:bg-gray-800"
placeholder="1000000"
/>
</div>
@@ -277,68 +300,58 @@ export default function AddTierPage() {
value={formData.daily_request_quota}
onChange={handleChange}
min="0"
className="input w-full"
disabled={!formData.rate_limiting_enabled}
className="input w-full disabled:bg-gray-100 disabled:cursor-not-allowed dark:disabled:bg-gray-800"
placeholder="50000"
/>
</div>
</div>
</div>
{/* Burst Allowances */}
{/* Throttling */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Burst Allowances
Throttling
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Burst per Minute
</label>
<div className="space-y-4">
<label className="flex items-start">
<input
type="number"
name="burst_per_minute"
value={formData.burst_per_minute}
type="checkbox"
name="enable_throttling"
checked={formData.enable_throttling}
onChange={handleChange}
min="0"
className="input w-full"
placeholder="20"
className="mt-1 rounded border-gray-300 text-primary-600 focus:ring-primary-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Burst per Hour
</label>
<input
type="number"
name="burst_per_hour"
value={formData.burst_per_hour}
onChange={handleChange}
min="0"
className="input w-full"
placeholder="100"
/>
</div>
</div>
</div>
{/* Features */}
<div className="card p-6">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Features
</h2>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Features (one per line)
<div className="ml-3">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Enable request throttling
</span>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
When enabled, requests exceeding limits will be queued instead of immediately rejected. When disabled, requests return 429 errors.
</p>
</div>
</label>
<textarea
name="features"
value={formData.features}
onChange={handleChange}
rows={5}
className="input w-full font-mono text-sm"
placeholder="Priority support&#10;Advanced analytics&#10;Custom domains"
/>
{formData.enable_throttling && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Max Queue Time (ms)
</label>
<input
type="number"
name="max_queue_time_ms"
value={formData.max_queue_time_ms}
onChange={handleChange}
min="0"
step="100"
className="input w-full"
placeholder="5000"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Maximum time (in milliseconds) to queue a request before rejecting it
</p>
</div>
)}
</div>
</div>
+76 -163
View File
@@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation'
import Layout from '@/components/Layout'
import { ProtectedRoute } from '@/components/ProtectedRoute'
import { getJson, delJson } from '@/utils/api'
import { SERVER_URL } from '@/utils/config'
interface TierLimits {
requests_per_second?: number
@@ -18,6 +19,8 @@ interface TierLimits {
monthly_request_quota?: number
daily_request_quota?: number
monthly_bandwidth_quota?: number
enable_throttling: boolean
max_queue_time_ms: number
}
interface Tier {
@@ -49,7 +52,6 @@ export default function TiersPage() {
const [stats, setStats] = useState<TierStats[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [viewMode, setViewMode] = useState<'list' | 'comparison'>('list')
useEffect(() => {
fetchTiers()
@@ -59,12 +61,14 @@ export default function TiersPage() {
const fetchTiers = async () => {
try {
setLoading(true)
const data = await getJson('/platform/tiers')
setTiers(data)
const data = await getJson(`${SERVER_URL}/platform/tiers`)
// Ensure data is an array
setTiers(Array.isArray(data) ? data : [])
setError(null)
} catch (err) {
console.error('Failed to fetch tiers:', err)
setError('Failed to load tiers')
setTiers([]) // Reset to empty array on error
} finally {
setLoading(false)
}
@@ -72,10 +76,12 @@ export default function TiersPage() {
const fetchStats = async () => {
try {
const data = await getJson('/platform/tiers/statistics/all')
setStats(data)
const data = await getJson(`${SERVER_URL}/platform/tiers/statistics/all`)
// Ensure data is an array
setStats(Array.isArray(data) ? data : [])
} catch (err) {
console.error('Failed to fetch stats:', err)
setStats([]) // Reset to empty array on error
}
}
@@ -83,7 +89,7 @@ export default function TiersPage() {
if (!confirm('Are you sure you want to delete this tier?')) return
try {
await delJson(`/platform/tiers/${tierId}`)
await delJson(`${SERVER_URL}/platform/tiers/${tierId}`)
await fetchTiers()
} catch (err: any) {
alert(err.message || 'Failed to delete tier')
@@ -115,31 +121,15 @@ export default function TiersPage() {
Manage pricing tiers and rate limit plans
</p>
</div>
<div className="flex gap-3">
<div className="btn-group">
<button
onClick={() => setViewMode('list')}
className={`btn btn-sm ${viewMode === 'list' ? 'btn-primary' : 'btn-outline'}`}
>
List
</button>
<button
onClick={() => setViewMode('comparison')}
className={`btn btn-sm ${viewMode === 'comparison' ? 'btn-primary' : 'btn-outline'}`}
>
Compare
</button>
</div>
<button
onClick={() => router.push('/tiers/add')}
className="btn btn-primary"
>
<svg className="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Tier
</button>
</div>
<button
onClick={() => router.push('/tiers/add')}
className="btn btn-primary"
>
<svg className="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Add Tier
</button>
</div>
{loading ? (
@@ -149,10 +139,9 @@ export default function TiersPage() {
</div>
) : error ? (
<div className="card p-8 text-center text-error-600">{error}</div>
) : viewMode === 'list' ? (
/* List View */
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{tiers.map((tier) => {
{Array.isArray(tiers) && tiers.map((tier) => {
const tierStats = getTierStats(tier.tier_id)
return (
<div key={tier.tier_id} className="card p-6 hover:shadow-lg transition-shadow">
@@ -211,29 +200,22 @@ export default function TiersPage() {
)}
</div>
{/* Features */}
{tier.features.length > 0 && (
<div className="mb-4">
<p className="text-xs font-medium text-gray-700 dark:text-gray-300 mb-2">
Features:
</p>
<div className="flex flex-wrap gap-1">
{tier.features.slice(0, 3).map((feature, idx) => (
<span
key={idx}
className="inline-block px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
>
{feature}
</span>
))}
{tier.features.length > 3 && (
<span className="inline-block px-2 py-1 text-xs rounded bg-gray-100 dark:bg-gray-700 text-gray-500">
+{tier.features.length - 3} more
</span>
)}
</div>
</div>
)}
{/* Rate Limiting Status Badges */}
<div className="mb-4 flex flex-wrap gap-2">
{/* Check if rate limiting is disabled (all limits are 999999) */}
{(tier.limits.requests_per_minute ?? 0) >= 999999 &&
(tier.limits.requests_per_hour ?? 0) >= 999999 ? (
<span className="inline-block px-2 py-1 text-xs font-medium rounded bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400">
Unlimited
</span>
) : null}
{tier.limits.enable_throttling && (
<span className="inline-block px-2 py-1 text-xs font-medium rounded bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400">
🚦 Throttling Enabled
</span>
)}
</div>
{/* Stats */}
{tierStats && (
@@ -247,114 +229,45 @@ export default function TiersPage() {
</div>
)}
{/* Actions */}
<div className="flex gap-2">
<button
onClick={() => router.push(`/tiers/${tier.tier_id}/edit`)}
className="flex-1 btn btn-sm btn-outline"
>
Edit
</button>
<button
onClick={() => handleDelete(tier.tier_id)}
className="flex-1 btn btn-sm btn-outline text-error-600 hover:bg-error-50 dark:hover:bg-error-900/20"
disabled={tier.is_default}
>
Delete
</button>
</div>
{!tier.enabled && (
<p className="mt-2 text-xs text-center text-gray-500 dark:text-gray-400">
Disabled
</p>
)}
{/* Actions */}
<div className="flex gap-2">
<button
onClick={() => router.push(`/tiers/${tier.tier_id}/users`)}
className="flex-1 btn btn-sm btn-primary"
title="View assigned users"
>
<svg className="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
Users ({tierStats?.total_users || 0})
</button>
<button
onClick={() => router.push(`/tiers/${tier.tier_id}/edit`)}
className="flex-1 btn btn-sm btn-outline"
>
Edit
</button>
<button
onClick={() => handleDelete(tier.tier_id)}
className="flex-1 btn btn-sm btn-outline text-error-600 hover:bg-error-50 dark:hover:bg-error-900/20"
disabled={tier.is_default}
>
Delete
</button>
</div>
)
})}
</div>
) : (
/* Comparison View */
<div className="card overflow-x-auto">
<table className="table">
<thead>
<tr>
<th>Feature</th>
{tiers.map(tier => (
<th key={tier.tier_id} className="text-center">
<div className="font-bold">{tier.display_name}</div>
{tier.price_monthly && (
<div className="text-sm font-normal text-gray-600 dark:text-gray-400">
${tier.price_monthly}/mo
</div>
)}
</th>
))}
</tr>
</thead>
<tbody>
<tr>
<td className="font-medium">Requests/Minute</td>
{tiers.map(tier => (
<td key={tier.tier_id} className="text-center">
{formatNumber(tier.limits.requests_per_minute)}
</td>
))}
</tr>
<tr>
<td className="font-medium">Requests/Hour</td>
{tiers.map(tier => (
<td key={tier.tier_id} className="text-center">
{formatNumber(tier.limits.requests_per_hour)}
</td>
))}
</tr>
<tr>
<td className="font-medium">Monthly Quota</td>
{tiers.map(tier => (
<td key={tier.tier_id} className="text-center">
{formatNumber(tier.limits.monthly_request_quota)}
</td>
))}
</tr>
<tr>
<td className="font-medium">Burst Allowance</td>
{tiers.map(tier => (
<td key={tier.tier_id} className="text-center">
{tier.limits.burst_per_minute || 0}
</td>
))}
</tr>
<tr>
<td className="font-medium">Active Users</td>
{tiers.map(tier => {
const tierStats = getTierStats(tier.tier_id)
return (
<td key={tier.tier_id} className="text-center">
{tierStats?.active_users || 0}
</td>
)
})}
</tr>
<tr>
<td className="font-medium">Actions</td>
{tiers.map(tier => (
<td key={tier.tier_id} className="text-center">
<button
onClick={() => router.push(`/tiers/${tier.tier_id}/edit`)}
className="text-primary-600 hover:text-primary-700 text-sm font-medium"
>
Edit
</button>
</td>
))}
</tr>
</tbody>
</table>
{!tier.enabled && (
<p className="mt-2 text-xs text-center text-gray-500 dark:text-gray-400">
Disabled
</p>
)}
</div>
)
})}
</div>
)}
</div>
</Layout>
</ProtectedRoute>
</div>
</Layout>
</ProtectedRoute>
)
}
@@ -14,6 +14,7 @@ interface User {
username: string
email: string
role: string
tier_id?: string
groups: string[]
rate_limit_duration: number
rate_limit_duration_type: string
@@ -37,6 +38,7 @@ interface UpdateUserData {
email?: string
password?: string
role?: string
tier_id?: string
groups?: string[]
rate_limit_duration?: number
rate_limit_duration_type?: string
@@ -71,10 +73,27 @@ const UserDetailPage = () => {
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [deleteConfirmation, setDeleteConfirmation] = useState('')
const [deleting, setDeleting] = useState(false)
const [availableTiers, setAvailableTiers] = useState<any[]>([])
const [currentTier, setCurrentTier] = useState<any>(null)
const isProtected = PROTECTED_USERS.includes((username || '').toLowerCase())
const currentCustomAttrs = (isEditing ? (editData.custom_attributes || {}) : (user?.custom_attributes || {})) as Record<string, string>
const editCustomAttrCount = Object.keys(currentCustomAttrs).length
useEffect(() => {
// Fetch available tiers
const fetchTiers = async () => {
try {
const tiers = await fetchJson(`${SERVER_URL}/platform/tiers`)
// Ensure tiers is an array
setAvailableTiers(Array.isArray(tiers) ? tiers : [])
} catch (err) {
console.error('Failed to fetch tiers:', err)
setAvailableTiers([]) // Reset to empty array on error
}
}
fetchTiers()
}, [])
useEffect(() => {
const userData = sessionStorage.getItem('selectedUser')
if (userData) {
@@ -85,6 +104,7 @@ const UserDetailPage = () => {
username: parsedUser.username,
email: parsedUser.email,
role: parsedUser.role,
tier_id: parsedUser.tier_id,
groups: [...parsedUser.groups],
rate_limit_duration: parsedUser.rate_limit_duration,
rate_limit_duration_type: parsedUser.rate_limit_duration_type,
@@ -110,12 +130,29 @@ const UserDetailPage = () => {
sessionStorage.setItem('selectedUser', JSON.stringify(refreshed))
setEditData(prev => ({
...prev,
tier_id: refreshed.tier_id,
bandwidth_limit_bytes: refreshed.bandwidth_limit_bytes,
bandwidth_limit_window: refreshed.bandwidth_limit_window,
bandwidth_limit_enabled: Boolean((refreshed as any).bandwidth_limit_enabled),
rate_limit_enabled: Boolean((refreshed as any).rate_limit_enabled),
throttle_enabled: Boolean((refreshed as any).throttle_enabled),
}))
// Fetch current tier if assigned
if (refreshed.tier_id) {
try {
const tier = await fetchJson(`${SERVER_URL}/platform/tiers/${refreshed.tier_id}`)
// Ensure tier is a valid object with tier_id
if (tier && typeof tier === 'object' && tier.tier_id) {
setCurrentTier(tier)
} else {
setCurrentTier(null)
}
} catch {
setCurrentTier(null)
}
} else {
setCurrentTier(null)
}
} catch {}
})()
} catch (err) {
@@ -147,6 +184,7 @@ const UserDetailPage = () => {
username: user.username,
email: user.email,
role: user.role,
tier_id: user.tier_id,
groups: [...user.groups],
rate_limit_duration: user.rate_limit_duration,
rate_limit_duration_type: user.rate_limit_duration_type,
@@ -504,6 +542,35 @@ const UserDetailPage = () => {
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Tier
<InfoTooltip text="Assign user to a pricing tier. Tier limits take priority over custom rate limits." />
</label>
{isEditing ? (
<select
value={editData.tier_id || ''}
onChange={(e) => handleInputChange('tier_id', e.target.value || undefined)}
className="input"
>
<option value="">No Tier (Use custom rate limits)</option>
{Array.isArray(availableTiers) && availableTiers.map((tier) => (
<option key={tier.tier_id} value={tier.tier_id}>
{tier.display_name || tier.name} - {tier.limits?.requests_per_minute || 0} req/min
</option>
))}
</select>
) : (
<>
{currentTier ? (
<span className="badge badge-success">{currentTier.display_name || currentTier.name}</span>
) : (
<span className="badge badge-gray">No Tier</span>
)}
</>
)}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Status
+3 -1
View File
@@ -25,9 +25,11 @@ const menuItems: MenuItem[] = [
{ label: 'Routings', href: '/routings', icon: 'M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4', permission: 'manage_routings' },
{ label: 'Logging', href: '/logging', icon: 'M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2', permission: 'view_logs' },
{ label: 'Monitor', href: '/monitor', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z', permission: 'manage_gateway' },
{ label: 'Analytics', href: '/analytics', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z', permission: 'view_analytics' },
{ label: 'Analytics', href: '/analytics', icon: 'M13 7h8m0 0v8m0-8l-8 8-4-4-6 6', permission: 'view_analytics' },
{ label: 'Reports', href: '/reports', icon: 'M9 17v-6h13M9 7h13M5 7h.01M5 17h.01', permission: 'manage_gateway' },
{ label: 'Credits', href: '/credits', icon: 'M12 8c-1.657 0-3 1.343-3 3 0 2.239 3 5 3 5s3-2.761 3-5c0-1.657-1.343-3-3-3z M12 13a2 2 0 110-4 2 2 0 010 4z', permission: 'manage_credits' },
{ label: 'Tiers', href: '/tiers', icon: 'M3 10h18M3 14h18m-9-4v8m-7 0h14a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z', permission: 'manage_tiers' },
{ label: 'Rate Limits', href: '/rate-limits', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z', permission: 'manage_rate_limits' },
{ label: 'Subscriptions', href: '/authorizations', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z', permission: 'manage_subscriptions' },
{ label: 'Auth Control', href: '/auth-admin', icon: 'M5 13l4 4L19 7', permission: 'manage_auth' },
{ label: 'Security', href: '/security', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z', permission: 'manage_security' },