mirror of
https://github.com/fosrl/pangolin.git
synced 2026-03-09 18:09:11 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91b7ceb2cf | ||
|
|
d4b830b9bb | ||
|
|
14d6ff25a7 | ||
|
|
1f62f305ce | ||
|
|
cebcf3e337 | ||
|
|
4cfcc64481 | ||
|
|
1a2069a6d9 |
187
package-lock.json
generated
187
package-lock.json
generated
@@ -57,7 +57,7 @@
|
||||
"d3": "7.9.0",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.2.1",
|
||||
"express-rate-limit": "8.2.2",
|
||||
"glob": "13.0.6",
|
||||
"helmet": "8.1.0",
|
||||
"http-errors": "2.0.1",
|
||||
@@ -87,6 +87,7 @@
|
||||
"react-icons": "5.5.0",
|
||||
"recharts": "2.15.4",
|
||||
"reodotdev": "1.0.0",
|
||||
"resend": "6.9.2",
|
||||
"semver": "7.7.4",
|
||||
"sshpk": "^1.18.0",
|
||||
"stripe": "20.3.1",
|
||||
@@ -1034,13 +1035,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder": {
|
||||
"version": "3.972.5",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.5.tgz",
|
||||
"integrity": "sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA==",
|
||||
"version": "3.972.10",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.10.tgz",
|
||||
"integrity": "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.12.0",
|
||||
"fast-xml-parser": "5.3.6",
|
||||
"@smithy/types": "^4.13.0",
|
||||
"fast-xml-parser": "5.4.1",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1086,7 +1087,6 @@
|
||||
"integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.26.2",
|
||||
@@ -2809,7 +2809,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2832,7 +2831,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2855,7 +2853,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2872,7 +2869,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2889,7 +2885,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2906,7 +2901,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2923,7 +2917,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2940,7 +2933,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2957,7 +2949,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2974,7 +2965,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2991,7 +2981,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3008,7 +2997,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3031,7 +3019,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3054,7 +3041,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3077,7 +3063,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3100,7 +3085,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3123,7 +3107,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3146,7 +3129,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3169,7 +3151,6 @@
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -3189,7 +3170,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3209,7 +3189,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3229,7 +3208,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3489,7 +3467,6 @@
|
||||
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
@@ -7920,7 +7897,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz",
|
||||
"integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -8487,9 +8463,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@smithy/types": {
|
||||
"version": "4.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.12.0.tgz",
|
||||
"integrity": "sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==",
|
||||
"version": "4.13.0",
|
||||
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz",
|
||||
"integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.2"
|
||||
@@ -8737,6 +8713,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stablelib/base64": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
@@ -9333,7 +9315,6 @@
|
||||
"version": "5.90.21",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz",
|
||||
"integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.20"
|
||||
},
|
||||
@@ -9449,7 +9430,6 @@
|
||||
"integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
@@ -9790,7 +9770,6 @@
|
||||
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/body-parser": "*",
|
||||
"@types/express-serve-static-core": "^5.0.0",
|
||||
@@ -9885,7 +9864,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
|
||||
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~7.16.0"
|
||||
}
|
||||
@@ -9913,7 +9891,6 @@
|
||||
"integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
@@ -9939,7 +9916,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"devOptional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -9950,7 +9926,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -10037,7 +10012,8 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
@@ -10108,7 +10084,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.55.0.tgz",
|
||||
"integrity": "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw==",
|
||||
"dev": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.55.0",
|
||||
"@typescript-eslint/types": "8.55.0",
|
||||
@@ -10613,7 +10588,6 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -11078,7 +11052,6 @@
|
||||
"integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.26.0"
|
||||
}
|
||||
@@ -11145,7 +11118,6 @@
|
||||
"integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bindings": "^1.5.0",
|
||||
"prebuild-install": "^7.1.1"
|
||||
@@ -11272,7 +11244,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -12226,7 +12197,6 @@
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
@@ -12667,6 +12637,7 @@
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
|
||||
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"peer": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@@ -13780,7 +13751,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
@@ -13879,7 +13849,6 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -14065,7 +14034,6 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@@ -14385,7 +14353,6 @@
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"accepts": "^2.0.0",
|
||||
"body-parser": "^2.2.1",
|
||||
@@ -14425,12 +14392,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
|
||||
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
|
||||
"version": "8.2.2",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.2.tgz",
|
||||
"integrity": "sha512-Ybv7bqtOgA914MLwaHWVFXMpMYeR1MQu/D+z2MaLYteqBsTIp9sY3AU7mGNLMJv8eLg8uQMpE20I+L2Lv49nSg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.0.1"
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -14527,6 +14494,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-sha256": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||
"license": "Unlicense"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
@@ -14544,10 +14517,22 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fast-xml-builder": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
|
||||
"integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.3.6",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.6.tgz",
|
||||
"integrity": "sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==",
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz",
|
||||
"integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -14556,6 +14541,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-xml-builder": "^1.0.0",
|
||||
"strnum": "^2.1.2"
|
||||
},
|
||||
"bin": {
|
||||
@@ -15517,9 +15503,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
|
||||
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -16892,6 +16878,7 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -16902,6 +16889,7 @@
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz",
|
||||
"integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
@@ -16989,7 +16977,6 @@
|
||||
"version": "15.5.12",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz",
|
||||
"integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@next/env": "15.5.12",
|
||||
"@swc/helpers": "0.5.15",
|
||||
@@ -17924,7 +17911,6 @@
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.19.0.tgz",
|
||||
"integrity": "sha512-QIcLGi508BAHkQ3pJNptsFz5WQMlpGbuBGBaIaXsWK8mel2kQ/rThYI+DbgjUvZrIr7MiuEuc9LcChJoEZK1xQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.11.0",
|
||||
"pg-pool": "^3.12.0",
|
||||
@@ -18069,6 +18055,11 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/postal-mime": {
|
||||
"version": "2.7.3",
|
||||
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz",
|
||||
"integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw=="
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
@@ -18415,7 +18406,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -18445,7 +18435,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -19285,7 +19274,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz",
|
||||
"integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -19565,6 +19553,26 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resend": {
|
||||
"version": "6.9.2",
|
||||
"resolved": "https://registry.npmjs.org/resend/-/resend-6.9.2.tgz",
|
||||
"integrity": "sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==",
|
||||
"dependencies": {
|
||||
"postal-mime": "2.7.3",
|
||||
"svix": "1.84.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-email/render": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@react-email/render": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@@ -20339,6 +20347,16 @@
|
||||
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/state-local": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz",
|
||||
@@ -20569,9 +20587,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strnum": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
|
||||
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -20646,6 +20664,29 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svix": {
|
||||
"version": "1.84.1",
|
||||
"resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz",
|
||||
"integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"standardwebhooks": "1.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/svix/node_modules/uuid": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
|
||||
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
|
||||
"funding": [
|
||||
"https://github.com/sponsors/broofa",
|
||||
"https://github.com/sponsors/ctavan"
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/swagger-ui-dist": {
|
||||
"version": "5.30.3",
|
||||
"resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.30.3.tgz",
|
||||
@@ -20703,8 +20744,7 @@
|
||||
"version": "4.1.18",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz",
|
||||
"integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.0",
|
||||
@@ -21178,7 +21218,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -21605,7 +21644,6 @@
|
||||
"resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz",
|
||||
"integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@colors/colors": "^1.6.0",
|
||||
"@dabh/diagnostics": "^2.0.8",
|
||||
@@ -21812,7 +21850,6 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
"d3": "7.9.0",
|
||||
"drizzle-orm": "0.45.1",
|
||||
"express": "5.2.1",
|
||||
"express-rate-limit": "8.2.1",
|
||||
"express-rate-limit": "8.2.2",
|
||||
"glob": "13.0.6",
|
||||
"helmet": "8.1.0",
|
||||
"http-errors": "2.0.1",
|
||||
@@ -110,6 +110,7 @@
|
||||
"react-icons": "5.5.0",
|
||||
"recharts": "2.15.4",
|
||||
"reodotdev": "1.0.0",
|
||||
"resend": "6.9.2",
|
||||
"semver": "7.7.4",
|
||||
"sshpk": "^1.18.0",
|
||||
"stripe": "20.3.1",
|
||||
|
||||
16
server/lib/resend.ts
Normal file
16
server/lib/resend.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export enum AudienceIds {
|
||||
SignUps = "",
|
||||
Subscribed = "",
|
||||
Churned = "",
|
||||
Newsletter = ""
|
||||
}
|
||||
|
||||
let resend;
|
||||
export default resend;
|
||||
|
||||
export async function moveEmailToAudience(
|
||||
email: string,
|
||||
audienceId: AudienceIds
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import { resources, sites, Target, targets } from "@server/db";
|
||||
import createPathRewriteMiddleware from "./middleware";
|
||||
import { sanitize, encodePath, validatePathRewriteConfig } from "./utils";
|
||||
import { sanitize, validatePathRewriteConfig } from "./utils";
|
||||
|
||||
const redirectHttpsMiddlewareName = "redirect-to-https";
|
||||
const badgerMiddlewareName = "badger";
|
||||
@@ -44,7 +44,7 @@ export async function getTraefikConfig(
|
||||
filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE
|
||||
generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE
|
||||
allowRawResources = true,
|
||||
allowMaintenancePage = true // UNUSED BUT USED IN PRIVATE
|
||||
allowMaintenancePage = true, // UNUSED BUT USED IN PRIVATE
|
||||
): Promise<any> {
|
||||
// Get resources with their targets and sites in a single optimized query
|
||||
// Start from sites on this exit node, then join to targets and resources
|
||||
@@ -127,7 +127,7 @@ export async function getTraefikConfig(
|
||||
resourcesWithTargetsAndSites.forEach((row) => {
|
||||
const resourceId = row.resourceId;
|
||||
const resourceName = sanitize(row.resourceName) || "";
|
||||
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
|
||||
const targetPath = sanitize(row.path) || ""; // Handle null/undefined paths
|
||||
const pathMatchType = row.pathMatchType || "";
|
||||
const rewritePath = row.rewritePath || "";
|
||||
const rewritePathType = row.rewritePathType || "";
|
||||
@@ -145,7 +145,7 @@ export async function getTraefikConfig(
|
||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||
const key = sanitize(mapKey);
|
||||
|
||||
if (!resourcesMap.has(mapKey)) {
|
||||
if (!resourcesMap.has(key)) {
|
||||
const validation = validatePathRewriteConfig(
|
||||
row.path,
|
||||
row.pathMatchType,
|
||||
@@ -160,10 +160,9 @@ export async function getTraefikConfig(
|
||||
return;
|
||||
}
|
||||
|
||||
resourcesMap.set(mapKey, {
|
||||
resourcesMap.set(key, {
|
||||
resourceId: row.resourceId,
|
||||
name: resourceName,
|
||||
key: key,
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
http: row.http,
|
||||
@@ -191,7 +190,7 @@ export async function getTraefikConfig(
|
||||
});
|
||||
}
|
||||
|
||||
resourcesMap.get(mapKey).targets.push({
|
||||
resourcesMap.get(key).targets.push({
|
||||
resourceId: row.resourceId,
|
||||
targetId: row.targetId,
|
||||
ip: row.ip,
|
||||
@@ -228,9 +227,8 @@ export async function getTraefikConfig(
|
||||
};
|
||||
|
||||
// get the key and the resource
|
||||
for (const [, resource] of resourcesMap.entries()) {
|
||||
for (const [key, resource] of resourcesMap.entries()) {
|
||||
const targets = resource.targets as TargetWithSite[];
|
||||
const key = resource.key;
|
||||
|
||||
const routerName = `${key}-${resource.name}-router`;
|
||||
const serviceName = `${key}-${resource.name}-service`;
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
import { assertEquals } from "../../../test/assert";
|
||||
|
||||
// ── Pure function copies (inlined to avoid pulling in server dependencies) ──
|
||||
|
||||
function sanitize(input: string | null | undefined): string | undefined {
|
||||
if (!input) return undefined;
|
||||
if (input.length > 50) {
|
||||
input = input.substring(0, 50);
|
||||
}
|
||||
return input
|
||||
.replace(/[^a-zA-Z0-9-]/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
function encodePath(path: string | null | undefined): string {
|
||||
if (!path) return "";
|
||||
return path.replace(/[^a-zA-Z0-9]/g, (ch) => {
|
||||
return ch.charCodeAt(0).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Exact replica of the OLD key computation from upstream main.
|
||||
* Uses sanitize() for paths — this is what had the collision bug.
|
||||
*/
|
||||
function oldKeyComputation(
|
||||
resourceId: number,
|
||||
path: string | null,
|
||||
pathMatchType: string | null,
|
||||
rewritePath: string | null,
|
||||
rewritePathType: string | null
|
||||
): string {
|
||||
const targetPath = sanitize(path) || "";
|
||||
const pmt = pathMatchType || "";
|
||||
const rp = rewritePath || "";
|
||||
const rpt = rewritePathType || "";
|
||||
const pathKey = [targetPath, pmt, rp, rpt].filter(Boolean).join("-");
|
||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||
return sanitize(mapKey) || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Replica of the NEW key computation from our fix.
|
||||
* Uses encodePath() for paths — collision-free.
|
||||
*/
|
||||
function newKeyComputation(
|
||||
resourceId: number,
|
||||
path: string | null,
|
||||
pathMatchType: string | null,
|
||||
rewritePath: string | null,
|
||||
rewritePathType: string | null
|
||||
): string {
|
||||
const targetPath = encodePath(path);
|
||||
const pmt = pathMatchType || "";
|
||||
const rp = rewritePath || "";
|
||||
const rpt = rewritePathType || "";
|
||||
const pathKey = [targetPath, pmt, rp, rpt].filter(Boolean).join("-");
|
||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||
return sanitize(mapKey) || "";
|
||||
}
|
||||
|
||||
// ── Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
function runTests() {
|
||||
console.log("Running path encoding tests...\n");
|
||||
|
||||
let passed = 0;
|
||||
|
||||
// ── encodePath unit tests ────────────────────────────────────────
|
||||
|
||||
// Test 1: null/undefined/empty
|
||||
{
|
||||
assertEquals(encodePath(null), "", "null should return empty");
|
||||
assertEquals(
|
||||
encodePath(undefined),
|
||||
"",
|
||||
"undefined should return empty"
|
||||
);
|
||||
assertEquals(encodePath(""), "", "empty string should return empty");
|
||||
console.log(" PASS: encodePath handles null/undefined/empty");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 2: root path
|
||||
{
|
||||
assertEquals(encodePath("/"), "2f", "/ should encode to 2f");
|
||||
console.log(" PASS: encodePath encodes root path");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 3: alphanumeric passthrough
|
||||
{
|
||||
assertEquals(encodePath("/api"), "2fapi", "/api encodes slash only");
|
||||
assertEquals(encodePath("/v1"), "2fv1", "/v1 encodes slash only");
|
||||
assertEquals(encodePath("abc"), "abc", "plain alpha passes through");
|
||||
console.log(" PASS: encodePath preserves alphanumeric chars");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 4: all special chars produce unique hex
|
||||
{
|
||||
const paths = ["/a/b", "/a-b", "/a.b", "/a_b", "/a b"];
|
||||
const results = paths.map((p) => encodePath(p));
|
||||
const unique = new Set(results);
|
||||
assertEquals(
|
||||
unique.size,
|
||||
paths.length,
|
||||
"all special-char paths must produce unique encodings"
|
||||
);
|
||||
console.log(
|
||||
" PASS: encodePath produces unique output for different special chars"
|
||||
);
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 5: output is always alphanumeric (safe for Traefik names)
|
||||
{
|
||||
const paths = [
|
||||
"/",
|
||||
"/api",
|
||||
"/a/b",
|
||||
"/a-b",
|
||||
"/a.b",
|
||||
"/complex/path/here"
|
||||
];
|
||||
for (const p of paths) {
|
||||
const e = encodePath(p);
|
||||
assertEquals(
|
||||
/^[a-zA-Z0-9]+$/.test(e),
|
||||
true,
|
||||
`encodePath("${p}") = "${e}" must be alphanumeric`
|
||||
);
|
||||
}
|
||||
console.log(" PASS: encodePath output is always alphanumeric");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 6: deterministic
|
||||
{
|
||||
assertEquals(
|
||||
encodePath("/api"),
|
||||
encodePath("/api"),
|
||||
"same input same output"
|
||||
);
|
||||
assertEquals(
|
||||
encodePath("/a/b/c"),
|
||||
encodePath("/a/b/c"),
|
||||
"same input same output"
|
||||
);
|
||||
console.log(" PASS: encodePath is deterministic");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 7: many distinct paths never collide
|
||||
{
|
||||
const paths = [
|
||||
"/",
|
||||
"/api",
|
||||
"/api/v1",
|
||||
"/api/v2",
|
||||
"/a/b",
|
||||
"/a-b",
|
||||
"/a.b",
|
||||
"/a_b",
|
||||
"/health",
|
||||
"/health/check",
|
||||
"/admin",
|
||||
"/admin/users",
|
||||
"/api/v1/users",
|
||||
"/api/v1/posts",
|
||||
"/app",
|
||||
"/app/dashboard"
|
||||
];
|
||||
const encoded = new Set(paths.map((p) => encodePath(p)));
|
||||
assertEquals(
|
||||
encoded.size,
|
||||
paths.length,
|
||||
`expected ${paths.length} unique encodings, got ${encoded.size}`
|
||||
);
|
||||
console.log(" PASS: 16 realistic paths all produce unique encodings");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// ── Collision fix: the actual bug we're fixing ───────────────────
|
||||
|
||||
// Test 8: /a/b and /a-b now have different keys (THE BUG FIX)
|
||||
{
|
||||
const keyAB = newKeyComputation(1, "/a/b", "prefix", null, null);
|
||||
const keyDash = newKeyComputation(1, "/a-b", "prefix", null, null);
|
||||
assertEquals(
|
||||
keyAB !== keyDash,
|
||||
true,
|
||||
"/a/b and /a-b MUST have different keys"
|
||||
);
|
||||
console.log(" PASS: collision fix — /a/b vs /a-b have different keys");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 9: demonstrate the old bug — old code maps /a/b and /a-b to same key
|
||||
{
|
||||
const oldKeyAB = oldKeyComputation(1, "/a/b", "prefix", null, null);
|
||||
const oldKeyDash = oldKeyComputation(1, "/a-b", "prefix", null, null);
|
||||
assertEquals(
|
||||
oldKeyAB,
|
||||
oldKeyDash,
|
||||
"old code MUST have this collision (confirms the bug exists)"
|
||||
);
|
||||
console.log(" PASS: confirmed old code bug — /a/b and /a-b collided");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 10: /api/v1 and /api-v1 — old code collision, new code fixes it
|
||||
{
|
||||
const oldKey1 = oldKeyComputation(1, "/api/v1", "prefix", null, null);
|
||||
const oldKey2 = oldKeyComputation(1, "/api-v1", "prefix", null, null);
|
||||
assertEquals(
|
||||
oldKey1,
|
||||
oldKey2,
|
||||
"old code collision for /api/v1 vs /api-v1"
|
||||
);
|
||||
|
||||
const newKey1 = newKeyComputation(1, "/api/v1", "prefix", null, null);
|
||||
const newKey2 = newKeyComputation(1, "/api-v1", "prefix", null, null);
|
||||
assertEquals(
|
||||
newKey1 !== newKey2,
|
||||
true,
|
||||
"new code must separate /api/v1 and /api-v1"
|
||||
);
|
||||
console.log(" PASS: collision fix — /api/v1 vs /api-v1");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 11: /app.v2 and /app/v2 and /app-v2 — three-way collision fixed
|
||||
{
|
||||
const a = newKeyComputation(1, "/app.v2", "prefix", null, null);
|
||||
const b = newKeyComputation(1, "/app/v2", "prefix", null, null);
|
||||
const c = newKeyComputation(1, "/app-v2", "prefix", null, null);
|
||||
const keys = new Set([a, b, c]);
|
||||
assertEquals(
|
||||
keys.size,
|
||||
3,
|
||||
"three paths must produce three unique keys"
|
||||
);
|
||||
console.log(
|
||||
" PASS: collision fix — three-way /app.v2, /app/v2, /app-v2"
|
||||
);
|
||||
passed++;
|
||||
}
|
||||
|
||||
// ── Edge cases ───────────────────────────────────────────────────
|
||||
|
||||
// Test 12: same path in different resources — always separate
|
||||
{
|
||||
const key1 = newKeyComputation(1, "/api", "prefix", null, null);
|
||||
const key2 = newKeyComputation(2, "/api", "prefix", null, null);
|
||||
assertEquals(
|
||||
key1 !== key2,
|
||||
true,
|
||||
"different resources with same path must have different keys"
|
||||
);
|
||||
console.log(" PASS: edge case — same path, different resources");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 13: same resource, different pathMatchType — separate keys
|
||||
{
|
||||
const exact = newKeyComputation(1, "/api", "exact", null, null);
|
||||
const prefix = newKeyComputation(1, "/api", "prefix", null, null);
|
||||
assertEquals(
|
||||
exact !== prefix,
|
||||
true,
|
||||
"exact vs prefix must have different keys"
|
||||
);
|
||||
console.log(" PASS: edge case — same path, different match types");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 14: same resource and path, different rewrite config — separate keys
|
||||
{
|
||||
const noRewrite = newKeyComputation(1, "/api", "prefix", null, null);
|
||||
const withRewrite = newKeyComputation(
|
||||
1,
|
||||
"/api",
|
||||
"prefix",
|
||||
"/backend",
|
||||
"prefix"
|
||||
);
|
||||
assertEquals(
|
||||
noRewrite !== withRewrite,
|
||||
true,
|
||||
"with vs without rewrite must have different keys"
|
||||
);
|
||||
console.log(" PASS: edge case — same path, different rewrite config");
|
||||
passed++;
|
||||
}
|
||||
|
||||
// Test 15: paths with special URL characters
|
||||
{
|
||||
const paths = ["/api?foo", "/api#bar", "/api%20baz", "/api+qux"];
|
||||
const keys = new Set(
|
||||
paths.map((p) => newKeyComputation(1, p, "prefix", null, null))
|
||||
);
|
||||
assertEquals(
|
||||
keys.size,
|
||||
paths.length,
|
||||
"special URL chars must produce unique keys"
|
||||
);
|
||||
console.log(" PASS: edge case — special URL characters in paths");
|
||||
passed++;
|
||||
}
|
||||
|
||||
console.log(`\nAll ${passed} tests passed!`);
|
||||
}
|
||||
|
||||
try {
|
||||
runTests();
|
||||
} catch (error) {
|
||||
console.error("Test failed:", error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -13,26 +13,6 @@ export function sanitize(input: string | null | undefined): string | undefined {
|
||||
.replace(/^-|-$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a URL path into a collision-free alphanumeric string suitable for use
|
||||
* in Traefik map keys.
|
||||
*
|
||||
* Unlike sanitize(), this preserves uniqueness by encoding each non-alphanumeric
|
||||
* character as its hex code. Different paths always produce different outputs.
|
||||
*
|
||||
* encodePath("/api") => "2fapi"
|
||||
* encodePath("/a/b") => "2fa2fb"
|
||||
* encodePath("/a-b") => "2fa2db" (different from /a/b)
|
||||
* encodePath("/") => "2f"
|
||||
* encodePath(null) => ""
|
||||
*/
|
||||
export function encodePath(path: string | null | undefined): string {
|
||||
if (!path) return "";
|
||||
return path.replace(/[^a-zA-Z0-9]/g, (ch) => {
|
||||
return ch.charCodeAt(0).toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
export function validatePathRewriteConfig(
|
||||
path: string | null,
|
||||
pathMatchType: string | null,
|
||||
|
||||
@@ -38,6 +38,10 @@ export const privateConfigSchema = z.object({
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")),
|
||||
resend_api_key: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform(getEnvOrYaml("RESEND_API_KEY")),
|
||||
reo_client_id: z
|
||||
.string()
|
||||
.optional()
|
||||
|
||||
127
server/private/lib/resend.ts
Normal file
127
server/private/lib/resend.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/*
|
||||
* This file is part of a proprietary work.
|
||||
*
|
||||
* Copyright (c) 2025 Fossorial, Inc.
|
||||
* All rights reserved.
|
||||
*
|
||||
* This file is licensed under the Fossorial Commercial License.
|
||||
* You may not use this file except in compliance with the License.
|
||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||
*
|
||||
* This file is not licensed under the AGPLv3.
|
||||
*/
|
||||
|
||||
import { Resend } from "resend";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import logger from "@server/logger";
|
||||
|
||||
export enum AudienceIds {
|
||||
SignUps = "6c4e77b2-0851-4bd6-bac8-f51f91360f1a",
|
||||
Subscribed = "870b43fd-387f-44de-8fc1-707335f30b20",
|
||||
Churned = "f3ae92bd-2fdb-4d77-8746-2118afd62549",
|
||||
Newsletter = "5500c431-191c-42f0-a5d4-8b6d445b4ea0"
|
||||
}
|
||||
|
||||
const resend = new Resend(
|
||||
privateConfig.getRawPrivateConfig().server.resend_api_key || "missing"
|
||||
);
|
||||
|
||||
export default resend;
|
||||
|
||||
export async function moveEmailToAudience(
|
||||
email: string,
|
||||
audienceId: AudienceIds
|
||||
) {
|
||||
if (process.env.ENVIRONMENT !== "prod") {
|
||||
logger.debug(
|
||||
`Skipping moving email ${email} to audience ${audienceId} in non-prod environment`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const { error, data } = await retryWithBackoff(async () => {
|
||||
const { data, error } = await resend.contacts.create({
|
||||
email,
|
||||
unsubscribed: false,
|
||||
audienceId
|
||||
});
|
||||
if (error) {
|
||||
throw new Error(
|
||||
`Error adding email ${email} to audience ${audienceId}: ${error}`
|
||||
);
|
||||
}
|
||||
return { error, data };
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error(
|
||||
`Error adding email ${email} to audience ${audienceId}: ${error}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
logger.debug(
|
||||
`Added email ${email} to audience ${audienceId} with contact ID ${data.id}`
|
||||
);
|
||||
}
|
||||
|
||||
const otherAudiences = Object.values(AudienceIds).filter(
|
||||
(id) => id !== audienceId
|
||||
);
|
||||
|
||||
for (const otherAudienceId of otherAudiences) {
|
||||
const { error, data } = await retryWithBackoff(async () => {
|
||||
const { data, error } = await resend.contacts.remove({
|
||||
email,
|
||||
audienceId: otherAudienceId
|
||||
});
|
||||
if (error) {
|
||||
throw new Error(
|
||||
`Error removing email ${email} from audience ${otherAudienceId}: ${error}`
|
||||
);
|
||||
}
|
||||
return { error, data };
|
||||
});
|
||||
|
||||
if (error) {
|
||||
logger.error(
|
||||
`Error removing email ${email} from audience ${otherAudienceId}: ${error}`
|
||||
);
|
||||
}
|
||||
|
||||
if (data) {
|
||||
logger.info(
|
||||
`Removed email ${email} from audience ${otherAudienceId}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type RetryOptions = {
|
||||
retries?: number;
|
||||
initialDelayMs?: number;
|
||||
factor?: number;
|
||||
};
|
||||
|
||||
export async function retryWithBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: RetryOptions = {}
|
||||
): Promise<T> {
|
||||
const { retries = 5, initialDelayMs = 500, factor = 2 } = options;
|
||||
|
||||
let attempt = 0;
|
||||
let delay = initialDelayMs;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
attempt++;
|
||||
|
||||
if (attempt > retries) throw err;
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
delay *= factor;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,7 @@ import {
|
||||
import logger from "@server/logger";
|
||||
import config from "@server/lib/config";
|
||||
import { orgs, resources, sites, Target, targets } from "@server/db";
|
||||
import {
|
||||
sanitize,
|
||||
encodePath,
|
||||
validatePathRewriteConfig
|
||||
} from "@server/lib/traefik/utils";
|
||||
import { sanitize, validatePathRewriteConfig } from "@server/lib/traefik/utils";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import createPathRewriteMiddleware from "@server/lib/traefik/middleware";
|
||||
import {
|
||||
@@ -174,7 +170,7 @@ export async function getTraefikConfig(
|
||||
resourcesWithTargetsAndSites.forEach((row) => {
|
||||
const resourceId = row.resourceId;
|
||||
const resourceName = sanitize(row.resourceName) || "";
|
||||
const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b")
|
||||
const targetPath = sanitize(row.path) || ""; // Handle null/undefined paths
|
||||
const pathMatchType = row.pathMatchType || "";
|
||||
const rewritePath = row.rewritePath || "";
|
||||
const rewritePathType = row.rewritePathType || "";
|
||||
@@ -196,7 +192,7 @@ export async function getTraefikConfig(
|
||||
const mapKey = [resourceId, pathKey].filter(Boolean).join("-");
|
||||
const key = sanitize(mapKey);
|
||||
|
||||
if (!resourcesMap.has(mapKey)) {
|
||||
if (!resourcesMap.has(key)) {
|
||||
const validation = validatePathRewriteConfig(
|
||||
row.path,
|
||||
row.pathMatchType,
|
||||
@@ -211,10 +207,9 @@ export async function getTraefikConfig(
|
||||
return;
|
||||
}
|
||||
|
||||
resourcesMap.set(mapKey, {
|
||||
resourcesMap.set(key, {
|
||||
resourceId: row.resourceId,
|
||||
name: resourceName,
|
||||
key: key,
|
||||
fullDomain: row.fullDomain,
|
||||
ssl: row.ssl,
|
||||
http: row.http,
|
||||
@@ -248,7 +243,7 @@ export async function getTraefikConfig(
|
||||
}
|
||||
|
||||
// Add target with its associated site data
|
||||
resourcesMap.get(mapKey).targets.push({
|
||||
resourcesMap.get(key).targets.push({
|
||||
resourceId: row.resourceId,
|
||||
targetId: row.targetId,
|
||||
ip: row.ip,
|
||||
@@ -301,9 +296,8 @@ export async function getTraefikConfig(
|
||||
};
|
||||
|
||||
// get the key and the resource
|
||||
for (const [, resource] of resourcesMap.entries()) {
|
||||
for (const [key, resource] of resourcesMap.entries()) {
|
||||
const targets = resource.targets as TargetWithSite[];
|
||||
const key = resource.key;
|
||||
|
||||
const routerName = `${key}-${resource.name}-router`;
|
||||
const serviceName = `${key}-${resource.name}-service`;
|
||||
|
||||
@@ -24,6 +24,7 @@ import { eq, and } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import stripe from "#private/lib/stripe";
|
||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
|
||||
import { getSubType } from "./getSubType";
|
||||
import privateConfig from "#private/lib/config";
|
||||
import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses";
|
||||
@@ -171,7 +172,7 @@ export async function handleSubscriptionCreated(
|
||||
const email = orgUserRes.user.email;
|
||||
|
||||
if (email) {
|
||||
// TODO: update user in Sendy
|
||||
moveEmailToAudience(email, AudienceIds.Subscribed);
|
||||
}
|
||||
}
|
||||
} else if (type === "license") {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import logger from "@server/logger";
|
||||
import { handleSubscriptionLifesycle } from "../subscriptionLifecycle";
|
||||
import { AudienceIds, moveEmailToAudience } from "#private/lib/resend";
|
||||
import { getSubType } from "./getSubType";
|
||||
import stripe from "#private/lib/stripe";
|
||||
import privateConfig from "#private/lib/config";
|
||||
@@ -108,7 +109,7 @@ export async function handleSubscriptionDeleted(
|
||||
const email = orgUserRes.user.email;
|
||||
|
||||
if (email) {
|
||||
// TODO: update user in Sendy
|
||||
moveEmailToAudience(email, AudienceIds.Churned);
|
||||
}
|
||||
}
|
||||
} else if (type === "license") {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { checkValidInvite } from "@server/auth/checkValidInvite";
|
||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { build } from "@server/build";
|
||||
import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend";
|
||||
|
||||
export const signupBodySchema = z.object({
|
||||
email: z.email().toLowerCase(),
|
||||
@@ -212,7 +213,7 @@ export async function signup(
|
||||
logger.debug(
|
||||
`User ${email} opted in to marketing emails during signup.`
|
||||
);
|
||||
// TODO: update user in Sendy
|
||||
moveEmailToAudience(email, AudienceIds.SignUps);
|
||||
}
|
||||
|
||||
if (config.getRawConfig().flags?.require_email_verification) {
|
||||
|
||||
@@ -188,8 +188,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
||||
hcTimeout: targetHealthCheck.hcTimeout,
|
||||
hcHeaders: targetHealthCheck.hcHeaders,
|
||||
hcMethod: targetHealthCheck.hcMethod,
|
||||
hcTlsServerName: targetHealthCheck.hcTlsServerName,
|
||||
hcStatus: targetHealthCheck.hcStatus
|
||||
hcTlsServerName: targetHealthCheck.hcTlsServerName
|
||||
})
|
||||
.from(targets)
|
||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||
@@ -262,8 +261,7 @@ export async function buildTargetConfigurationForNewtClient(siteId: number) {
|
||||
hcTimeout: target.hcTimeout, // in seconds
|
||||
hcHeaders: hcHeadersSend,
|
||||
hcMethod: target.hcMethod,
|
||||
hcTlsServerName: target.hcTlsServerName,
|
||||
hcStatus: target.hcStatus
|
||||
hcTlsServerName: target.hcTlsServerName
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -223,6 +223,20 @@ async function createHttpResource(
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent creating resource with same domain as dashboard
|
||||
const dashboardUrl = config.getRawConfig().app.dashboard_url;
|
||||
if (dashboardUrl) {
|
||||
const dashboardHost = new URL(dashboardUrl).hostname;
|
||||
if (fullDomain === dashboardHost) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Resource domain cannot be the same as the dashboard domain"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (build != "oss") {
|
||||
const existingLoginPages = await db
|
||||
.select()
|
||||
|
||||
@@ -353,6 +353,20 @@ async function updateHttpResource(
|
||||
);
|
||||
}
|
||||
|
||||
// Prevent updating resource with same domain as dashboard
|
||||
const dashboardUrl = config.getRawConfig().app.dashboard_url;
|
||||
if (dashboardUrl) {
|
||||
const dashboardHost = new URL(dashboardUrl).hostname;
|
||||
if (fullDomain === dashboardHost) {
|
||||
return next(
|
||||
createHttpError(
|
||||
HttpCode.CONFLICT,
|
||||
"Resource domain cannot be the same as the dashboard domain"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (build != "oss") {
|
||||
const existingLoginPages = await db
|
||||
.select()
|
||||
|
||||
@@ -35,7 +35,11 @@ import {
|
||||
} from "@app/components/Credenza";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react";
|
||||
import { Alert, AlertTitle, AlertDescription } from "@app/components/ui/alert";
|
||||
import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
AlertDescription
|
||||
} from "@app/components/ui/alert";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
@@ -65,7 +69,6 @@ type PlanOption = {
|
||||
price: string;
|
||||
priceDetail?: string;
|
||||
tierType: Tier | null;
|
||||
features: string[];
|
||||
};
|
||||
|
||||
const planOptions: PlanOption[] = [
|
||||
@@ -73,87 +76,41 @@ const planOptions: PlanOption[] = [
|
||||
id: "basic",
|
||||
name: "Basic",
|
||||
price: "Free",
|
||||
tierType: null,
|
||||
features: [
|
||||
"Basic Pangolin features",
|
||||
"Free provided domains",
|
||||
"Web-based proxy resources",
|
||||
"Private resources and clients",
|
||||
"Peer-to-peer connections"
|
||||
]
|
||||
tierType: null
|
||||
},
|
||||
{
|
||||
id: "home",
|
||||
name: "Home",
|
||||
price: "$12.50",
|
||||
priceDetail: "/ month",
|
||||
tierType: "tier1",
|
||||
features: [
|
||||
"Everything in Basic",
|
||||
"OAuth2/OIDC, Google, & Azure SSO",
|
||||
"Bring your own identity provider",
|
||||
"Pangolin SSH",
|
||||
"Custom branding",
|
||||
"Device admin approvals"
|
||||
]
|
||||
tierType: "tier1"
|
||||
},
|
||||
{
|
||||
id: "team",
|
||||
name: "Team",
|
||||
price: "$4",
|
||||
priceDetail: "per user / month",
|
||||
tierType: "tier2",
|
||||
features: [
|
||||
"Everything in Basic",
|
||||
"Custom domains",
|
||||
"OAuth2/OIDC, Google, & Azure SSO",
|
||||
"Access and action audit logs",
|
||||
"Device posture information"
|
||||
]
|
||||
tierType: "tier2"
|
||||
},
|
||||
{
|
||||
id: "business",
|
||||
name: "Business",
|
||||
price: "$9",
|
||||
priceDetail: "per user / month",
|
||||
tierType: "tier3",
|
||||
features: [
|
||||
"Everything in Team",
|
||||
"Multiple organizations (multi-tenancy)",
|
||||
"Auto-provisioning via IdP",
|
||||
"Pangolin SSH",
|
||||
"Device approvals",
|
||||
"Custom branding",
|
||||
"Business support"
|
||||
]
|
||||
tierType: "tier3"
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
name: "Enterprise",
|
||||
price: "Custom",
|
||||
tierType: null,
|
||||
features: [
|
||||
"Everything in Business",
|
||||
"Custom limits",
|
||||
"Priority support and SLA",
|
||||
"Log push and export",
|
||||
"Private and Gov-Cloud deployment options",
|
||||
"Dedicated, premium relay/exit nodes",
|
||||
"Pay by invoice "
|
||||
]
|
||||
tierType: null
|
||||
}
|
||||
];
|
||||
|
||||
// Tier limits mapping derived from limit sets
|
||||
const tierLimits: Record<
|
||||
Tier | "basic",
|
||||
{
|
||||
users: number;
|
||||
sites: number;
|
||||
domains: number;
|
||||
remoteNodes: number;
|
||||
organizations: number;
|
||||
}
|
||||
{ users: number; sites: number; domains: number; remoteNodes: number; organizations: number }
|
||||
> = {
|
||||
basic: {
|
||||
users: freeLimitSet[FeatureId.USERS]?.value ?? 0,
|
||||
@@ -506,43 +463,31 @@ export default function BillingPage() {
|
||||
const isProblematicState = hasProblematicSubscription();
|
||||
|
||||
// Get user-friendly subscription status message
|
||||
const getSubscriptionStatusMessage = (): {
|
||||
title: string;
|
||||
description: string;
|
||||
} | null => {
|
||||
const getSubscriptionStatusMessage = (): { title: string; description: string } | null => {
|
||||
if (!tierSubscription?.subscription || !isProblematicState) return null;
|
||||
|
||||
|
||||
const status = tierSubscription.subscription.status;
|
||||
|
||||
|
||||
switch (status) {
|
||||
case "past_due":
|
||||
return {
|
||||
title: t("billingPastDueTitle") || "Payment Past Due",
|
||||
description:
|
||||
t("billingPastDueDescription") ||
|
||||
"Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier."
|
||||
description: t("billingPastDueDescription") || "Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier."
|
||||
};
|
||||
case "unpaid":
|
||||
return {
|
||||
title: t("billingUnpaidTitle") || "Subscription Unpaid",
|
||||
description:
|
||||
t("billingUnpaidDescription") ||
|
||||
"Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription."
|
||||
description: t("billingUnpaidDescription") || "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription."
|
||||
};
|
||||
case "incomplete":
|
||||
return {
|
||||
title: t("billingIncompleteTitle") || "Payment Incomplete",
|
||||
description:
|
||||
t("billingIncompleteDescription") ||
|
||||
"Your payment is incomplete. Please complete the payment process to activate your subscription."
|
||||
description: t("billingIncompleteDescription") || "Your payment is incomplete. Please complete the payment process to activate your subscription."
|
||||
};
|
||||
case "incomplete_expired":
|
||||
return {
|
||||
title:
|
||||
t("billingIncompleteExpiredTitle") || "Payment Expired",
|
||||
description:
|
||||
t("billingIncompleteExpiredDescription") ||
|
||||
"Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features."
|
||||
title: t("billingIncompleteExpiredTitle") || "Payment Expired",
|
||||
description: t("billingIncompleteExpiredDescription") || "Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features."
|
||||
};
|
||||
default:
|
||||
return null;
|
||||
@@ -564,11 +509,7 @@ export default function BillingPage() {
|
||||
|
||||
if (plan.id === currentPlanId) {
|
||||
// If it's the basic plan (basic with no subscription), show as current but disabled
|
||||
if (
|
||||
plan.id === "basic" &&
|
||||
!hasSubscription &&
|
||||
!isProblematicState
|
||||
) {
|
||||
if (plan.id === "basic" && !hasSubscription && !isProblematicState) {
|
||||
return {
|
||||
label: "Current Plan",
|
||||
action: () => {},
|
||||
@@ -691,9 +632,7 @@ export default function BillingPage() {
|
||||
};
|
||||
|
||||
// Check if downgrading to a tier would violate current usage limits
|
||||
const checkLimitViolations = (
|
||||
targetTier: Tier | "basic"
|
||||
): Array<{
|
||||
const checkLimitViolations = (targetTier: Tier | "basic"): Array<{
|
||||
feature: string;
|
||||
currentUsage: number;
|
||||
newLimit: number;
|
||||
@@ -748,10 +687,7 @@ export default function BillingPage() {
|
||||
|
||||
// Check organizations
|
||||
const organizationsUsage = getUsageValue(ORGINIZATIONS);
|
||||
if (
|
||||
limits.organizations > 0 &&
|
||||
organizationsUsage > limits.organizations
|
||||
) {
|
||||
if (limits.organizations > 0 && organizationsUsage > limits.organizations) {
|
||||
violations.push({
|
||||
feature: "Organizations",
|
||||
currentUsage: organizationsUsage,
|
||||
@@ -776,15 +712,17 @@ export default function BillingPage() {
|
||||
{isProblematicState && statusMessage && (
|
||||
<Alert variant="destructive" className="mb-6">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>{statusMessage.title}</AlertTitle>
|
||||
<AlertTitle>
|
||||
{statusMessage.title}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{statusMessage.description}{" "}
|
||||
{statusMessage.description}
|
||||
{" "}
|
||||
<button
|
||||
onClick={handleModifySubscription}
|
||||
className="underline font-semibold hover:no-underline"
|
||||
>
|
||||
{t("billingManageSubscription") ||
|
||||
"Manage your subscription"}
|
||||
{t("billingManageSubscription") || "Manage your subscription"}
|
||||
</button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -834,10 +772,7 @@ export default function BillingPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{isProblematicState &&
|
||||
planAction.disabled &&
|
||||
!isCurrentPlan &&
|
||||
plan.id !== "enterprise" ? (
|
||||
{isProblematicState && planAction.disabled && !isCurrentPlan && plan.id !== "enterprise" ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
@@ -849,29 +784,18 @@ export default function BillingPage() {
|
||||
}
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={
|
||||
planAction.action
|
||||
}
|
||||
onClick={planAction.action}
|
||||
disabled={
|
||||
isLoading ||
|
||||
planAction.disabled
|
||||
}
|
||||
loading={
|
||||
isLoading &&
|
||||
isCurrentPlan
|
||||
isLoading || planAction.disabled
|
||||
}
|
||||
loading={isLoading && isCurrentPlan}
|
||||
>
|
||||
{planAction.label}
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t(
|
||||
"billingResolvePaymentIssue"
|
||||
) ||
|
||||
"Please resolve your payment issue before upgrading or downgrading"}
|
||||
</p>
|
||||
<p>{t("billingResolvePaymentIssue") || "Please resolve your payment issue before upgrading or downgrading"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
@@ -885,12 +809,9 @@ export default function BillingPage() {
|
||||
className="w-full"
|
||||
onClick={planAction.action}
|
||||
disabled={
|
||||
isLoading ||
|
||||
planAction.disabled
|
||||
}
|
||||
loading={
|
||||
isLoading && isCurrentPlan
|
||||
isLoading || planAction.disabled
|
||||
}
|
||||
loading={isLoading && isCurrentPlan}
|
||||
>
|
||||
{planAction.label}
|
||||
</Button>
|
||||
@@ -965,38 +886,18 @@ export default function BillingPage() {
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}>
|
||||
{getLimitValue(USERS) ??
|
||||
t(
|
||||
"billingUnlimited"
|
||||
) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(
|
||||
USERS
|
||||
) !== null && "users"}
|
||||
{getLimitValue(USERS) !== null &&
|
||||
"users"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t(
|
||||
"billingUsageExceedsLimit",
|
||||
{
|
||||
current:
|
||||
getUsageValue(
|
||||
USERS
|
||||
),
|
||||
limit:
|
||||
getLimitValue(
|
||||
USERS
|
||||
) ?? 0
|
||||
}
|
||||
) ||
|
||||
`Current usage (${getUsageValue(USERS)}) exceeds limit (${getLimitValue(USERS)})`}
|
||||
</p>
|
||||
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(USERS), limit: getLimitValue(USERS) ?? 0 }) || `Current usage (${getUsageValue(USERS)}) exceeds limit (${getLimitValue(USERS)})`}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
@@ -1004,8 +905,8 @@ export default function BillingPage() {
|
||||
{getLimitValue(USERS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(USERS) !==
|
||||
null && "users"}
|
||||
{getLimitValue(USERS) !== null &&
|
||||
"users"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
@@ -1019,38 +920,18 @@ export default function BillingPage() {
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}>
|
||||
{getLimitValue(SITES) ??
|
||||
t(
|
||||
"billingUnlimited"
|
||||
) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(
|
||||
SITES
|
||||
) !== null && "sites"}
|
||||
{getLimitValue(SITES) !== null &&
|
||||
"sites"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t(
|
||||
"billingUsageExceedsLimit",
|
||||
{
|
||||
current:
|
||||
getUsageValue(
|
||||
SITES
|
||||
),
|
||||
limit:
|
||||
getLimitValue(
|
||||
SITES
|
||||
) ?? 0
|
||||
}
|
||||
) ||
|
||||
`Current usage (${getUsageValue(SITES)}) exceeds limit (${getLimitValue(SITES)})`}
|
||||
</p>
|
||||
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(SITES), limit: getLimitValue(SITES) ?? 0 }) || `Current usage (${getUsageValue(SITES)}) exceeds limit (${getLimitValue(SITES)})`}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
@@ -1058,8 +939,8 @@ export default function BillingPage() {
|
||||
{getLimitValue(SITES) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(SITES) !==
|
||||
null && "sites"}
|
||||
{getLimitValue(SITES) !== null &&
|
||||
"sites"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
@@ -1073,40 +954,18 @@ export default function BillingPage() {
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}
|
||||
>
|
||||
{getLimitValue(
|
||||
DOMAINS
|
||||
) ??
|
||||
t(
|
||||
"billingUnlimited"
|
||||
) ??
|
||||
<span className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}>
|
||||
{getLimitValue(DOMAINS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(
|
||||
DOMAINS
|
||||
) !== null && "domains"}
|
||||
{getLimitValue(DOMAINS) !== null &&
|
||||
"domains"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t(
|
||||
"billingUsageExceedsLimit",
|
||||
{
|
||||
current:
|
||||
getUsageValue(
|
||||
DOMAINS
|
||||
),
|
||||
limit:
|
||||
getLimitValue(
|
||||
DOMAINS
|
||||
) ?? 0
|
||||
}
|
||||
) ||
|
||||
`Current usage (${getUsageValue(DOMAINS)}) exceeds limit (${getLimitValue(DOMAINS)})`}
|
||||
</p>
|
||||
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(DOMAINS), limit: getLimitValue(DOMAINS) ?? 0 }) || `Current usage (${getUsageValue(DOMAINS)}) exceeds limit (${getLimitValue(DOMAINS)})`}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
@@ -1114,8 +973,8 @@ export default function BillingPage() {
|
||||
{getLimitValue(DOMAINS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(DOMAINS) !==
|
||||
null && "domains"}
|
||||
{getLimitValue(DOMAINS) !== null &&
|
||||
"domains"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
@@ -1130,40 +989,18 @@ export default function BillingPage() {
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}
|
||||
>
|
||||
{getLimitValue(
|
||||
ORGINIZATIONS
|
||||
) ??
|
||||
t(
|
||||
"billingUnlimited"
|
||||
) ??
|
||||
<span className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}>
|
||||
{getLimitValue(ORGINIZATIONS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(
|
||||
ORGINIZATIONS
|
||||
) !== null && "orgs"}
|
||||
{getLimitValue(ORGINIZATIONS) !==
|
||||
null && "orgs"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t(
|
||||
"billingUsageExceedsLimit",
|
||||
{
|
||||
current:
|
||||
getUsageValue(
|
||||
ORGINIZATIONS
|
||||
),
|
||||
limit:
|
||||
getLimitValue(
|
||||
ORGINIZATIONS
|
||||
) ?? 0
|
||||
}
|
||||
) ||
|
||||
`Current usage (${getUsageValue(ORGINIZATIONS)}) exceeds limit (${getLimitValue(ORGINIZATIONS)})`}
|
||||
</p>
|
||||
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(ORGINIZATIONS), limit: getLimitValue(ORGINIZATIONS) ?? 0 }) || `Current usage (${getUsageValue(ORGINIZATIONS)}) exceeds limit (${getLimitValue(ORGINIZATIONS)})`}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
@@ -1171,9 +1008,8 @@ export default function BillingPage() {
|
||||
{getLimitValue(ORGINIZATIONS) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(
|
||||
ORGINIZATIONS
|
||||
) !== null && "orgs"}
|
||||
{getLimitValue(ORGINIZATIONS) !==
|
||||
null && "orgs"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
@@ -1188,52 +1024,27 @@ export default function BillingPage() {
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="flex items-center gap-1">
|
||||
<AlertTriangle className="h-3 w-3 text-orange-400" />
|
||||
<span
|
||||
className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}
|
||||
>
|
||||
{getLimitValue(
|
||||
REMOTE_EXIT_NODES
|
||||
) ??
|
||||
t(
|
||||
"billingUnlimited"
|
||||
) ??
|
||||
<span className={cn(
|
||||
"text-orange-600 dark:text-orange-400 font-medium"
|
||||
)}>
|
||||
{getLimitValue(REMOTE_EXIT_NODES) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(
|
||||
REMOTE_EXIT_NODES
|
||||
) !== null && "nodes"}
|
||||
{getLimitValue(REMOTE_EXIT_NODES) !==
|
||||
null && "nodes"}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{t(
|
||||
"billingUsageExceedsLimit",
|
||||
{
|
||||
current:
|
||||
getUsageValue(
|
||||
REMOTE_EXIT_NODES
|
||||
),
|
||||
limit:
|
||||
getLimitValue(
|
||||
REMOTE_EXIT_NODES
|
||||
) ?? 0
|
||||
}
|
||||
) ||
|
||||
`Current usage (${getUsageValue(REMOTE_EXIT_NODES)}) exceeds limit (${getLimitValue(REMOTE_EXIT_NODES)})`}
|
||||
</p>
|
||||
<p>{t("billingUsageExceedsLimit", { current: getUsageValue(REMOTE_EXIT_NODES), limit: getLimitValue(REMOTE_EXIT_NODES) ?? 0 }) || `Current usage (${getUsageValue(REMOTE_EXIT_NODES)}) exceeds limit (${getLimitValue(REMOTE_EXIT_NODES)})`}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
{getLimitValue(
|
||||
REMOTE_EXIT_NODES
|
||||
) ??
|
||||
{getLimitValue(REMOTE_EXIT_NODES) ??
|
||||
t("billingUnlimited") ??
|
||||
"∞"}{" "}
|
||||
{getLimitValue(
|
||||
REMOTE_EXIT_NODES
|
||||
) !== null && "nodes"}
|
||||
{getLimitValue(REMOTE_EXIT_NODES) !==
|
||||
null && "nodes"}
|
||||
</>
|
||||
)}
|
||||
</InfoSectionContent>
|
||||
@@ -1261,8 +1072,7 @@ export default function BillingPage() {
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-4 border rounded-lg p-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground mb-1">
|
||||
{t("billingCurrentKeys") ||
|
||||
"Current Keys"}
|
||||
{t("billingCurrentKeys") || "Current Keys"}
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold">
|
||||
@@ -1327,101 +1137,61 @@ export default function BillingPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features with check marks */}
|
||||
{(() => {
|
||||
const plan = planOptions.find(
|
||||
(p) =>
|
||||
p.tierType === pendingTier.tier ||
|
||||
(pendingTier.tier === "basic" &&
|
||||
p.id === "basic")
|
||||
);
|
||||
return plan?.features?.length ? (
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">
|
||||
{"What's included:"}
|
||||
</h4>
|
||||
<div className="space-y-2">
|
||||
{plan.features.map(
|
||||
(feature, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Check className="h-4 w-4 text-green-600 shrink-0" />
|
||||
<span>
|
||||
{feature}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
|
||||
{/* Limits without check marks */}
|
||||
{tierLimits[pendingTier.tier] && (
|
||||
<div>
|
||||
<h4 className="font-semibold mb-3">
|
||||
{"Up to:"}
|
||||
{t("billingPlanIncludes") ||
|
||||
"Plan Includes:"}
|
||||
</h4>
|
||||
<div className="space-y-2 text-sm text-muted-foreground">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[
|
||||
pendingTier.tier
|
||||
].users
|
||||
tierLimits[pendingTier.tier]
|
||||
.users
|
||||
}{" "}
|
||||
{t("billingUsers") ||
|
||||
"Users"}
|
||||
{t("billingUsers") || "Users"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[
|
||||
pendingTier.tier
|
||||
].sites
|
||||
tierLimits[pendingTier.tier]
|
||||
.sites
|
||||
}{" "}
|
||||
{t("billingSites") ||
|
||||
"Sites"}
|
||||
{t("billingSites") || "Sites"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[
|
||||
pendingTier.tier
|
||||
].domains
|
||||
tierLimits[pendingTier.tier]
|
||||
.domains
|
||||
}{" "}
|
||||
{t("billingDomains") ||
|
||||
"Domains"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[
|
||||
pendingTier.tier
|
||||
].organizations
|
||||
tierLimits[pendingTier.tier]
|
||||
.organizations
|
||||
}{" "}
|
||||
{t(
|
||||
"billingOrganizations"
|
||||
) || "Organizations"}
|
||||
{t("billingOrganizations") ||
|
||||
"Organizations"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-muted-foreground/50 shrink-0" />
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
<span>
|
||||
{
|
||||
tierLimits[
|
||||
pendingTier.tier
|
||||
].remoteNodes
|
||||
tierLimits[pendingTier.tier]
|
||||
.remoteNodes
|
||||
}{" "}
|
||||
{t("billingRemoteNodes") ||
|
||||
"Remote Nodes"}
|
||||
@@ -1432,84 +1202,43 @@ export default function BillingPage() {
|
||||
)}
|
||||
|
||||
{/* Warning for limit violations when downgrading */}
|
||||
{pendingTier.action === "downgrade" &&
|
||||
(() => {
|
||||
const violations = checkLimitViolations(
|
||||
pendingTier.tier
|
||||
{pendingTier.action === "downgrade" && (() => {
|
||||
const violations = checkLimitViolations(pendingTier.tier);
|
||||
if (violations.length > 0) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t("billingLimitViolationWarning") || "Usage Exceeds New Plan Limits"}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="mb-3">
|
||||
{t("billingLimitViolationDescription") || "Your current usage exceeds the limits of this plan. The following features will be disabled until you reduce usage:"}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{violations.map((violation, index) => (
|
||||
<li key={index} className="flex items-center gap-2">
|
||||
<span className="font-medium">{violation.feature}:</span>
|
||||
<span>Currently using {violation.currentUsage}, new limit is {violation.newLimit}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
if (violations.length > 0) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t(
|
||||
"billingLimitViolationWarning"
|
||||
) ||
|
||||
"Usage Exceeds New Plan Limits"}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p className="mb-3">
|
||||
{t(
|
||||
"billingLimitViolationDescription"
|
||||
) ||
|
||||
"Your current usage exceeds the limits of this plan. The following features will be disabled until you reduce usage:"}
|
||||
</p>
|
||||
<ul className="space-y-2">
|
||||
{violations.map(
|
||||
(
|
||||
violation,
|
||||
index
|
||||
) => (
|
||||
<li
|
||||
key={
|
||||
index
|
||||
}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span className="font-medium">
|
||||
{
|
||||
violation.feature
|
||||
}
|
||||
:
|
||||
</span>
|
||||
<span>
|
||||
Currently
|
||||
using{" "}
|
||||
{
|
||||
violation.currentUsage
|
||||
}
|
||||
,
|
||||
new
|
||||
limit
|
||||
is{" "}
|
||||
{
|
||||
violation.newLimit
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
}
|
||||
return null;
|
||||
})()}
|
||||
|
||||
{/* Warning for feature loss when downgrading */}
|
||||
{pendingTier.action === "downgrade" && (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>
|
||||
{t("billingFeatureLossWarning") ||
|
||||
"Feature Availability Notice"}
|
||||
{t("billingFeatureLossWarning") || "Feature Availability Notice"}
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
{t(
|
||||
"billingFeatureLossDescription"
|
||||
) ||
|
||||
"By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available."}
|
||||
{t("billingFeatureLossDescription") || "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -559,7 +559,7 @@ export default function Page() {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("resourceErrorCreate"),
|
||||
description: t("resourceErrorCreateMessageDescription")
|
||||
description: formatAxiosError(e, t("resourceErrorCreateMessageDescription"))
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user