mirror of
https://github.com/fosrl/pangolin.git
synced 2025-05-12 21:30:35 +01:00
add license system and ui
This commit is contained in:
parent
80d76befc9
commit
4819f410e6
46 changed files with 2159 additions and 94 deletions
32
LICENSE
32
LICENSE
|
@ -1,3 +1,35 @@
|
||||||
|
Copyright (c) 2025 Fossorial, LLC.
|
||||||
|
|
||||||
|
Portions of this software are licensed as follows:
|
||||||
|
|
||||||
|
* All files that include a header specifying they are licensed under the
|
||||||
|
"Fossorial Commercial License" are governed by the Fossorial Commercial
|
||||||
|
License terms. The specific terms applicable to each customer depend on the
|
||||||
|
commercial license tier agreed upon in writing with Fossorial LLC.
|
||||||
|
Unauthorized use, copying, modification, or distribution is strictly
|
||||||
|
prohibited.
|
||||||
|
|
||||||
|
* All files that include a header specifying they are licensed under the GNU
|
||||||
|
Affero General Public License, Version 3 ("AGPL-3"), are governed by the
|
||||||
|
AGPL-3 terms. A full copy of the AGPL-3 license is provided below. However,
|
||||||
|
these files are also available under the Fossorial Commercial License if a
|
||||||
|
separate commercial license agreement has been executed between the customer
|
||||||
|
and Fossorial LLC.
|
||||||
|
|
||||||
|
* All files without a license header are, by default, licensed under the GNU
|
||||||
|
Affero General Public License, Version 3 (AGPL-3). These files may also be
|
||||||
|
made available under the Fossorial Commercial License upon agreement with
|
||||||
|
Fossorial LLC.
|
||||||
|
|
||||||
|
* All third-party components included in this repository are licensed under
|
||||||
|
their respective original licenses, as provided by their authors.
|
||||||
|
|
||||||
|
Please consult the header of each individual file to determine the applicable
|
||||||
|
license. For AGPL-3 licensed files, dual-licensing under the Fossorial
|
||||||
|
Commercial License is available subject to written agreement with Fossorial
|
||||||
|
LLC.
|
||||||
|
|
||||||
|
|
||||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
Version 3, 19 November 2007
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
|
|
@ -136,7 +136,7 @@ View the [project board](https://github.com/orgs/fosrl/projects/1) for more deta
|
||||||
|
|
||||||
## Licensing
|
## Licensing
|
||||||
|
|
||||||
Pangolin is dual licensed under the AGPL-3 and the Fossorial Commercial license. To see our commercial offerings, please see our [website](https://fossorial.io) for details. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io).
|
Pangolin is dual licensed under the AGPL-3 and the Fossorial Commercial license. Please see the [LICENSE](./LICENSE) file in the repository for details. For inquiries about commercial licensing, please contact us at [numbat@fossorial.io](mailto:numbat@fossorial.io).
|
||||||
|
|
||||||
## Contributions
|
## Contributions
|
||||||
|
|
||||||
|
|
|
@ -52,6 +52,7 @@ esbuild
|
||||||
bundle: true,
|
bundle: true,
|
||||||
outfile: argv.out,
|
outfile: argv.out,
|
||||||
format: "esm",
|
format: "esm",
|
||||||
|
minify: true,
|
||||||
banner: {
|
banner: {
|
||||||
js: banner,
|
js: banner,
|
||||||
},
|
},
|
||||||
|
|
126
package-lock.json
generated
126
package-lock.json
generated
|
@ -22,6 +22,7 @@
|
||||||
"@radix-ui/react-icons": "1.3.2",
|
"@radix-ui/react-icons": "1.3.2",
|
||||||
"@radix-ui/react-label": "2.1.1",
|
"@radix-ui/react-label": "2.1.1",
|
||||||
"@radix-ui/react-popover": "1.1.4",
|
"@radix-ui/react-popover": "1.1.4",
|
||||||
|
"@radix-ui/react-progress": "^1.1.4",
|
||||||
"@radix-ui/react-radio-group": "1.2.2",
|
"@radix-ui/react-radio-group": "1.2.2",
|
||||||
"@radix-ui/react-select": "2.1.4",
|
"@radix-ui/react-select": "2.1.4",
|
||||||
"@radix-ui/react-separator": "1.1.1",
|
"@radix-ui/react-separator": "1.1.1",
|
||||||
|
@ -78,6 +79,7 @@
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"tailwind-merge": "2.6.0",
|
"tailwind-merge": "2.6.0",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"winston": "3.17.0",
|
"winston": "3.17.0",
|
||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
|
@ -3351,6 +3353,101 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-8rl9w7lJdcVPor47Dhws9mUHRHLE+8JEgyJRdNWCpGPa6HIlr3eh+Yn9gyx1CnCLbw5naHsI2gaO9dBWO50vzw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-context": "1.1.2",
|
||||||
|
"@radix-ui/react-primitive": "2.1.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-compose-refs": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-/J/FhLdK0zVcILOwt5g+dH4KnkonCtkVJsa2G6JmvbbtZfBEI1gMsO3QMjseL4F/SwfAMt1Vc/0XKYKq+xJ1sw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-slot": "1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-ujc+V6r0HNDviYqIK3rW4ffgYiZ8g5DEHrGJVk4x7kTlLXRDILnKX9vAUYeIsLOoDpDJ0ujpqMkjH4w2ofuo6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-radio-group": {
|
"node_modules/@radix-ui/react-radio-group": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.2.tgz",
|
||||||
|
@ -4381,7 +4478,7 @@
|
||||||
"version": "7.6.12",
|
"version": "7.6.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.12.tgz",
|
||||||
"integrity": "sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==",
|
"integrity": "sha512-fnQmj8lELIj7BSrZQAdBMHEHX8OZLYIHXqAKT1O7tDfLxaINzf00PMjw22r3N/xXh0w/sGHlO6SVaCQ2mj78lg==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
|
@ -4522,7 +4619,7 @@
|
||||||
"version": "22.14.1",
|
"version": "22.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.14.1.tgz",
|
||||||
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
|
"integrity": "sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
|
@ -4556,7 +4653,7 @@
|
||||||
"version": "19.1.1",
|
"version": "19.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.1.tgz",
|
||||||
"integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==",
|
"integrity": "sha512-ePapxDL7qrgqSF67s0h9m412d9DbXyC1n59O2st+9rjuuamWsZuD2w55rqY12CbzsZ7uVXb5Nw0gEp9Z8MMutQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
|
@ -4566,7 +4663,7 @@
|
||||||
"version": "19.1.2",
|
"version": "19.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.2.tgz",
|
||||||
"integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==",
|
"integrity": "sha512-XGJkWF41Qq305SKWEILa1O8vzhb3aOo3ogBlSmiqNko/WmRb6QIaweuZCXjKygVDXpzXb5wyxKTSOsmkuqj+Qw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/react": "^19.0.0"
|
"@types/react": "^19.0.0"
|
||||||
|
@ -6178,7 +6275,7 @@
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
|
@ -9487,7 +9584,7 @@
|
||||||
"version": "2.4.2",
|
"version": "2.4.2",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz",
|
||||||
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
|
"integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"bin": {
|
"bin": {
|
||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
|
@ -15865,6 +15962,7 @@
|
||||||
"version": "4.1.4",
|
"version": "4.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.4.tgz",
|
||||||
"integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==",
|
"integrity": "sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/tapable": {
|
"node_modules/tapable": {
|
||||||
|
@ -16232,6 +16330,7 @@
|
||||||
"version": "5.8.3",
|
"version": "5.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
|
@ -16263,7 +16362,7 @@
|
||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"devOptional": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unpipe": {
|
"node_modules/unpipe": {
|
||||||
|
@ -16378,6 +16477,19 @@
|
||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/uuid": {
|
||||||
|
"version": "11.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||||
|
"integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/sponsors/broofa",
|
||||||
|
"https://github.com/sponsors/ctavan"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"uuid": "dist/esm/bin/uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
|
|
|
@ -33,6 +33,7 @@
|
||||||
"@radix-ui/react-icons": "1.3.2",
|
"@radix-ui/react-icons": "1.3.2",
|
||||||
"@radix-ui/react-label": "2.1.1",
|
"@radix-ui/react-label": "2.1.1",
|
||||||
"@radix-ui/react-popover": "1.1.4",
|
"@radix-ui/react-popover": "1.1.4",
|
||||||
|
"@radix-ui/react-progress": "^1.1.4",
|
||||||
"@radix-ui/react-radio-group": "1.2.2",
|
"@radix-ui/react-radio-group": "1.2.2",
|
||||||
"@radix-ui/react-select": "2.1.4",
|
"@radix-ui/react-select": "2.1.4",
|
||||||
"@radix-ui/react-separator": "1.1.1",
|
"@radix-ui/react-separator": "1.1.1",
|
||||||
|
@ -89,6 +90,7 @@
|
||||||
"swagger-ui-express": "^5.0.1",
|
"swagger-ui-express": "^5.0.1",
|
||||||
"tailwind-merge": "2.6.0",
|
"tailwind-merge": "2.6.0",
|
||||||
"tw-animate-css": "^1.2.5",
|
"tw-animate-css": "^1.2.5",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"winston": "3.17.0",
|
"winston": "3.17.0",
|
||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
|
|
0
server/db/schemas/hostMeta.ts
Normal file
0
server/db/schemas/hostMeta.ts
Normal file
|
@ -1 +1,2 @@
|
||||||
export * from "./schema";
|
export * from "./schema";
|
||||||
|
export * from "./proSchema";
|
||||||
|
|
17
server/db/schemas/proSchema.ts
Normal file
17
server/db/schemas/proSchema.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||||
|
|
||||||
|
export const licenseKey = sqliteTable("licenseKey", {
|
||||||
|
licenseKeyId: text("licenseKeyId").primaryKey().notNull(),
|
||||||
|
instanceId: text("instanceId").notNull(),
|
||||||
|
token: text("token").notNull()
|
||||||
|
});
|
||||||
|
|
||||||
|
export const hostMeta = sqliteTable("hostMeta", {
|
||||||
|
hostMetaId: text("hostMetaId").primaryKey().notNull(),
|
||||||
|
createdAt: integer("createdAt").notNull()
|
||||||
|
});
|
|
@ -12,8 +12,8 @@ import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
import stoi from "./stoi";
|
import stoi from "./stoi";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { SupporterKey, supporterKey } from "@server/db/schemas";
|
import { SupporterKey, supporterKey } from "@server/db/schemas";
|
||||||
import { suppressDeprecationWarnings } from "moment";
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { license } from "@server/license/license";
|
||||||
|
|
||||||
const portSchema = z.number().positive().gt(0).lte(65535);
|
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||||
|
|
||||||
|
@ -267,13 +267,19 @@ export class Config {
|
||||||
: "false";
|
: "false";
|
||||||
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
||||||
|
|
||||||
if (!this.isDev) {
|
this.checkKeyStatus();
|
||||||
this.checkSupporterKey();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.rawConfig = parsedConfig.data;
|
this.rawConfig = parsedConfig.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async checkKeyStatus() {
|
||||||
|
const licenseStatus = await license.check();
|
||||||
|
console.log("License status", licenseStatus);
|
||||||
|
if (!licenseStatus.isHostLicensed) {
|
||||||
|
this.checkSupporterKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public getRawConfig() {
|
public getRawConfig() {
|
||||||
return this.rawConfig;
|
return this.rawConfig;
|
||||||
}
|
}
|
||||||
|
|
429
server/license/license.ts
Normal file
429
server/license/license.ts
Normal file
|
@ -0,0 +1,429 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import db from "@server/db";
|
||||||
|
import { hostMeta, licenseKey, sites } from "@server/db/schemas";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import NodeCache from "node-cache";
|
||||||
|
import { validateJWT } from "./licenseJwt";
|
||||||
|
import { count, eq } from "drizzle-orm";
|
||||||
|
import moment from "moment";
|
||||||
|
|
||||||
|
export type LicenseStatus = {
|
||||||
|
isHostLicensed: boolean; // Are there any license keys?
|
||||||
|
isLicenseValid: boolean; // Is the license key valid?
|
||||||
|
hostId: string; // Host ID
|
||||||
|
maxSites?: number;
|
||||||
|
usedSites?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LicenseKeyCache = {
|
||||||
|
licenseKey: string;
|
||||||
|
valid: boolean;
|
||||||
|
iat?: Date;
|
||||||
|
type?: "LICENSE" | "SITES";
|
||||||
|
numSites?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActivateLicenseKeyAPIResponse = {
|
||||||
|
data: {
|
||||||
|
instanceId: string;
|
||||||
|
};
|
||||||
|
success: boolean;
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
status: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ValidateLicenseAPIResponse = {
|
||||||
|
data: {
|
||||||
|
licenseKeys: {
|
||||||
|
[key: string]: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
success: boolean;
|
||||||
|
error: string;
|
||||||
|
message: string;
|
||||||
|
status: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TokenPayload = {
|
||||||
|
valid: boolean;
|
||||||
|
type: "LICENSE" | "SITES";
|
||||||
|
quantity: number;
|
||||||
|
terminateAt: string; // ISO
|
||||||
|
iat: number; // Issued at
|
||||||
|
};
|
||||||
|
|
||||||
|
export class License {
|
||||||
|
private phoneHomeInterval = 6 * 60 * 60; // 6 hours = 6 * 60 * 60 = 21600 seconds
|
||||||
|
private validationServerUrl =
|
||||||
|
"https://api.dev.fossorial.io/api/v1/license/professional/validate";
|
||||||
|
private activationServerUrl =
|
||||||
|
"https://api.dev.fossorial.io/api/v1/license/professional/activate";
|
||||||
|
|
||||||
|
private statusCache = new NodeCache({ stdTTL: this.phoneHomeInterval });
|
||||||
|
private licenseKeyCache = new NodeCache();
|
||||||
|
|
||||||
|
private ephemeralKey!: string;
|
||||||
|
private statusKey = "status";
|
||||||
|
|
||||||
|
private publicKey = `-----BEGIN PUBLIC KEY-----
|
||||||
|
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx9RKc8cw+G8r7h/xeozF
|
||||||
|
FNkRDggQfYO6Ae+EWHGujZ9WYAZ10spLh9F/zoLhhr3XhsjpoRXwMfgNuO5HstWf
|
||||||
|
CYM20I0l7EUUMWEyWd4tZLd+5XQ4jY5xWOCWyFJAGQSp7flcRmxdfde+l+xg9eKl
|
||||||
|
apbY84aVp09/GqM96hCS+CsQZrhohu/aOqYVB/eAhF01qsbmiZ7Y3WtdhTldveYt
|
||||||
|
h4mZWGmjf8d/aEgePf/tk1gp0BUxf+Ae5yqoAqU+6aiFbjJ7q1kgxc18PWFGfE9y
|
||||||
|
zSk+OZk887N5ThQ52154+oOUCMMR2Y3t5OH1hVZod51vuY2u5LsQXsf+87PwB91y
|
||||||
|
LQIDAQAB
|
||||||
|
-----END PUBLIC KEY-----`;
|
||||||
|
|
||||||
|
constructor(private hostId: string) {
|
||||||
|
this.ephemeralKey = Buffer.from(
|
||||||
|
JSON.stringify({ ts: new Date().toISOString() })
|
||||||
|
).toString("base64");
|
||||||
|
|
||||||
|
setInterval(
|
||||||
|
() => {
|
||||||
|
this.check();
|
||||||
|
},
|
||||||
|
1000 * 60 * 60
|
||||||
|
); // 1 hour = 60 * 60 = 3600 seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
public listKeys(): LicenseKeyCache[] {
|
||||||
|
const keys = this.licenseKeyCache.keys();
|
||||||
|
return keys.map((key) => {
|
||||||
|
return this.licenseKeyCache.get<LicenseKeyCache>(key)!;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async forceRecheck() {
|
||||||
|
this.statusCache.flushAll();
|
||||||
|
this.licenseKeyCache.flushAll();
|
||||||
|
|
||||||
|
return await this.check();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async isUnlocked(): Promise<boolean> {
|
||||||
|
const status = await this.check();
|
||||||
|
if (status.isHostLicensed) {
|
||||||
|
if (status.isLicenseValid) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async check(): Promise<LicenseStatus> {
|
||||||
|
// Set used sites
|
||||||
|
const [siteCount] = await db
|
||||||
|
.select({
|
||||||
|
value: count()
|
||||||
|
})
|
||||||
|
.from(sites);
|
||||||
|
|
||||||
|
const status: LicenseStatus = {
|
||||||
|
hostId: this.hostId,
|
||||||
|
isHostLicensed: true,
|
||||||
|
isLicenseValid: false,
|
||||||
|
maxSites: undefined,
|
||||||
|
usedSites: 150
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.statusCache.has(this.statusKey)) {
|
||||||
|
const res = this.statusCache.get("status") as LicenseStatus;
|
||||||
|
res.usedSites = status.usedSites;
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate all
|
||||||
|
this.licenseKeyCache.flushAll();
|
||||||
|
|
||||||
|
logger.debug("Checking license status...");
|
||||||
|
|
||||||
|
const allKeysRes = await db.select().from(licenseKey);
|
||||||
|
|
||||||
|
if (allKeysRes.length === 0) {
|
||||||
|
status.isHostLicensed = false;
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate stored license keys
|
||||||
|
for (const key of allKeysRes) {
|
||||||
|
try {
|
||||||
|
const payload = validateJWT<TokenPayload>(
|
||||||
|
key.token,
|
||||||
|
this.publicKey
|
||||||
|
);
|
||||||
|
|
||||||
|
this.licenseKeyCache.set<LicenseKeyCache>(
|
||||||
|
key.licenseKeyId,
|
||||||
|
{
|
||||||
|
licenseKey: key.licenseKeyId,
|
||||||
|
valid: payload.valid,
|
||||||
|
type: payload.type,
|
||||||
|
numSites: payload.quantity,
|
||||||
|
iat: new Date(payload.iat * 1000)
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(
|
||||||
|
`Error validating license key: ${key.licenseKeyId}`
|
||||||
|
);
|
||||||
|
logger.error(e);
|
||||||
|
|
||||||
|
this.licenseKeyCache.set<LicenseKeyCache>(
|
||||||
|
key.licenseKeyId,
|
||||||
|
{
|
||||||
|
licenseKey: key.licenseKeyId,
|
||||||
|
valid: false
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = allKeysRes.map((key) => ({
|
||||||
|
licenseKey: key.licenseKeyId,
|
||||||
|
instanceId: key.instanceId
|
||||||
|
}));
|
||||||
|
|
||||||
|
let apiResponse: ValidateLicenseAPIResponse | undefined;
|
||||||
|
try {
|
||||||
|
// Phone home to validate license keys
|
||||||
|
apiResponse = await this.phoneHome(keys);
|
||||||
|
|
||||||
|
if (!apiResponse?.success) {
|
||||||
|
throw new Error(apiResponse?.error);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
logger.error("Error communicating with license server:");
|
||||||
|
logger.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug("Validate response", apiResponse);
|
||||||
|
|
||||||
|
// Check and update all license keys with server response
|
||||||
|
for (const key of keys) {
|
||||||
|
try {
|
||||||
|
const cached = this.licenseKeyCache.get<LicenseKeyCache>(
|
||||||
|
key.licenseKey
|
||||||
|
)!;
|
||||||
|
const licenseKeyRes =
|
||||||
|
apiResponse?.data?.licenseKeys[key.licenseKey];
|
||||||
|
|
||||||
|
if (!apiResponse || !licenseKeyRes) {
|
||||||
|
logger.debug(
|
||||||
|
`No response from server for license key: ${key.licenseKey}`
|
||||||
|
);
|
||||||
|
if (cached.iat) {
|
||||||
|
const exp = moment(cached.iat)
|
||||||
|
.add(7, "days")
|
||||||
|
.toDate();
|
||||||
|
if (exp > new Date()) {
|
||||||
|
logger.debug(
|
||||||
|
`Using cached license key: ${key.licenseKey}, valid ${cached.valid}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Can't trust license key: ${key.licenseKey}`
|
||||||
|
);
|
||||||
|
cached.valid = false;
|
||||||
|
this.licenseKeyCache.set<LicenseKeyCache>(
|
||||||
|
key.licenseKey,
|
||||||
|
cached
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = validateJWT<TokenPayload>(
|
||||||
|
licenseKeyRes,
|
||||||
|
this.publicKey
|
||||||
|
);
|
||||||
|
cached.valid = payload.valid;
|
||||||
|
cached.type = payload.type;
|
||||||
|
cached.numSites = payload.quantity;
|
||||||
|
cached.iat = new Date(payload.iat * 1000);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(licenseKey)
|
||||||
|
.set({
|
||||||
|
token: licenseKeyRes
|
||||||
|
})
|
||||||
|
.where(eq(licenseKey.licenseKeyId, key.licenseKey));
|
||||||
|
|
||||||
|
this.licenseKeyCache.set<LicenseKeyCache>(
|
||||||
|
key.licenseKey,
|
||||||
|
cached
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Error validating license key: ${key}`);
|
||||||
|
logger.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute host status
|
||||||
|
for (const key of keys) {
|
||||||
|
const cached = this.licenseKeyCache.get<LicenseKeyCache>(
|
||||||
|
key.licenseKey
|
||||||
|
)!;
|
||||||
|
|
||||||
|
logger.debug("Checking key", cached);
|
||||||
|
|
||||||
|
if (cached.type === "LICENSE") {
|
||||||
|
status.isLicenseValid = cached.valid;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cached.valid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!status.maxSites) {
|
||||||
|
status.maxSites = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
status.maxSites += cached.numSites || 0;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error checking license status:");
|
||||||
|
logger.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.statusCache.set(this.statusKey, status);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async activateLicenseKey(key: string) {
|
||||||
|
const [existingKey] = await db
|
||||||
|
.select()
|
||||||
|
.from(licenseKey)
|
||||||
|
.where(eq(licenseKey.licenseKeyId, key))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (existingKey) {
|
||||||
|
throw new Error("License key already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
let instanceId: string | undefined;
|
||||||
|
try {
|
||||||
|
// Call activate
|
||||||
|
const apiResponse = await fetch(this.activationServerUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
licenseKey: key,
|
||||||
|
instanceName: this.hostId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await apiResponse.json();
|
||||||
|
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(`${data.message || data.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = data as ActivateLicenseKeyAPIResponse;
|
||||||
|
|
||||||
|
if (!response.data) {
|
||||||
|
throw new Error("No response from server");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.data.instanceId) {
|
||||||
|
throw new Error("No instance ID in response");
|
||||||
|
}
|
||||||
|
|
||||||
|
instanceId = response.data.instanceId;
|
||||||
|
} catch (error) {
|
||||||
|
throw Error(`Error activating license key: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phone home to validate license key
|
||||||
|
const keys = [
|
||||||
|
{
|
||||||
|
licenseKey: key,
|
||||||
|
instanceId: instanceId!
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
let validateResponse: ValidateLicenseAPIResponse;
|
||||||
|
try {
|
||||||
|
validateResponse = await this.phoneHome(keys);
|
||||||
|
|
||||||
|
if (!validateResponse) {
|
||||||
|
throw new Error("No response from server");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateResponse.success) {
|
||||||
|
throw new Error(validateResponse.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the license key
|
||||||
|
const licenseKeyRes = validateResponse.data.licenseKeys[key];
|
||||||
|
if (!licenseKeyRes) {
|
||||||
|
throw new Error("Invalid license key");
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = validateJWT<TokenPayload>(
|
||||||
|
licenseKeyRes,
|
||||||
|
this.publicKey
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!payload.valid) {
|
||||||
|
throw new Error("Invalid license key");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store the license key in the database
|
||||||
|
await db.insert(licenseKey).values({
|
||||||
|
licenseKeyId: key,
|
||||||
|
token: licenseKeyRes,
|
||||||
|
instanceId: instanceId!
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw Error(`Error validating license key: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate the cache and re-compute the status
|
||||||
|
return await this.forceRecheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async phoneHome(
|
||||||
|
keys: {
|
||||||
|
licenseKey: string;
|
||||||
|
instanceId: string;
|
||||||
|
}[]
|
||||||
|
): Promise<ValidateLicenseAPIResponse> {
|
||||||
|
const response = await fetch(this.validationServerUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
licenseKeys: keys,
|
||||||
|
ephemeralKey: this.ephemeralKey,
|
||||||
|
instanceName: this.hostId
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
return data as ValidateLicenseAPIResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [info] = await db.select().from(hostMeta).limit(1);
|
||||||
|
|
||||||
|
if (!info) {
|
||||||
|
throw new Error("Host information not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const license = new License(info.hostMetaId);
|
||||||
|
|
||||||
|
export default license;
|
114
server/license/licenseJwt.ts
Normal file
114
server/license/licenseJwt.ts
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import * as crypto from "crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a JWT using a public key
|
||||||
|
* @param token - The JWT to validate
|
||||||
|
* @param publicKey - The public key used for verification (PEM format)
|
||||||
|
* @returns The decoded payload if validation succeeds, throws an error otherwise
|
||||||
|
*/
|
||||||
|
function validateJWT<Payload>(
|
||||||
|
token: string,
|
||||||
|
publicKey: string
|
||||||
|
): Payload {
|
||||||
|
// Split the JWT into its three parts
|
||||||
|
const parts = token.split(".");
|
||||||
|
if (parts.length !== 3) {
|
||||||
|
throw new Error("Invalid JWT format");
|
||||||
|
}
|
||||||
|
|
||||||
|
const [encodedHeader, encodedPayload, signature] = parts;
|
||||||
|
|
||||||
|
// Decode the header to get the algorithm
|
||||||
|
const header = JSON.parse(Buffer.from(encodedHeader, "base64").toString());
|
||||||
|
const algorithm = header.alg;
|
||||||
|
|
||||||
|
// Verify the signature
|
||||||
|
const signatureInput = `${encodedHeader}.${encodedPayload}`;
|
||||||
|
const isValid = verify(signatureInput, signature, publicKey, algorithm);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error("Invalid signature");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the payload
|
||||||
|
const payload = JSON.parse(
|
||||||
|
Buffer.from(encodedPayload, "base64").toString()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the token has expired
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (payload.exp && payload.exp < now) {
|
||||||
|
throw new Error("Token has expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the signature of a JWT
|
||||||
|
*/
|
||||||
|
function verify(
|
||||||
|
input: string,
|
||||||
|
signature: string,
|
||||||
|
publicKey: string,
|
||||||
|
algorithm: string
|
||||||
|
): boolean {
|
||||||
|
let verifyAlgorithm: string;
|
||||||
|
|
||||||
|
// Map JWT algorithm name to Node.js crypto algorithm name
|
||||||
|
switch (algorithm) {
|
||||||
|
case "RS256":
|
||||||
|
verifyAlgorithm = "RSA-SHA256";
|
||||||
|
break;
|
||||||
|
case "RS384":
|
||||||
|
verifyAlgorithm = "RSA-SHA384";
|
||||||
|
break;
|
||||||
|
case "RS512":
|
||||||
|
verifyAlgorithm = "RSA-SHA512";
|
||||||
|
break;
|
||||||
|
case "ES256":
|
||||||
|
verifyAlgorithm = "SHA256";
|
||||||
|
break;
|
||||||
|
case "ES384":
|
||||||
|
verifyAlgorithm = "SHA384";
|
||||||
|
break;
|
||||||
|
case "ES512":
|
||||||
|
verifyAlgorithm = "SHA512";
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported algorithm: ${algorithm}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert base64url signature to standard base64
|
||||||
|
const base64Signature = base64URLToBase64(signature);
|
||||||
|
|
||||||
|
// Verify the signature
|
||||||
|
const verifier = crypto.createVerify(verifyAlgorithm);
|
||||||
|
verifier.update(input);
|
||||||
|
return verifier.verify(publicKey, base64Signature, "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts base64url format to standard base64
|
||||||
|
*/
|
||||||
|
function base64URLToBase64(base64url: string): string {
|
||||||
|
// Add padding if needed
|
||||||
|
let base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
|
||||||
|
const pad = base64.length % 4;
|
||||||
|
if (pad) {
|
||||||
|
if (pad === 1) {
|
||||||
|
throw new Error("Invalid base64url string");
|
||||||
|
}
|
||||||
|
base64 += "=".repeat(4 - pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
return base64;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { validateJWT };
|
|
@ -11,6 +11,7 @@ import * as role from "./role";
|
||||||
import * as supporterKey from "./supporterKey";
|
import * as supporterKey from "./supporterKey";
|
||||||
import * as accessToken from "./accessToken";
|
import * as accessToken from "./accessToken";
|
||||||
import * as idp from "./idp";
|
import * as idp from "./idp";
|
||||||
|
import * as license from "./license";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import {
|
import {
|
||||||
verifyAccessTokenAccess,
|
verifyAccessTokenAccess,
|
||||||
|
@ -524,6 +525,30 @@ authenticated.delete("/idp/:idpId", verifyUserIsServerAdmin, idp.deleteIdp);
|
||||||
authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids
|
authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids
|
||||||
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
|
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/license/activate",
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
license.activateLicense
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/license/keys",
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
license.listLicenseKeys
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.delete(
|
||||||
|
"/license/:licenseKey",
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
license.deleteLicenseKey
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/license/recheck",
|
||||||
|
verifyUserIsServerAdmin,
|
||||||
|
license.recheckStatus
|
||||||
|
);
|
||||||
|
|
||||||
// Auth routes
|
// Auth routes
|
||||||
export const authRouter = Router();
|
export const authRouter = Router();
|
||||||
unauthenticated.use("/auth", authRouter);
|
unauthenticated.use("/auth", authRouter);
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { idp, idpOidcConfig } from "@server/db/schemas";
|
||||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||||
import { encrypt } from "@server/lib/crypto";
|
import { encrypt } from "@server/lib/crypto";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import license from "@server/license/license";
|
||||||
|
|
||||||
const paramsSchema = z.object({}).strict();
|
const paramsSchema = z.object({}).strict();
|
||||||
|
|
||||||
|
@ -67,7 +68,7 @@ export async function createOidcIdp(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
let {
|
||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
authUrl,
|
authUrl,
|
||||||
|
@ -80,6 +81,10 @@ export async function createOidcIdp(
|
||||||
autoProvision
|
autoProvision
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
if (!(await license.isUnlocked())) {
|
||||||
|
autoProvision = false;
|
||||||
|
}
|
||||||
|
|
||||||
const key = config.getRawConfig().server.secret;
|
const key = config.getRawConfig().server.secret;
|
||||||
|
|
||||||
const encryptedSecret = encrypt(clientSecret, key);
|
const encryptedSecret = encrypt(clientSecret, key);
|
||||||
|
|
|
@ -11,6 +11,7 @@ import { idp, idpOidcConfig } from "@server/db/schemas";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { encrypt } from "@server/lib/crypto";
|
import { encrypt } from "@server/lib/crypto";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import license from "@server/license/license";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -84,7 +85,7 @@ export async function updateOidcIdp(
|
||||||
}
|
}
|
||||||
|
|
||||||
const { idpId } = parsedParams.data;
|
const { idpId } = parsedParams.data;
|
||||||
const {
|
let {
|
||||||
clientId,
|
clientId,
|
||||||
clientSecret,
|
clientSecret,
|
||||||
authUrl,
|
authUrl,
|
||||||
|
@ -99,6 +100,10 @@ export async function updateOidcIdp(
|
||||||
defaultOrgMapping
|
defaultOrgMapping
|
||||||
} = parsedBody.data;
|
} = parsedBody.data;
|
||||||
|
|
||||||
|
if (!(await license.isUnlocked())) {
|
||||||
|
autoProvision = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if IDP exists and is of type OIDC
|
// Check if IDP exists and is of type OIDC
|
||||||
const [existingIdp] = await db
|
const [existingIdp] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|
|
@ -5,6 +5,7 @@ import * as resource from "./resource";
|
||||||
import * as badger from "./badger";
|
import * as badger from "./badger";
|
||||||
import * as auth from "@server/routers/auth";
|
import * as auth from "@server/routers/auth";
|
||||||
import * as supporterKey from "@server/routers/supporterKey";
|
import * as supporterKey from "@server/routers/supporterKey";
|
||||||
|
import * as license from "@server/routers/license";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import {
|
import {
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
|
@ -37,6 +38,11 @@ internalRouter.get(
|
||||||
supporterKey.isSupporterKeyVisible
|
supporterKey.isSupporterKeyVisible
|
||||||
);
|
);
|
||||||
|
|
||||||
|
internalRouter.get(
|
||||||
|
`/license/status`,
|
||||||
|
license.getLicenseStatus
|
||||||
|
);
|
||||||
|
|
||||||
// Gerbil routes
|
// Gerbil routes
|
||||||
const gerbilRouter = Router();
|
const gerbilRouter = Router();
|
||||||
internalRouter.use("/gerbil", gerbilRouter);
|
internalRouter.use("/gerbil", gerbilRouter);
|
||||||
|
|
62
server/routers/license/activateLicense.ts
Normal file
62
server/routers/license/activateLicense.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { response as sendResponse } from "@server/lib";
|
||||||
|
import license, { LicenseStatus } from "@server/license/license";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
|
||||||
|
const bodySchema = z
|
||||||
|
.object({
|
||||||
|
licenseKey: z.string().min(1).max(255)
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type ActivateLicenseStatus = LicenseStatus;
|
||||||
|
|
||||||
|
export async function activateLicense(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { licenseKey } = parsedBody.data;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const status = await license.activateLicenseKey(licenseKey);
|
||||||
|
return sendResponse(res, {
|
||||||
|
data: status,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "License key activated successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
76
server/routers/license/deleteLicenseKey.ts
Normal file
76
server/routers/license/deleteLicenseKey.ts
Normal file
|
@ -0,0 +1,76 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { response as sendResponse } from "@server/lib";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import db from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { licenseKey } from "@server/db/schemas";
|
||||||
|
import license, { LicenseStatus } from "@server/license/license";
|
||||||
|
|
||||||
|
const paramsSchema = z
|
||||||
|
.object({
|
||||||
|
licenseKey: z.string().min(1).max(255)
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export type DeleteLicenseKeyResponse = LicenseStatus;
|
||||||
|
|
||||||
|
export async function deleteLicenseKey(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
|
if (!parsedParams.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedParams.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { licenseKey: key } = parsedParams.data;
|
||||||
|
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(licenseKey)
|
||||||
|
.where(eq(licenseKey.licenseKeyId, key))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`License key ${key} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(licenseKey).where(eq(licenseKey.licenseKeyId, key));
|
||||||
|
|
||||||
|
const status = await license.forceRecheck();
|
||||||
|
|
||||||
|
return sendResponse(res, {
|
||||||
|
data: status,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "License key deleted successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
36
server/routers/license/getLicenseStatus.ts
Normal file
36
server/routers/license/getLicenseStatus.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { response as sendResponse } from "@server/lib";
|
||||||
|
import license, { LicenseStatus } from "@server/license/license";
|
||||||
|
|
||||||
|
export type GetLicenseStatusResponse = LicenseStatus;
|
||||||
|
|
||||||
|
export async function getLicenseStatus(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const status = await license.check();
|
||||||
|
|
||||||
|
return sendResponse<GetLicenseStatusResponse>(res, {
|
||||||
|
data: status,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Got status",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
10
server/routers/license/index.ts
Normal file
10
server/routers/license/index.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
export * from "./getLicenseStatus";
|
||||||
|
export * from "./activateLicense";
|
||||||
|
export * from "./listLicenseKeys";
|
||||||
|
export * from "./deleteLicenseKey";
|
||||||
|
export * from "./recheckStatus";
|
36
server/routers/license/listLicenseKeys.ts
Normal file
36
server/routers/license/listLicenseKeys.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { response as sendResponse } from "@server/lib";
|
||||||
|
import license, { LicenseKeyCache } from "@server/license/license";
|
||||||
|
|
||||||
|
export type ListLicenseKeysResponse = LicenseKeyCache[];
|
||||||
|
|
||||||
|
export async function listLicenseKeys(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
const keys = license.listKeys();
|
||||||
|
|
||||||
|
return sendResponse<ListLicenseKeysResponse>(res, {
|
||||||
|
data: keys,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Successfully retrieved license keys",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
42
server/routers/license/recheckStatus.ts
Normal file
42
server/routers/license/recheckStatus.ts
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { response as sendResponse } from "@server/lib";
|
||||||
|
import license, { LicenseStatus } from "@server/license/license";
|
||||||
|
|
||||||
|
export type RecheckStatusResponse = LicenseStatus;
|
||||||
|
|
||||||
|
export async function recheckStatus(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
next: NextFunction
|
||||||
|
): Promise<any> {
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
const status = await license.forceRecheck();
|
||||||
|
return sendResponse(res, {
|
||||||
|
data: status,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "License status rechecked successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(e);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -7,6 +7,7 @@ import config from "@server/lib/config";
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { count } from "drizzle-orm";
|
import { count } from "drizzle-orm";
|
||||||
import { users } from "@server/db/schemas";
|
import { users } from "@server/db/schemas";
|
||||||
|
import license from "@server/license/license";
|
||||||
|
|
||||||
export type IsSupporterKeyVisibleResponse = {
|
export type IsSupporterKeyVisibleResponse = {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
@ -26,6 +27,12 @@ export async function isSupporterKeyVisible(
|
||||||
|
|
||||||
let visible = !hidden && key?.valid !== true;
|
let visible = !hidden && key?.valid !== true;
|
||||||
|
|
||||||
|
const licenseStatus = await license.check();
|
||||||
|
|
||||||
|
if (licenseStatus.isLicenseValid) {
|
||||||
|
visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (key?.tier === "Limited Supporter") {
|
if (key?.tier === "Limited Supporter") {
|
||||||
const [numUsers] = await db.select({ count: count() }).from(users);
|
const [numUsers] = await db.select({ count: count() }).from(users);
|
||||||
|
|
||||||
|
|
|
@ -3,9 +3,11 @@ import { copyInConfig } from "./copyInConfig";
|
||||||
import { setupServerAdmin } from "./setupServerAdmin";
|
import { setupServerAdmin } from "./setupServerAdmin";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { clearStaleData } from "./clearStaleData";
|
import { clearStaleData } from "./clearStaleData";
|
||||||
|
import { setHostMeta } from "./setHostMeta";
|
||||||
|
|
||||||
export async function runSetupFunctions() {
|
export async function runSetupFunctions() {
|
||||||
try {
|
try {
|
||||||
|
await setHostMeta();
|
||||||
await copyInConfig(); // copy in the config to the db as needed
|
await copyInConfig(); // copy in the config to the db as needed
|
||||||
await setupServerAdmin();
|
await setupServerAdmin();
|
||||||
await ensureActions(); // make sure all of the actions are in the db and the roles
|
await ensureActions(); // make sure all of the actions are in the db and the roles
|
||||||
|
|
17
server/setup/setHostMeta.ts
Normal file
17
server/setup/setHostMeta.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
import db from "@server/db";
|
||||||
|
import { hostMeta } from "@server/db/schemas";
|
||||||
|
import { v4 as uuidv4 } from "uuid";
|
||||||
|
|
||||||
|
export async function setHostMeta() {
|
||||||
|
const [existing] = await db.select().from(hostMeta).limit(1);
|
||||||
|
|
||||||
|
if (existing && existing.hostMetaId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = uuidv4();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(hostMeta)
|
||||||
|
.values({ hostMetaId: id, createdAt: new Date().getTime() });
|
||||||
|
}
|
|
@ -42,6 +42,7 @@ import {
|
||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
|
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z.object({
|
||||||
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
|
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
|
||||||
|
@ -67,6 +68,7 @@ export default function GeneralPage() {
|
||||||
const { idpId } = useParams();
|
const { idpId } = useParams();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [initialLoading, setInitialLoading] = useState(true);
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
|
const { isUnlocked } = useLicenseStatusContext();
|
||||||
|
|
||||||
const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
|
const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
|
||||||
|
|
||||||
|
@ -230,7 +232,7 @@ export default function GeneralPage() {
|
||||||
defaultChecked={form.getValues(
|
defaultChecked={form.getValues(
|
||||||
"autoProvision"
|
"autoProvision"
|
||||||
)}
|
)}
|
||||||
disabled={true}
|
disabled={!isUnlocked()}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"autoProvision",
|
"autoProvision",
|
||||||
|
@ -238,12 +240,14 @@ export default function GeneralPage() {
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Badge
|
{!isUnlocked() && (
|
||||||
variant="outlinePrimary"
|
<Badge
|
||||||
className="ml-2"
|
variant="outlinePrimary"
|
||||||
>
|
className="ml-2"
|
||||||
Professional
|
>
|
||||||
</Badge>
|
Professional
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
When enabled, users will be
|
When enabled, users will be
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||||
|
import { ProfessionalContentOverlay } from "@app/components/ProfessionalContentOverlay";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowLeft } from "lucide-react";
|
import { ArrowLeft } from "lucide-react";
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
@ -35,7 +36,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) {
|
||||||
redirect("/admin/idp");
|
redirect("/admin/idp");
|
||||||
}
|
}
|
||||||
|
|
||||||
const navItems = [
|
const navItems: HorizontalTabs = [
|
||||||
{
|
{
|
||||||
title: "General",
|
title: "General",
|
||||||
href: `/admin/idp/${params.idpId}/general`
|
href: `/admin/idp/${params.idpId}/general`
|
||||||
|
|
|
@ -36,6 +36,7 @@ import { InfoIcon, ExternalLink } from "lucide-react";
|
||||||
import { StrategySelect } from "@app/components/StrategySelect";
|
import { StrategySelect } from "@app/components/StrategySelect";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
|
|
||||||
const createIdpFormSchema = z.object({
|
const createIdpFormSchema = z.object({
|
||||||
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
|
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
|
||||||
|
@ -74,6 +75,7 @@ export default function Page() {
|
||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
|
const { isUnlocked } = useLicenseStatusContext();
|
||||||
|
|
||||||
const form = useForm<CreateIdpFormValues>({
|
const form = useForm<CreateIdpFormValues>({
|
||||||
resolver: zodResolver(createIdpFormSchema),
|
resolver: zodResolver(createIdpFormSchema),
|
||||||
|
@ -190,7 +192,7 @@ export default function Page() {
|
||||||
defaultChecked={form.getValues(
|
defaultChecked={form.getValues(
|
||||||
"autoProvision"
|
"autoProvision"
|
||||||
)}
|
)}
|
||||||
disabled={true}
|
disabled={!isUnlocked()}
|
||||||
onCheckedChange={(checked) => {
|
onCheckedChange={(checked) => {
|
||||||
form.setValue(
|
form.setValue(
|
||||||
"autoProvision",
|
"autoProvision",
|
||||||
|
@ -198,12 +200,14 @@ export default function Page() {
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Badge
|
{!isUnlocked() && (
|
||||||
variant="outlinePrimary"
|
<Badge
|
||||||
className="ml-2"
|
variant="outlinePrimary"
|
||||||
>
|
className="ml-2"
|
||||||
Professional
|
>
|
||||||
</Badge>
|
Professional
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
When enabled, users will be
|
When enabled, users will be
|
||||||
|
|
147
src/app/admin/license/LicenseKeysDataTable.tsx
Normal file
147
src/app/admin/license/LicenseKeysDataTable.tsx
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { ColumnDef } from "@tanstack/react-table";
|
||||||
|
import { DataTable } from "@app/components/ui/data-table";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import { LicenseKeyCache } from "@server/license/license";
|
||||||
|
import { ArrowUpDown } from "lucide-react";
|
||||||
|
import moment from "moment";
|
||||||
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
|
|
||||||
|
type LicenseKeysDataTableProps = {
|
||||||
|
licenseKeys: LicenseKeyCache[];
|
||||||
|
onDelete: (key: string) => void;
|
||||||
|
onCreate: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function obfuscateLicenseKey(key: string): string {
|
||||||
|
if (key.length <= 8) return key;
|
||||||
|
const firstPart = key.substring(0, 4);
|
||||||
|
const lastPart = key.substring(key.length - 4);
|
||||||
|
return `${firstPart}••••••••••••••••••••${lastPart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LicenseKeysDataTable({
|
||||||
|
licenseKeys,
|
||||||
|
onDelete,
|
||||||
|
onCreate
|
||||||
|
}: LicenseKeysDataTableProps) {
|
||||||
|
const columns: ColumnDef<LicenseKeyCache>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "licenseKey",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
License Key
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const licenseKey = row.original.licenseKey;
|
||||||
|
return (
|
||||||
|
<CopyToClipboard
|
||||||
|
text={licenseKey}
|
||||||
|
displayText={obfuscateLicenseKey(licenseKey)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "valid",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Valid
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return row.original.valid ? "Yes" : "No";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "type",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Type
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const type = row.original.type;
|
||||||
|
const label =
|
||||||
|
type === "SITES" ? "Additional Sites" : "Host License";
|
||||||
|
const variant = type === "SITES" ? "secondary" : "default";
|
||||||
|
return row.original.valid ? (
|
||||||
|
<Badge variant={variant}>{label}</Badge>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "numSites",
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Number of Sites
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "delete",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center justify-end space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outlinePrimary"
|
||||||
|
onClick={() => onDelete(row.original.licenseKey)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataTable
|
||||||
|
columns={columns}
|
||||||
|
data={licenseKeys}
|
||||||
|
title="License Keys"
|
||||||
|
searchPlaceholder="Search license keys..."
|
||||||
|
searchColumn="licenseKey"
|
||||||
|
onAdd={onCreate}
|
||||||
|
addButtonText="Add License Key"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
131
src/app/admin/license/components/SitePriceCalculator.tsx
Normal file
131
src/app/admin/license/components/SitePriceCalculator.tsx
Normal file
|
@ -0,0 +1,131 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { MinusCircle, PlusCircle } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
|
||||||
|
type SitePriceCalculatorProps = {
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
mode: "license" | "additional-sites";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SitePriceCalculator({
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
mode
|
||||||
|
}: SitePriceCalculatorProps) {
|
||||||
|
const [siteCount, setSiteCount] = useState(1);
|
||||||
|
const pricePerSite = 5;
|
||||||
|
const licenseFlatRate = 125;
|
||||||
|
|
||||||
|
const incrementSites = () => {
|
||||||
|
setSiteCount((prev) => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const decrementSites = () => {
|
||||||
|
setSiteCount((prev) => (prev > 1 ? prev - 1 : 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalCost = mode === "license"
|
||||||
|
? licenseFlatRate + (siteCount * pricePerSite)
|
||||||
|
: siteCount * pricePerSite;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Credenza open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>
|
||||||
|
{mode === "license" ? "Purchase License" : "Purchase Additional Sites"}
|
||||||
|
</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
Choose how many sites you want to {mode === "license" ? "purchase a license for" : "add to your existing license"}.
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col items-center space-y-4">
|
||||||
|
<div className="text-sm font-medium text-muted-foreground">
|
||||||
|
Number of Sites
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={decrementSites}
|
||||||
|
disabled={siteCount <= 1}
|
||||||
|
aria-label="Decrease site count"
|
||||||
|
>
|
||||||
|
<MinusCircle className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
<span className="text-3xl w-12 text-center">
|
||||||
|
{siteCount}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={incrementSites}
|
||||||
|
aria-label="Increase site count"
|
||||||
|
>
|
||||||
|
<PlusCircle className="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
{mode === "license" && (
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
License fee:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
${licenseFlatRate.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between items-center mt-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Price per site:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
${pricePerSite.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center mt-2">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Number of sites:
|
||||||
|
</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{siteCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center mt-4 text-lg font-bold">
|
||||||
|
<span>Total:</span>
|
||||||
|
<span>${totalCost.toFixed(2)} / mo</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button>Continue to Payment</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
);
|
||||||
|
}
|
471
src/app/admin/license/page.tsx
Normal file
471
src/app/admin/license/page.tsx
Normal file
|
@ -0,0 +1,471 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { LicenseKeyCache } from "@server/license/license";
|
||||||
|
import { createApiClient } from "@app/lib/api";
|
||||||
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { toast } from "@app/hooks/useToast";
|
||||||
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
|
import { LicenseKeysDataTable } from "./LicenseKeysDataTable";
|
||||||
|
import { AxiosResponse } from "axios";
|
||||||
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage
|
||||||
|
} from "@app/components/ui/form";
|
||||||
|
import { Input } from "@app/components/ui/input";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Credenza,
|
||||||
|
CredenzaBody,
|
||||||
|
CredenzaClose,
|
||||||
|
CredenzaContent,
|
||||||
|
CredenzaDescription,
|
||||||
|
CredenzaFooter,
|
||||||
|
CredenzaHeader,
|
||||||
|
CredenzaTitle
|
||||||
|
} from "@app/components/Credenza";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
|
import {
|
||||||
|
SettingsContainer,
|
||||||
|
SettingsSectionTitle as SSTitle,
|
||||||
|
SettingsSection,
|
||||||
|
SettingsSectionDescription,
|
||||||
|
SettingsSectionGrid,
|
||||||
|
SettingsSectionHeader,
|
||||||
|
SettingsSectionFooter
|
||||||
|
} from "@app/components/Settings";
|
||||||
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import { Check, ShieldCheck, ShieldOff } from "lucide-react";
|
||||||
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
|
import { Progress } from "@app/components/ui/progress";
|
||||||
|
import { MinusCircle, PlusCircle } from "lucide-react";
|
||||||
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
|
import { SitePriceCalculator } from "./components/SitePriceCalculator";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
licenseKey: z
|
||||||
|
.string()
|
||||||
|
.nonempty({ message: "License key is required" })
|
||||||
|
.max(255)
|
||||||
|
});
|
||||||
|
|
||||||
|
function obfuscateLicenseKey(key: string): string {
|
||||||
|
if (key.length <= 8) return key;
|
||||||
|
const firstPart = key.substring(0, 4);
|
||||||
|
const lastPart = key.substring(key.length - 4);
|
||||||
|
return `${firstPart}••••••••••••••••••••${lastPart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LicensePage() {
|
||||||
|
const api = createApiClient(useEnvContext());
|
||||||
|
const [rows, setRows] = useState<LicenseKeyCache[]>([]);
|
||||||
|
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||||
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||||
|
const [selectedLicenseKey, setSelectedLicenseKey] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const router = useRouter();
|
||||||
|
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
|
||||||
|
const [hostLicense, setHostLicense] = useState<string | null>(null);
|
||||||
|
const [isPurchaseModalOpen, setIsPurchaseModalOpen] = useState(false);
|
||||||
|
const [purchaseMode, setPurchaseMode] = useState<
|
||||||
|
"license" | "additional-sites"
|
||||||
|
>("license");
|
||||||
|
|
||||||
|
// Separate loading states for different actions
|
||||||
|
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||||
|
const [isActivatingLicense, setIsActivatingLicense] = useState(false);
|
||||||
|
const [isDeletingLicense, setIsDeletingLicense] = useState(false);
|
||||||
|
const [isRecheckingLicense, setIsRecheckingLicense] = useState(false);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
licenseKey: ""
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
setIsInitialLoading(true);
|
||||||
|
await loadLicenseKeys();
|
||||||
|
setIsInitialLoading(false);
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function loadLicenseKeys() {
|
||||||
|
try {
|
||||||
|
const response =
|
||||||
|
await api.get<AxiosResponse<LicenseKeyCache[]>>(
|
||||||
|
"/license/keys"
|
||||||
|
);
|
||||||
|
const keys = response.data.data;
|
||||||
|
setRows(keys);
|
||||||
|
const hostKey = keys.find((key) => key.type === "LICENSE");
|
||||||
|
if (hostKey) {
|
||||||
|
setHostLicense(hostKey.licenseKey);
|
||||||
|
} else {
|
||||||
|
setHostLicense(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to load license keys",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"An error occurred loading license keys"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteLicenseKey(key: string) {
|
||||||
|
try {
|
||||||
|
setIsDeletingLicense(true);
|
||||||
|
const res = await api.delete(`/license/${key}`);
|
||||||
|
if (res.data.data) {
|
||||||
|
updateLicenseStatus(res.data.data);
|
||||||
|
}
|
||||||
|
await loadLicenseKeys();
|
||||||
|
toast({
|
||||||
|
title: "License key deleted",
|
||||||
|
description: "The license key has been deleted"
|
||||||
|
});
|
||||||
|
setIsDeleteModalOpen(false);
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to delete license key",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"An error occurred deleting license key"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDeletingLicense(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recheck() {
|
||||||
|
try {
|
||||||
|
setIsRecheckingLicense(true);
|
||||||
|
const res = await api.post(`/license/recheck`);
|
||||||
|
if (res.data.data) {
|
||||||
|
updateLicenseStatus(res.data.data);
|
||||||
|
}
|
||||||
|
await loadLicenseKeys();
|
||||||
|
toast({
|
||||||
|
title: "License keys rechecked",
|
||||||
|
description: "All license keys have been rechecked"
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
title: "Failed to recheck license keys",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"An error occurred rechecking license keys"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRecheckingLicense(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
try {
|
||||||
|
setIsActivatingLicense(true);
|
||||||
|
const res = await api.post("/license/activate", {
|
||||||
|
licenseKey: values.licenseKey
|
||||||
|
});
|
||||||
|
if (res.data.data) {
|
||||||
|
updateLicenseStatus(res.data.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "License key activated",
|
||||||
|
description: "The license key has been successfully activated."
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsCreateModalOpen(false);
|
||||||
|
form.reset();
|
||||||
|
await loadLicenseKeys();
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Failed to activate license key",
|
||||||
|
description: formatAxiosError(
|
||||||
|
e,
|
||||||
|
"An error occurred while activating the license key."
|
||||||
|
)
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsActivatingLicense(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isInitialLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SitePriceCalculator
|
||||||
|
isOpen={isPurchaseModalOpen}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setIsPurchaseModalOpen(val);
|
||||||
|
}}
|
||||||
|
mode={purchaseMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Credenza
|
||||||
|
open={isCreateModalOpen}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setIsCreateModalOpen(val);
|
||||||
|
form.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CredenzaContent>
|
||||||
|
<CredenzaHeader>
|
||||||
|
<CredenzaTitle>Activate License Key</CredenzaTitle>
|
||||||
|
<CredenzaDescription>
|
||||||
|
Enter a license key to activate it.
|
||||||
|
</CredenzaDescription>
|
||||||
|
</CredenzaHeader>
|
||||||
|
<CredenzaBody>
|
||||||
|
<Form {...form}>
|
||||||
|
<form
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
className="space-y-4"
|
||||||
|
id="activate-license-form"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="licenseKey"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>License Key</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</CredenzaBody>
|
||||||
|
<CredenzaFooter>
|
||||||
|
<CredenzaClose asChild>
|
||||||
|
<Button variant="outline">Close</Button>
|
||||||
|
</CredenzaClose>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
form="activate-license-form"
|
||||||
|
loading={isActivatingLicense}
|
||||||
|
disabled={isActivatingLicense}
|
||||||
|
>
|
||||||
|
Activate License
|
||||||
|
</Button>
|
||||||
|
</CredenzaFooter>
|
||||||
|
</CredenzaContent>
|
||||||
|
</Credenza>
|
||||||
|
|
||||||
|
{selectedLicenseKey && (
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
open={isDeleteModalOpen}
|
||||||
|
setOpen={(val) => {
|
||||||
|
setIsDeleteModalOpen(val);
|
||||||
|
setSelectedLicenseKey(null);
|
||||||
|
}}
|
||||||
|
dialog={
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p>
|
||||||
|
Are you sure you want to delete the license key{" "}
|
||||||
|
<b>{obfuscateLicenseKey(selectedLicenseKey)}</b>
|
||||||
|
?
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<b>
|
||||||
|
This will remove the license key and all
|
||||||
|
associated permissions. Any sites using this
|
||||||
|
license key will no longer be accessible.
|
||||||
|
</b>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
To confirm, please type the license key below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
buttonText="Confirm Delete License Key"
|
||||||
|
onConfirm={async () => deleteLicenseKey(selectedLicenseKey)}
|
||||||
|
string={selectedLicenseKey}
|
||||||
|
title="Delete License Key"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SettingsSectionTitle
|
||||||
|
title="Manage License Status"
|
||||||
|
description="View and manage license keys in the system"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SettingsContainer>
|
||||||
|
<SettingsSectionGrid cols={2}>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SSTitle>Host License</SSTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
Manage the main license key for the host.
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
{licenseStatus?.isLicenseValid ? (
|
||||||
|
<div className="space-y-2 text-green-500">
|
||||||
|
<div className="text-2xl flex items-center gap-2">
|
||||||
|
<Check />
|
||||||
|
Licensed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-2xl">
|
||||||
|
Not Licensed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{licenseStatus?.hostId && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
Host ID
|
||||||
|
</div>
|
||||||
|
<CopyTextBox text={licenseStatus.hostId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hostLicense && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
License Key
|
||||||
|
</div>
|
||||||
|
<CopyTextBox
|
||||||
|
text={hostLicense}
|
||||||
|
displayText={obfuscateLicenseKey(
|
||||||
|
hostLicense
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={recheck}
|
||||||
|
disabled={isRecheckingLicense}
|
||||||
|
loading={isRecheckingLicense}
|
||||||
|
>
|
||||||
|
Recheck All Keys
|
||||||
|
</Button>
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SSTitle>Sites Usage</SSTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
View the number of sites using this license.
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="text-2xl">
|
||||||
|
{licenseStatus?.usedSites || 0}{" "}
|
||||||
|
{licenseStatus?.usedSites === 1
|
||||||
|
? "site"
|
||||||
|
: "sites"}{" "}
|
||||||
|
in system
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{licenseStatus?.maxSites && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{licenseStatus.usedSites || 0} of{" "}
|
||||||
|
{licenseStatus.maxSites} sites used
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{Math.round(
|
||||||
|
((licenseStatus.usedSites ||
|
||||||
|
0) /
|
||||||
|
licenseStatus.maxSites) *
|
||||||
|
100
|
||||||
|
)}
|
||||||
|
%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={
|
||||||
|
((licenseStatus.usedSites || 0) /
|
||||||
|
licenseStatus.maxSites) *
|
||||||
|
100
|
||||||
|
}
|
||||||
|
className="h-5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<SettingsSectionFooter>
|
||||||
|
{!licenseStatus?.isHostLicensed ? (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {}}
|
||||||
|
>
|
||||||
|
View License Portal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setPurchaseMode("license");
|
||||||
|
setIsPurchaseModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Purchase License
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
setPurchaseMode("additional-sites");
|
||||||
|
setIsPurchaseModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Purchase Additional Sites
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</SettingsSectionFooter>
|
||||||
|
</SettingsSection>
|
||||||
|
</SettingsSectionGrid>
|
||||||
|
<LicenseKeysDataTable
|
||||||
|
licenseKeys={rows}
|
||||||
|
onDelete={(key) => {
|
||||||
|
setSelectedLicenseKey(key);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
onCreate={() => setIsCreateModalOpen(true)}
|
||||||
|
/>
|
||||||
|
</SettingsContainer>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -15,6 +15,7 @@ import {
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||||
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
|
|
||||||
type ValidateOidcTokenParams = {
|
type ValidateOidcTokenParams = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
|
@ -33,6 +34,8 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { licenseStatus, isLicenseViolation } = useLicenseStatusContext();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function validate() {
|
async function validate() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
@ -43,6 +46,10 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||||
stateCookie: props.stateCookie
|
stateCookie: props.stateCookie
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (isLicenseViolation()) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.post<
|
const res = await api.post<
|
||||||
AxiosResponse<ValidateOidcUrlCallbackResponse>
|
AxiosResponse<ValidateOidcUrlCallbackResponse>
|
||||||
|
|
45
src/app/components/LicenseViolation.tsx
Normal file
45
src/app/components/LicenseViolation.tsx
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
|
|
||||||
|
export default function LicenseViolation() {
|
||||||
|
const { licenseStatus } = useLicenseStatusContext();
|
||||||
|
|
||||||
|
if (!licenseStatus) return null;
|
||||||
|
|
||||||
|
// Show invalid license banner
|
||||||
|
if (licenseStatus.isHostLicensed && !licenseStatus.isLicenseValid) {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 w-full bg-red-500 text-white p-4 text-center z-50">
|
||||||
|
<p>
|
||||||
|
Invalid or expired license keys detected. Follow license
|
||||||
|
terms to continue using all features.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show usage violation banner
|
||||||
|
if (
|
||||||
|
licenseStatus.maxSites &&
|
||||||
|
licenseStatus.usedSites &&
|
||||||
|
licenseStatus.usedSites > licenseStatus.maxSites
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 w-full bg-yellow-500 text-black p-4 text-center z-50">
|
||||||
|
<p>
|
||||||
|
License Violation: Using {licenseStatus.usedSites} sites
|
||||||
|
exceeds your licensed limit of {licenseStatus.maxSites}{" "}
|
||||||
|
sites. Follow license terms to continue using all features.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
|
@ -1,25 +1,17 @@
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import {
|
import { Inter } from "next/font/google";
|
||||||
Figtree,
|
|
||||||
Inter,
|
|
||||||
Red_Hat_Display,
|
|
||||||
Red_Hat_Mono,
|
|
||||||
Red_Hat_Text,
|
|
||||||
Space_Grotesk
|
|
||||||
} from "next/font/google";
|
|
||||||
import { Toaster } from "@/components/ui/toaster";
|
import { Toaster } from "@/components/ui/toaster";
|
||||||
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
||||||
import EnvProvider from "@app/providers/EnvProvider";
|
import EnvProvider from "@app/providers/EnvProvider";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
import { pullEnv } from "@app/lib/pullEnv";
|
||||||
import { BookOpenText, ExternalLink } from "lucide-react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import SupportStatusProvider from "@app/providers/SupporterStatusProvider";
|
import SupportStatusProvider from "@app/providers/SupporterStatusProvider";
|
||||||
import { createApiClient, internal, priv } from "@app/lib/api";
|
import { priv } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey";
|
import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey";
|
||||||
import SupporterMessage from "./components/SupporterMessage";
|
import LicenseStatusProvider from "@app/providers/LicenseStatusProvider";
|
||||||
|
import { GetLicenseStatusResponse } from "@server/routers/license";
|
||||||
|
import LicenseViolation from "./components/LicenseViolation";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: `Dashboard - Pangolin`,
|
title: `Dashboard - Pangolin`,
|
||||||
|
@ -48,6 +40,12 @@ export default async function RootLayout({
|
||||||
supporterData.visible = res.data.data.visible;
|
supporterData.visible = res.data.data.visible;
|
||||||
supporterData.tier = res.data.data.tier;
|
supporterData.tier = res.data.data.tier;
|
||||||
|
|
||||||
|
const licenseStatusRes =
|
||||||
|
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
|
||||||
|
"/license/status"
|
||||||
|
);
|
||||||
|
const licenseStatus = licenseStatusRes.data.data;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html suppressHydrationWarning>
|
<html suppressHydrationWarning>
|
||||||
<body className={`${font.className} h-screen overflow-hidden`}>
|
<body className={`${font.className} h-screen overflow-hidden`}>
|
||||||
|
@ -58,14 +56,19 @@ export default async function RootLayout({
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<EnvProvider env={pullEnv()}>
|
<EnvProvider env={pullEnv()}>
|
||||||
<SupportStatusProvider supporterStatus={supporterData}>
|
<LicenseStatusProvider licenseStatus={licenseStatus}>
|
||||||
{/* Main content */}
|
<SupportStatusProvider
|
||||||
<div className="h-full flex flex-col">
|
supporterStatus={supporterData}
|
||||||
<div className="flex-1 overflow-auto">
|
>
|
||||||
{children}
|
{/* Main content */}
|
||||||
|
<div className="h-full flex flex-col">
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<LicenseViolation />
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</SupportStatusProvider>
|
||||||
</SupportStatusProvider>
|
</LicenseStatusProvider>
|
||||||
</EnvProvider>
|
</EnvProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
|
@ -7,7 +7,8 @@ import {
|
||||||
Waypoints,
|
Waypoints,
|
||||||
Combine,
|
Combine,
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
KeyRound
|
KeyRound,
|
||||||
|
TicketCheck
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export const orgLangingNavItems: SidebarNavItem[] = [
|
export const orgLangingNavItems: SidebarNavItem[] = [
|
||||||
|
@ -93,5 +94,10 @@ export const adminNavItems: SidebarNavItem[] = [
|
||||||
title: "Identity Providers",
|
title: "Identity Providers",
|
||||||
href: "/admin/idp",
|
href: "/admin/idp",
|
||||||
icon: <Fingerprint className="h-4 w-4" />
|
icon: <Fingerprint className="h-4 w-4" />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "License",
|
||||||
|
href: "/admin/license",
|
||||||
|
icon: <TicketCheck className="h-4 w-4" />
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
@ -4,20 +4,26 @@ import { useState, useRef } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Copy, Check } from "lucide-react";
|
import { Copy, Check } from "lucide-react";
|
||||||
|
|
||||||
|
type CopyTextBoxProps = {
|
||||||
|
text?: string;
|
||||||
|
displayText?: string;
|
||||||
|
wrapText?: boolean;
|
||||||
|
outline?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export default function CopyTextBox({
|
export default function CopyTextBox({
|
||||||
text = "",
|
text = "",
|
||||||
|
displayText,
|
||||||
wrapText = false,
|
wrapText = false,
|
||||||
outline = true
|
outline = true
|
||||||
}) {
|
}: CopyTextBoxProps) {
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
const textRef = useRef<HTMLPreElement>(null);
|
const textRef = useRef<HTMLPreElement>(null);
|
||||||
|
|
||||||
const copyToClipboard = async () => {
|
const copyToClipboard = async () => {
|
||||||
if (textRef.current) {
|
if (textRef.current) {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(
|
await navigator.clipboard.writeText(text);
|
||||||
textRef.current.textContent || ""
|
|
||||||
);
|
|
||||||
setIsCopied(true);
|
setIsCopied(true);
|
||||||
setTimeout(() => setIsCopied(false), 2000);
|
setTimeout(() => setIsCopied(false), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -38,7 +44,7 @@ export default function CopyTextBox({
|
||||||
: "overflow-x-auto"
|
: "overflow-x-auto"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<code className="block w-full">{text}</code>
|
<code className="block w-full">{displayText || text}</code>
|
||||||
</pre>
|
</pre>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|
|
@ -4,10 +4,11 @@ import { useState } from "react";
|
||||||
|
|
||||||
type CopyToClipboardProps = {
|
type CopyToClipboardProps = {
|
||||||
text: string;
|
text: string;
|
||||||
|
displayText?: string;
|
||||||
isLink?: boolean;
|
isLink?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
|
const CopyToClipboard = ({ text, displayText, isLink }: CopyToClipboardProps) => {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
|
@ -19,6 +20,8 @@ const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
|
||||||
}, 2000);
|
}, 2000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const displayValue = displayText ?? text;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center space-x-2 max-w-full">
|
<div className="flex items-center space-x-2 max-w-full">
|
||||||
{isLink ? (
|
{isLink ? (
|
||||||
|
@ -30,7 +33,7 @@ const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
|
||||||
style={{ maxWidth: "100%" }} // Ensures truncation works within parent
|
style={{ maxWidth: "100%" }} // Ensures truncation works within parent
|
||||||
title={text} // Shows full text on hover
|
title={text} // Shows full text on hover
|
||||||
>
|
>
|
||||||
{text}
|
{displayValue}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span
|
<span
|
||||||
|
@ -44,7 +47,7 @@ const CopyToClipboard = ({ text, isLink }: CopyToClipboardProps) => {
|
||||||
}}
|
}}
|
||||||
title={text} // Full text tooltip
|
title={text} // Full text tooltip
|
||||||
>
|
>
|
||||||
{text}
|
{displayValue}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -5,14 +5,19 @@ import Link from "next/link";
|
||||||
import { useParams, usePathname } from "next/navigation";
|
import { useParams, usePathname } from "next/navigation";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
import { buttonVariants } from "@/components/ui/button";
|
||||||
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
|
|
||||||
|
export type HorizontalTabs = Array<{
|
||||||
|
title: string;
|
||||||
|
href: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
showProfessional?: boolean;
|
||||||
|
}>;
|
||||||
|
|
||||||
interface HorizontalTabsProps {
|
interface HorizontalTabsProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
items: Array<{
|
items: HorizontalTabs;
|
||||||
title: string;
|
|
||||||
href: string;
|
|
||||||
icon?: React.ReactNode;
|
|
||||||
}>;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -23,6 +28,7 @@ export function HorizontalTabs({
|
||||||
}: HorizontalTabsProps) {
|
}: HorizontalTabsProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
|
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
||||||
|
|
||||||
function hydrateHref(href: string) {
|
function hydrateHref(href: string) {
|
||||||
return href
|
return href
|
||||||
|
@ -42,34 +48,47 @@ export function HorizontalTabs({
|
||||||
const isActive =
|
const isActive =
|
||||||
pathname.startsWith(hydratedHref) &&
|
pathname.startsWith(hydratedHref) &&
|
||||||
!pathname.includes("create");
|
!pathname.includes("create");
|
||||||
|
const isProfessional =
|
||||||
|
item.showProfessional && !isUnlocked();
|
||||||
|
const isDisabled =
|
||||||
|
disabled || (isProfessional && !isUnlocked());
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={hydratedHref}
|
key={hydratedHref}
|
||||||
href={hydratedHref}
|
href={isProfessional ? "#" : hydratedHref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap",
|
"px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap",
|
||||||
isActive
|
isActive
|
||||||
? "border-b-2 border-primary text-primary"
|
? "border-b-2 border-primary text-primary"
|
||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
disabled && "cursor-not-allowed"
|
isDisabled && "cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
onClick={
|
onClick={(e) => {
|
||||||
disabled
|
if (isDisabled) {
|
||||||
? (e) => e.preventDefault()
|
e.preventDefault();
|
||||||
: undefined
|
}
|
||||||
}
|
}}
|
||||||
tabIndex={disabled ? -1 : undefined}
|
tabIndex={isDisabled ? -1 : undefined}
|
||||||
aria-disabled={disabled}
|
aria-disabled={isDisabled}
|
||||||
>
|
>
|
||||||
{item.icon ? (
|
<div
|
||||||
<div className="flex items-center space-x-2">
|
className={cn(
|
||||||
{item.icon}
|
"flex items-center space-x-2",
|
||||||
<span>{item.title}</span>
|
isDisabled && "opacity-60"
|
||||||
</div>
|
)}
|
||||||
) : (
|
>
|
||||||
item.title
|
{item.icon && item.icon}
|
||||||
)}
|
<span>{item.title}</span>
|
||||||
|
{isProfessional && (
|
||||||
|
<Badge
|
||||||
|
variant="outlinePrimary"
|
||||||
|
className="ml-2"
|
||||||
|
>
|
||||||
|
Professional
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -161,14 +161,6 @@ export function Layout({
|
||||||
>
|
>
|
||||||
Documentation
|
Documentation
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
|
||||||
href="mailto:support@fossorial.io"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
Support
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<ProfileIcon />
|
<ProfileIcon />
|
||||||
|
|
42
src/components/ProfessionalContentOverlay.tsx
Normal file
42
src/components/ProfessionalContentOverlay.tsx
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
|
||||||
|
type ProfessionalContentOverlayProps = {
|
||||||
|
children: React.ReactNode;
|
||||||
|
isProfessional?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProfessionalContentOverlay({
|
||||||
|
children,
|
||||||
|
isProfessional = false
|
||||||
|
}: ProfessionalContentOverlayProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"relative",
|
||||||
|
isProfessional && "opacity-60 pointer-events-none"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isProfessional && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-50">
|
||||||
|
<div className="text-center p-6 bg-primary/10 rounded-lg">
|
||||||
|
<h3 className="text-lg font-semibold mb-2">
|
||||||
|
Professional Edition Required
|
||||||
|
</h3>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
This feature is only available in the Professional
|
||||||
|
Edition.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -3,7 +3,7 @@ export function SettingsContainer({ children }: { children: React.ReactNode }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSection({ children }: { children: React.ReactNode }) {
|
export function SettingsSection({ children }: { children: React.ReactNode }) {
|
||||||
return <div className="border rounded-lg bg-card p-5">{children}</div>;
|
return <div className="border rounded-lg bg-card p-5 flex flex-col min-h-[200px]">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSectionHeader({
|
export function SettingsSectionHeader({
|
||||||
|
@ -47,7 +47,7 @@ export function SettingsSectionBody({
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return <div className="space-y-5">{children}</div>;
|
return <div className="space-y-5 flex-grow">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSectionFooter({
|
export function SettingsSectionFooter({
|
||||||
|
@ -55,7 +55,7 @@ export function SettingsSectionFooter({
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return <div className="flex justify-end space-x-4 mt-8">{children}</div>;
|
return <div className="flex justify-end space-x-2 mt-auto pt-8">{children}</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSectionGrid({
|
export function SettingsSectionGrid({
|
||||||
|
|
|
@ -7,6 +7,7 @@ import { cn } from "@app/lib/cn";
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
|
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
||||||
|
|
||||||
export interface SidebarNavItem {
|
export interface SidebarNavItem {
|
||||||
href: string;
|
href: string;
|
||||||
|
@ -37,6 +38,7 @@ export function SidebarNav({
|
||||||
const resourceId = params.resourceId as string;
|
const resourceId = params.resourceId as string;
|
||||||
const userId = params.userId as string;
|
const userId = params.userId as string;
|
||||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||||
|
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
||||||
|
|
||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
|
|
||||||
|
@ -98,7 +100,7 @@ export function SidebarNav({
|
||||||
const hasChildren = item.children && item.children.length > 0;
|
const hasChildren = item.children && item.children.length > 0;
|
||||||
const isExpanded = expandedItems.has(hydratedHref);
|
const isExpanded = expandedItems.has(hydratedHref);
|
||||||
const indent = level * 28; // Base indent for each level
|
const indent = level * 28; // Base indent for each level
|
||||||
const isProfessional = item.showProfessional;
|
const isProfessional = item.showProfessional && !isUnlocked();
|
||||||
const isDisabled = disabled || isProfessional;
|
const isDisabled = disabled || isProfessional;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -16,8 +16,8 @@ const badgeVariants = cva(
|
||||||
destructive:
|
destructive:
|
||||||
"border-transparent bg-destructive text-destructive-foreground",
|
"border-transparent bg-destructive text-destructive-foreground",
|
||||||
outline: "text-foreground",
|
outline: "text-foreground",
|
||||||
green: "border-transparent bg-green-300",
|
green: "border-transparent bg-green-500",
|
||||||
yellow: "border-transparent bg-yellow-300",
|
yellow: "border-transparent bg-yellow-500",
|
||||||
red: "border-transparent bg-red-300",
|
red: "border-transparent bg-red-300",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
30
src/components/ui/progress.tsx
Normal file
30
src/components/ui/progress.tsx
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as ProgressPrimitive from "@radix-ui/react-progress";
|
||||||
|
import { cn } from "@app/lib/cn";
|
||||||
|
|
||||||
|
function Progress({
|
||||||
|
className,
|
||||||
|
value,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<ProgressPrimitive.Root
|
||||||
|
data-slot="progress"
|
||||||
|
className={cn(
|
||||||
|
"border relative h-2 w-full overflow-hidden rounded-full",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ProgressPrimitive.Indicator
|
||||||
|
data-slot="progress-indicator"
|
||||||
|
className="bg-primary h-full w-full flex-1 transition-all"
|
||||||
|
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||||
|
/>
|
||||||
|
</ProgressPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Progress };
|
20
src/contexts/licenseStatusContext.ts
Normal file
20
src/contexts/licenseStatusContext.ts
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import { LicenseStatus } from "@server/license/license";
|
||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
type LicenseStatusContextType = {
|
||||||
|
licenseStatus: LicenseStatus | null;
|
||||||
|
updateLicenseStatus: (updatedSite: LicenseStatus) => void;
|
||||||
|
isLicenseViolation: () => boolean;
|
||||||
|
isUnlocked: () => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LicenseStatusContext = createContext<
|
||||||
|
LicenseStatusContextType | undefined
|
||||||
|
>(undefined);
|
||||||
|
|
||||||
|
export default LicenseStatusContext;
|
17
src/hooks/useLicenseStatusContext.ts
Normal file
17
src/hooks/useLicenseStatusContext.ts
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
import LicenseStatusContext from "@app/contexts/licenseStatusContext";
|
||||||
|
import { useContext } from "react";
|
||||||
|
|
||||||
|
export function useLicenseStatusContext() {
|
||||||
|
const context = useContext(LicenseStatusContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error(
|
||||||
|
"useLicenseStatusContext must be used within an LicenseStatusProvider"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
72
src/providers/LicenseStatusProvider.tsx
Normal file
72
src/providers/LicenseStatusProvider.tsx
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
// This file is licensed under the Fossorial Commercial License.
|
||||||
|
// Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
||||||
|
//
|
||||||
|
// Copyright (c) 2025 Fossorial LLC. All rights reserved.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import LicenseStatusContext from "@app/contexts/licenseStatusContext";
|
||||||
|
import { LicenseStatus } from "@server/license/license";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface ProviderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
licenseStatus: LicenseStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LicenseStatusProvider({
|
||||||
|
children,
|
||||||
|
licenseStatus
|
||||||
|
}: ProviderProps) {
|
||||||
|
const [licenseStatusState, setLicenseStatusState] =
|
||||||
|
useState<LicenseStatus | null>(licenseStatus);
|
||||||
|
|
||||||
|
const updateLicenseStatus = (updatedLicenseStatus: LicenseStatus) => {
|
||||||
|
setLicenseStatusState((prev) => {
|
||||||
|
return {
|
||||||
|
...updatedLicenseStatus
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUnlocked = () => {
|
||||||
|
if (licenseStatusState?.isHostLicensed) {
|
||||||
|
if (licenseStatusState?.isLicenseValid) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isLicenseViolation = () => {
|
||||||
|
if (
|
||||||
|
licenseStatusState?.isHostLicensed &&
|
||||||
|
!licenseStatusState?.isLicenseValid
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
licenseStatusState?.maxSites &&
|
||||||
|
licenseStatusState?.usedSites &&
|
||||||
|
licenseStatusState.usedSites > licenseStatusState.maxSites
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LicenseStatusContext.Provider
|
||||||
|
value={{
|
||||||
|
licenseStatus: licenseStatusState,
|
||||||
|
updateLicenseStatus,
|
||||||
|
isLicenseViolation,
|
||||||
|
isUnlocked
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</LicenseStatusContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LicenseStatusProvider;
|
Loading…
Reference in a new issue