mirror of
https://github.com/apidoorman/doorman.git
synced 2026-05-05 07:39:12 -05:00
updated rate limiting, added analytics dash, added tiers.
This commit is contained in:
Generated
+395
-5
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
@@ -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' }
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 Advanced analytics 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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' },
|
||||
|
||||
Reference in New Issue
Block a user