diff --git a/Dockerfile b/Dockerfile index 6b2c55a..6ec9e23 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,8 @@ FROM node:20-alpine AS builder WORKDIR /app -COPY package.json package-lock.json ./ +# COPY package.json package-lock.json ./ +COPY package.json ./ RUN npm install COPY . . @@ -20,7 +21,7 @@ RUN apk add --no-cache curl # COPY package.json package-lock.json ./ COPY package.json ./ -RUN npm install --omit=dev && npm cache clean --force +RUN npm install --only=production && npm cache clean --force COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static diff --git a/LICENSE b/LICENSE index 0e38f56..8c5cfb8 100644 --- a/LICENSE +++ b/LICENSE @@ -1,5 +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 Version 3, 19 November 2007 diff --git a/README.md b/README.md index e513a13..15ca7ad 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,6 @@ Pangolin is a self-hosted tunneled reverse proxy server with identity and access _Resources page of Pangolin dashboard (dark mode) showing multiple resources available to connect._ -This is a fork of Pangolin with all proprietary code removed. Proprietary and paywalled features -will be reimplemented under the AGPL license. - ## Key Features ### Reverse Proxy Through WireGuard Tunnel diff --git a/package-lock.json b/package-lock.json index 20cb9e5..c6da917 100644 --- a/package-lock.json +++ b/package-lock.json @@ -383,30 +383,11 @@ "@noble/ciphers": "^1.0.0" } }, - "node_modules/@emnapi/core": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-0.45.0.tgz", - "integrity": "sha512-DPWjcUDQkCeEM4VnljEOEcXdAD7pp8zSZsgOujk/LGIwCXWbXJngin+MO4zbH429lzeC3WbYLGjE2MaUOwzpyw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/runtime": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz", - "integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.2.tgz", - "integrity": "sha512-5n3nTJblwRi8LlXkJ9eBzu+kZR8Yxcc7ubakyQTFzPMtIhFpUBRbsnc2Dv88IZDIbCDlBiWrknhB4Lsz7mg6BA==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", + "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -1637,39 +1618,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "0.2.9", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.9.tgz", - "integrity": "sha512-OKRBiajrrxB9ATokgEQoG87Z25c67pCpYcCwmXYX8PBftC9pBfN18gnm/fh1wurSLEKIAt+QRFLFCQISrb66Jg==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^1.4.0", - "@emnapi/runtime": "^1.4.0", - "@tybys/wasm-util": "^0.9.0" - } - }, - "node_modules/@napi-rs/wasm-runtime/node_modules/@emnapi/core": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", - "integrity": "sha512-4m62DuCE07lw01soJwPiBGC0nAww0Q+RY70VZ+n49yDIO13yyinhbWCeNnaob0lakDtWQzSdtNWzJeOJt2ma+g==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.0.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@napi-rs/wasm-runtime/node_modules/@tybys/wasm-util": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", - "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@next/env": { "version": "15.2.4", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz", @@ -1880,38 +1828,6 @@ "@node-rs/argon2-win32-x64-msvc": "2.0.2" } }, - "node_modules/@node-rs/argon2-android-arm-eabi": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-1.7.0.tgz", - "integrity": "sha512-udDqkr5P9E+wYX1SZwAVPdyfYvaF4ry9Tm+R9LkfSHbzWH0uhU6zjIwNRp7m+n4gx691rk+lqqDAIP8RLKwbhg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-android-arm64": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-1.7.0.tgz", - "integrity": "sha512-s9j/G30xKUx8WU50WIhF0fIl1EdhBGq0RQ06lEhZ0Gi0ap8lhqbE2Bn5h3/G2D1k0Dx+yjeVVNmt/xOQIRG38A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@node-rs/argon2-darwin-arm64": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.0.2.tgz", @@ -1928,387 +1844,6 @@ "node": ">= 10" } }, - "node_modules/@node-rs/argon2-darwin-x64": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-1.7.0.tgz", - "integrity": "sha512-5oi/pxqVhODW/pj1+3zElMTn/YukQeywPHHYDbcAW3KsojFjKySfhcJMd1DjKTc+CHQI+4lOxZzSUzK7mI14Hw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-freebsd-x64": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-1.7.0.tgz", - "integrity": "sha512-Ify08683hA4QVXYoIm5SUWOY5DPIT/CMB0CQT+IdxQAg/F+qp342+lUkeAtD5bvStQuCx/dFO3bnnzoe2clMhA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-arm-gnueabihf": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-1.7.0.tgz", - "integrity": "sha512-7DjDZ1h5AUHAtRNjD19RnQatbhL+uuxBASuuXIBu4/w6Dx8n7YPxwTP4MXfsvuRgKuMWiOb/Ub/HJ3kXVCXRkg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-arm64-gnu": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-1.7.0.tgz", - "integrity": "sha512-nJDoMP4Y3YcqGswE4DvP080w6O24RmnFEDnL0emdI8Nou17kNYBzP2546Nasx9GCyLzRcYQwZOUjrtUuQ+od2g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-arm64-musl": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-1.7.0.tgz", - "integrity": "sha512-BKWS8iVconhE3jrb9mj6t1J9vwUqQPpzCbUKxfTGJfc+kNL58F1SXHBoe2cDYGnHrFEHTY0YochzXoAfm4Dm/A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-x64-gnu": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.0.2.tgz", - "integrity": "sha512-ZM3jrHuJ0dKOhvA80gKJqBpBRmTJTFSo2+xVZR+phQcbAKRlDMSZMFDiKbSTnctkfwNFtjgDdh5g1vaEV04AvA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-linux-x64-musl": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-1.7.0.tgz", - "integrity": "sha512-/o1efYCYIxjfuoRYyBTi2Iy+1iFfhqHCvvVsnjNSgO1xWiWrX0Rrt/xXW5Zsl7vS2Y+yu8PL8KFWRzZhaVxfKA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-wasm32-wasi": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-1.7.0.tgz", - "integrity": "sha512-Evmk9VcxqnuwQftfAfYEr6YZYSPLzmKUsbFIMep5nTt9PT4XYRFAERj7wNYp+rOcBenF3X4xoB+LhwcOMTNE5w==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "^0.45.0", - "@emnapi/runtime": "^0.45.0", - "@tybys/wasm-util": "^0.8.1", - "memfs-browser": "^3.4.13000" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@node-rs/argon2-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "0.45.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-0.45.0.tgz", - "integrity": "sha512-Txumi3td7J4A/xTTwlssKieHKTGl3j4A1tglBx72auZ49YK7ePY6XZricgIg9mnZT4xPfA+UPCUdnhRuEFDL+w==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@node-rs/argon2-win32-arm64-msvc": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-1.7.0.tgz", - "integrity": "sha512-qgsU7T004COWWpSA0tppDqDxbPLgg8FaU09krIJ7FBl71Sz8SFO40h7fDIjfbTT5w7u6mcaINMQ5bSHu75PCaA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-win32-ia32-msvc": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-1.7.0.tgz", - "integrity": "sha512-JGafwWYQ/HpZ3XSwP4adQ6W41pRvhcdXvpzIWtKvX+17+xEXAe2nmGWM6s27pVkg1iV2ZtoYLRDkOUoGqZkCcg==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2-win32-x64-msvc": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-1.7.0.tgz", - "integrity": "sha512-9oq4ShyFakw8AG3mRls0AoCpxBFcimYx7+jvXeAf2OqKNO+mSA6eZ9z7KQeVCi0+SOEUYxMGf5UiGiDb9R6+9Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2/node_modules/@node-rs/argon2-android-arm-eabi": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.0.2.tgz", - "integrity": "sha512-DV/H8p/jt40lrao5z5g6nM9dPNPGEHL+aK6Iy/og+dbL503Uj0AHLqj1Hk9aVUSCNnsDdUEKp4TVMi0YakDYKw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2/node_modules/@node-rs/argon2-android-arm64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.0.2.tgz", - "integrity": "sha512-1LKwskau+8O1ktKx7TbK7jx1oMOMt4YEXZOdSNIar1TQKxm6isZ0cRXgHLibPHEcNHgYRsJWDE9zvDGBB17QDg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2/node_modules/@node-rs/argon2-darwin-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.0.2.tgz", - "integrity": "sha512-vNPfkLj5Ij5111UTiYuwgxMqE7DRbOS2y58O2DIySzSHbcnu+nipmRKg+P0doRq6eKIJStyBK8dQi5Ic8pFyDw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2/node_modules/@node-rs/argon2-freebsd-x64": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.0.2.tgz", - "integrity": "sha512-M8vQZk01qojQfCqQU0/O1j1a4zPPrz93zc9fSINY7Q/6RhQRBCYwDw7ltDCZXg5JRGlSaeS8cUXWyhPGar3cGg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2/node_modules/@node-rs/argon2-linux-arm-gnueabihf": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.0.2.tgz", - "integrity": "sha512-7EmmEPHLzcu0G2GDh30L6G48CH38roFC2dqlQJmtRCxs6no3tTE/pvgBGatTp/o2n2oyOJcfmgndVFcUpwMnww==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2/node_modules/@node-rs/argon2-linux-arm64-gnu": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.0.2.tgz", - "integrity": "sha512-6lsYh3Ftbk+HAIZ7wNuRF4SZDtxtFTfK+HYFAQQyW7Ig3LHqasqwfUKRXVSV5tJ+xTnxjqgKzvZSUJCAyIfHew==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2/node_modules/@node-rs/argon2-linux-arm64-musl": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.0.2.tgz", - "integrity": "sha512-p3YqVMNT/4DNR67tIHTYGbedYmXxW9QlFmF39SkXyEbGQwpgSf6pH457/fyXBIYznTU/smnG9EH+C1uzT5j4hA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2/node_modules/@node-rs/argon2-linux-x64-musl": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.0.2.tgz", - "integrity": "sha512-of5uPqk7oCRF/44a89YlWTEfjsftPywyTULwuFDKyD8QtVZoonrJR6ZWvfFE/6jBT68S0okAkAzzMEdBVWdxWw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2/node_modules/@node-rs/argon2-wasm32-wasi": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-wasm32-wasi/-/argon2-wasm32-wasi-2.0.2.tgz", - "integrity": "sha512-U3PzLYKSQYzTERstgtHLd4ZTkOF9co57zTXT77r0cVUsleGZOrd6ut7rHzeWwoJSiHOVxxa0OhG1JVQeB7lLoQ==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^0.2.5" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@node-rs/argon2/node_modules/@node-rs/argon2-win32-arm64-msvc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.0.2.tgz", - "integrity": "sha512-Eisd7/NM0m23ijrGr6xI2iMocdOuyl6gO27gfMfya4C5BODbUSP7ljKJ7LrA0teqZMdYHesRDzx36Js++/vhiQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2/node_modules/@node-rs/argon2-win32-ia32-msvc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.0.2.tgz", - "integrity": "sha512-GsE2ezwAYwh72f9gIjbGTZOf4HxEksb5M2eCaj+Y5rGYVwAdt7C12Q2e9H5LRYxWcFvLH4m4jiSZpQQ4upnPAQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@node-rs/argon2/node_modules/@node-rs/argon2-win32-x64-msvc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.0.2.tgz", - "integrity": "sha512-cJxWXanH4Ew9CfuZ4IAEiafpOBCe97bzoKowHCGk5lG/7kR4WF/eknnBlHW9m8q7t10mKq75kruPLtbSDqgRTw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/@node-rs/bcrypt": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/@node-rs/bcrypt/-/bcrypt-1.9.0.tgz", @@ -4144,16 +3679,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@tybys/wasm-util": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.8.3.tgz", - "integrity": "sha512-Z96T/L6dUFFxgFJ+pQtkPpne9q7i6kIPYCFnQBHSgSPV9idTsKfIhCss0h5iM9irweZCatkrdeP8yi5uM1eX6Q==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@types/better-sqlite3": { "version": "7.6.12", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.12.tgz", @@ -7997,13 +7522,6 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, - "node_modules/fs-monkey": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.0.6.tgz", - "integrity": "sha512-b1FMfwetIKymC0eioW7mTywihSQE4oLzQn1dB6rZB5fx/3NpNEdAWeCSMB+60/AeT0TCXsxzAlcYVEFCTAksWg==", - "license": "Unlicense", - "optional": true - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -9665,29 +9183,6 @@ "node": ">= 0.6" } }, - "node_modules/memfs": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", - "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", - "license": "Unlicense", - "optional": true, - "dependencies": { - "fs-monkey": "^1.0.4" - }, - "engines": { - "node": ">= 4.0.0" - } - }, - "node_modules/memfs-browser": { - "version": "3.5.10302", - "resolved": "https://registry.npmjs.org/memfs-browser/-/memfs-browser-3.5.10302.tgz", - "integrity": "sha512-JJTc/nh3ig05O0gBBGZjTCPOyydaTxNF0uHYBrcc1gHNnO+KIHIvo0Y1FKCJsaei6FCl8C6xfQomXqu+cuzkIw==", - "license": "Unlicense", - "optional": true, - "dependencies": { - "memfs": "3.5.3" - } - }, "node_modules/merge-descriptors": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", @@ -12865,22 +12360,6 @@ "node": ">= 10" } }, - "node_modules/oslo/node_modules/@node-rs/argon2-linux-x64-gnu": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-1.7.0.tgz", - "integrity": "sha512-EmgqZOlf4Jurk/szW1iTsVISx25bKksVC5uttJDUloTgsAgIGReCpUUO1R24pBhu9ESJa47iv8NSf3yAfGv6jQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", diff --git a/server/auth/actions.ts b/server/auth/actions.ts index d974f03..e83031a 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -1,7 +1,7 @@ import { Request } from "express"; import { db } from "@server/db"; import { userActions, roleActions, userOrgs } from "@server/db/schemas"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -51,7 +51,6 @@ export enum ActionsEnum { listRoleResources = "listRoleResources", // listRoleActions = "listRoleActions", addUserRole = "addUserRole", - setUserRoles = "setUserRoles", // addUserSite = "addUserSite", // addUserAction = "addUserAction", // removeUserAction = "removeUserAction", @@ -107,28 +106,29 @@ export async function checkUserActionPermission( } try { - let userRoleIds = req.userRoleIds; + let userOrgRoleId = req.userOrgRoleId; // If userOrgRoleId is not available on the request, fetch it - if (userRoleIds === undefined) { - const userOrgRoles = await db - .select({ roleId: userOrgs.roleId }) + if (userOrgRoleId === undefined) { + const userOrgRole = await db + .select() .from(userOrgs) .where( and( eq(userOrgs.userId, userId), eq(userOrgs.orgId, req.userOrgId!) ) - ); + ) + .limit(1); - if (userOrgRoles.length === 0) { + if (userOrgRole.length === 0) { throw createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ); } - userRoleIds = userOrgRoles.map((r) => r.roleId); + userOrgRoleId = userOrgRole[0].roleId; } // Check if the user has direct permission for the action in the current org @@ -155,8 +155,8 @@ export async function checkUserActionPermission( .where( and( eq(roleActions.actionId, actionId), - eq(roleActions.orgId, req.userOrgId!), - inArray(roleActions.roleId, userRoleIds!) + eq(roleActions.roleId, userOrgRoleId!), + eq(roleActions.orgId, req.userOrgId!) ) ) .limit(1); diff --git a/server/auth/canUserAccessResource.ts b/server/auth/canUserAccessResource.ts index f322529..0d61825 100644 --- a/server/auth/canUserAccessResource.ts +++ b/server/auth/canUserAccessResource.ts @@ -1,15 +1,15 @@ import db from "@server/db"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { roleResources, userResources } from "@server/db/schemas"; export async function canUserAccessResource({ userId, resourceId, - roleIds + roleId }: { userId: string; resourceId: number; - roleIds: number[]; + roleId: number; }): Promise { const roleResourceAccess = await db .select() @@ -17,7 +17,7 @@ export async function canUserAccessResource({ .where( and( eq(roleResources.resourceId, resourceId), - inArray(roleResources.roleId, roleIds) + eq(roleResources.roleId, roleId) ) ) .limit(1); diff --git a/server/db/schemas/schema.ts b/server/db/schemas/schema.ts index 7c790eb..ebbc0ce 100644 --- a/server/db/schemas/schema.ts +++ b/server/db/schemas/schema.ts @@ -417,6 +417,15 @@ export const resourceRules = sqliteTable("resourceRules", { value: text("value").notNull() }); +export const supporterKey = sqliteTable("supporterKey", { + keyId: integer("keyId").primaryKey({ autoIncrement: true }), + key: text("key").notNull(), + githubUsername: text("githubUsername").notNull(), + phrase: text("phrase"), + tier: text("tier"), + valid: integer("valid", { mode: "boolean" }).notNull().default(false) +}); + // Identity Providers export const idp = sqliteTable("idp", { idpId: integer("idpId").primaryKey({ autoIncrement: true }), @@ -449,6 +458,12 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", { scopes: text("scopes").notNull() }); +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() @@ -528,8 +543,8 @@ export type ResourceWhitelist = InferSelectModel; export type VersionMigration = InferSelectModel; export type ResourceRule = InferSelectModel; export type Domain = InferSelectModel; +export type SupporterKey = InferSelectModel; export type Idp = InferSelectModel; -export type IdpOrg = InferSelectModel; export type ApiKey = InferSelectModel; export type ApiKeyAction = InferSelectModel; export type ApiKeyOrg = InferSelectModel; diff --git a/server/index.ts b/server/index.ts index 7dacae1..4c16caa 100644 --- a/server/index.ts +++ b/server/index.ts @@ -5,7 +5,8 @@ import { createApiServer } from "./apiServer"; import { createNextServer } from "./nextServer"; import { createInternalServer } from "./internalServer"; import { ApiKey, ApiKeyOrg, Session, User, UserOrg } from "./db/schemas"; -// import { createIntegrationApiServer } from "./integrationApiServer"; +import { createIntegrationApiServer } from "./integrationApiServer"; +import license from "./license/license.js"; async function startServers() { await runSetupFunctions(); @@ -16,7 +17,9 @@ async function startServers() { const nextServer = await createNextServer(); let integrationServer; - // integrationServer = createIntegrationApiServer(); + if (await license.isUnlocked()) { + integrationServer = createIntegrationApiServer(); + } return { apiServer, @@ -35,7 +38,7 @@ declare global { session?: Session; userOrg?: UserOrg; apiKeyOrg?: ApiKeyOrg; - userRoleIds?: number[]; + userOrgRoleId?: number; userOrgId?: string; userOrgIds?: string[]; } diff --git a/server/integrationApiServer.ts b/server/integrationApiServer.ts new file mode 100644 index 0000000..ff5dca5 --- /dev/null +++ b/server/integrationApiServer.ts @@ -0,0 +1,110 @@ +// 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 express from "express"; +import cors from "cors"; +import cookieParser from "cookie-parser"; +import config from "@server/lib/config"; +import logger from "@server/logger"; +import { + errorHandlerMiddleware, + notFoundMiddleware, + verifyValidLicense +} from "@server/middlewares"; +import { authenticated, unauthenticated } from "@server/routers/integration"; +import { logIncomingMiddleware } from "./middlewares/logIncoming"; +import helmet from "helmet"; +import swaggerUi from "swagger-ui-express"; +import { OpenApiGeneratorV3 } from "@asteasolutions/zod-to-openapi"; +import { registry } from "./openApi"; + +const dev = process.env.ENVIRONMENT !== "prod"; +const externalPort = config.getRawConfig().server.integration_port; + +export function createIntegrationApiServer() { + const apiServer = express(); + + apiServer.use(verifyValidLicense); + + if (config.getRawConfig().server.trust_proxy) { + apiServer.set("trust proxy", 1); + } + + apiServer.use(cors()); + + if (!dev) { + apiServer.use(helmet()); + } + + apiServer.use(cookieParser()); + apiServer.use(express.json()); + + apiServer.use( + "/v1/docs", + swaggerUi.serve, + swaggerUi.setup(getOpenApiDocumentation()) + ); + + // API routes + const prefix = `/v1`; + apiServer.use(logIncomingMiddleware); + apiServer.use(prefix, unauthenticated); + apiServer.use(prefix, authenticated); + + // Error handling + apiServer.use(notFoundMiddleware); + apiServer.use(errorHandlerMiddleware); + + // Create HTTP server + const httpServer = apiServer.listen(externalPort, (err?: any) => { + if (err) throw err; + logger.info( + `Integration API server is running on http://localhost:${externalPort}` + ); + }); + + return httpServer; +} + +function getOpenApiDocumentation() { + const bearerAuth = registry.registerComponent( + "securitySchemes", + "Bearer Auth", + { + type: "http", + scheme: "bearer" + } + ); + + for (const def of registry.definitions) { + if (def.type === "route") { + def.route.security = [ + { + [bearerAuth.name]: [] + } + ]; + } + } + + registry.registerPath({ + method: "get", + path: "/", + description: "Health check", + tags: [], + request: {}, + responses: {} + }); + + const generator = new OpenApiGeneratorV3(registry.definitions); + + return generator.generateDocument({ + openapi: "3.0.0", + info: { + version: "v1", + title: "Pangolin Integration API" + }, + servers: [{ url: "/v1" }] + }); +} diff --git a/server/lib/canUserAccessResource.ts b/server/lib/canUserAccessResource.ts index f322529..0d61825 100644 --- a/server/lib/canUserAccessResource.ts +++ b/server/lib/canUserAccessResource.ts @@ -1,15 +1,15 @@ import db from "@server/db"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { roleResources, userResources } from "@server/db/schemas"; export async function canUserAccessResource({ userId, resourceId, - roleIds + roleId }: { userId: string; resourceId: number; - roleIds: number[]; + roleId: number; }): Promise { const roleResourceAccess = await db .select() @@ -17,7 +17,7 @@ export async function canUserAccessResource({ .where( and( eq(roleResources.resourceId, resourceId), - inArray(roleResources.roleId, roleIds) + eq(roleResources.roleId, roleId) ) ) .limit(1); diff --git a/server/lib/config.ts b/server/lib/config.ts index 1937d41..a19b4a2 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -10,6 +10,10 @@ import { } from "@server/lib/consts"; import { passwordSchema } from "@server/auth/passwordSchema"; import stoi from "./stoi"; +import db from "@server/db"; +import { SupporterKey, supporterKey } from "@server/db/schemas"; +import { eq } from "drizzle-orm"; +import { license } from "@server/license/license"; const portSchema = z.number().positive().gt(0).lte(65535); @@ -168,6 +172,10 @@ const configSchema = z.object({ export class Config { private rawConfig!: z.infer; + supporterData: SupporterKey | null = null; + + supporterHiddenUntil: number | null = null; + isDev: boolean = process.env.ENVIRONMENT !== "prod"; constructor() { @@ -256,9 +264,20 @@ export class Config { : "false"; process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url; + license.setServerSecret(parsedConfig.data.server.secret); + + this.checkKeyStatus(); + this.rawConfig = parsedConfig.data; } + private async checkKeyStatus() { + const licenseStatus = await license.check(); + if (!licenseStatus.isHostLicensed) { + this.checkSupporterKey(); + } + } + public getRawConfig() { return this.rawConfig; } @@ -272,6 +291,90 @@ export class Config { public getDomain(domainId: string) { return this.rawConfig.domains[domainId]; } + + public hideSupporterKey(days: number = 7) { + const now = new Date().getTime(); + + if (this.supporterHiddenUntil && now < this.supporterHiddenUntil) { + return; + } + + this.supporterHiddenUntil = now + 1000 * 60 * 60 * 24 * days; + } + + public isSupporterKeyHidden() { + const now = new Date().getTime(); + + if (this.supporterHiddenUntil && now < this.supporterHiddenUntil) { + return true; + } + + return false; + } + + public async checkSupporterKey() { + const [key] = await db.select().from(supporterKey).limit(1); + + if (!key) { + return; + } + + const { key: licenseKey, githubUsername } = key; + + try { + const response = await fetch( + "https://api.fossorial.io/api/v1/license/validate", + { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + licenseKey, + githubUsername + }) + } + ); + + if (!response.ok) { + this.supporterData = key; + return; + } + + const data = await response.json(); + + if (!data.data.valid) { + this.supporterData = { + ...key, + valid: false + }; + return; + } + + this.supporterData = { + ...key, + tier: data.data.tier, + valid: true + }; + + // update the supporter key in the database + await db + .update(supporterKey) + .set({ + tier: data.data.tier || null, + phrase: data.data.cutePhrase || null, + valid: true + }) + .where(eq(supporterKey.keyId, key.keyId)); + } catch (e) { + this.supporterData = key; + console.error("Failed to validate supporter key", e); + } + } + + public getSupporterData() { + return this.supporterData; + } } export const config = new Config(); diff --git a/server/license/license.ts b/server/license/license.ts new file mode 100644 index 0000000..e97b8f5 --- /dev/null +++ b/server/license/license.ts @@ -0,0 +1,493 @@ +// 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"; +import { setHostMeta } from "@server/setup/setHostMeta"; +import { encrypt, decrypt } from "@server/lib/crypto"; + +const keyTypes = ["HOST", "SITES"] as const; +type KeyType = (typeof keyTypes)[number]; + +const keyTiers = ["PROFESSIONAL", "ENTERPRISE"] as const; +type KeyTier = (typeof keyTiers)[number]; + +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; + tier?: KeyTier; +}; + +export type LicenseKeyCache = { + licenseKey: string; + licenseKeyEncrypted: string; + valid: boolean; + iat?: Date; + type?: KeyType; + tier?: KeyTier; + 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: KeyType; + tier: KeyTier; + 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.fossorial.io/api/v1/license/professional/validate"; + private activationServerUrl = + "https://api.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 serverSecret!: string; + + 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( + async () => { + await 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(key)!; + }); + } + + public setServerSecret(secret: string) { + this.serverSecret = secret; + } + + public async forceRecheck() { + this.statusCache.flushAll(); + this.licenseKeyCache.flushAll(); + + return await this.check(); + } + + public async isUnlocked(): Promise { + const status = await this.check(); + if (status.isHostLicensed) { + if (status.isLicenseValid) { + return true; + } + } + return false; + } + + public async check(): Promise { + // 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: siteCount.value + }; + + 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(); + + const allKeysRes = await db.select().from(licenseKey); + + if (allKeysRes.length === 0) { + status.isHostLicensed = false; + return status; + } + + let foundHostKey = false; + // Validate stored license keys + for (const key of allKeysRes) { + try { + // Decrypt the license key and token + const decryptedKey = decrypt( + key.licenseKeyId, + this.serverSecret + ); + const decryptedToken = decrypt( + key.token, + this.serverSecret + ); + + const payload = validateJWT( + decryptedToken, + this.publicKey + ); + + this.licenseKeyCache.set(decryptedKey, { + licenseKey: decryptedKey, + licenseKeyEncrypted: key.licenseKeyId, + valid: payload.valid, + type: payload.type, + tier: payload.tier, + numSites: payload.quantity, + iat: new Date(payload.iat * 1000) + }); + + if (payload.type === "HOST") { + foundHostKey = true; + } + } catch (e) { + logger.error( + `Error validating license key: ${key.licenseKeyId}` + ); + logger.error(e); + + this.licenseKeyCache.set( + key.licenseKeyId, + { + licenseKey: key.licenseKeyId, + licenseKeyEncrypted: key.licenseKeyId, + valid: false + } + ); + } + } + + if (!foundHostKey && allKeysRes.length) { + logger.debug("No host license key found"); + status.isHostLicensed = false; + } + + const keys = allKeysRes.map((key) => ({ + licenseKey: decrypt(key.licenseKeyId, this.serverSecret), + instanceId: decrypt(key.instanceId, this.serverSecret) + })); + + 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( + 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( + key.licenseKey, + cached + ); + continue; + } + + const payload = validateJWT( + licenseKeyRes, + this.publicKey + ); + cached.valid = payload.valid; + cached.type = payload.type; + cached.tier = payload.tier; + cached.numSites = payload.quantity; + cached.iat = new Date(payload.iat * 1000); + + // Encrypt the updated token before storing + const encryptedKey = encrypt( + key.licenseKey, + this.serverSecret + ); + const encryptedToken = encrypt( + licenseKeyRes, + this.serverSecret + ); + + await db + .update(licenseKey) + .set({ + token: encryptedToken + }) + .where(eq(licenseKey.licenseKeyId, encryptedKey)); + + this.licenseKeyCache.set( + 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( + key.licenseKey + )!; + + logger.debug("Checking key", cached); + + if (cached.type === "HOST") { + status.isLicenseValid = cached.valid; + status.tier = cached.tier; + } + + 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) { + // Encrypt the license key before storing + const encryptedKey = encrypt(key, this.serverSecret); + + const [existingKey] = await db + .select() + .from(licenseKey) + .where(eq(licenseKey.licenseKeyId, encryptedKey)) + .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( + licenseKeyRes, + this.publicKey + ); + + if (!payload.valid) { + throw new Error("Invalid license key"); + } + + const encryptedToken = encrypt(licenseKeyRes, this.serverSecret); + // Encrypt the instanceId before storing + const encryptedInstanceId = encrypt(instanceId!, this.serverSecret); + + // Store the license key in the database + await db.insert(licenseKey).values({ + licenseKeyId: encryptedKey, + token: encryptedToken, + instanceId: encryptedInstanceId + }); + } 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 { + // Decrypt the instanceIds before sending to the server + const decryptedKeys = keys.map((key) => ({ + licenseKey: key.licenseKey, + instanceId: key.instanceId + ? decrypt(key.instanceId, this.serverSecret) + : key.instanceId + })); + + const response = await fetch(this.validationServerUrl, { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + licenseKeys: decryptedKeys, + ephemeralKey: this.ephemeralKey, + instanceName: this.hostId + }) + }); + + const data = await response.json(); + + return data as ValidateLicenseAPIResponse; + } +} + +await setHostMeta(); + +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; diff --git a/server/license/licenseJwt.ts b/server/license/licenseJwt.ts new file mode 100644 index 0000000..ed7f4a0 --- /dev/null +++ b/server/license/licenseJwt.ts @@ -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( + 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 }; diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 6dbdcd6..03d6f3b 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -16,6 +16,7 @@ export * from "./verifyUserInRole"; export * from "./verifyAccessTokenAccess"; export * from "./verifyUserIsServerAdmin"; export * from "./verifyIsLoggedInUser"; -// export * from "./integration"; +export * from "./integration"; +export * from "./verifyValidLicense"; export * from "./verifyUserHasAction"; -// export * from "./verifyApiKeyAccess"; +export * from "./verifyApiKeyAccess"; diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts new file mode 100644 index 0000000..c16e129 --- /dev/null +++ b/server/middlewares/integration/index.ts @@ -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. + +export * from "./verifyApiKey"; +export * from "./verifyApiKeyOrgAccess"; +export * from "./verifyApiKeyHasAction"; +export * from "./verifyApiKeySiteAccess"; +export * from "./verifyApiKeyResourceAccess"; +export * from "./verifyApiKeyTargetAccess"; +export * from "./verifyApiKeyRoleAccess"; +export * from "./verifyApiKeyUserAccess"; +export * from "./verifyApiKeySetResourceUsers"; +export * from "./verifyAccessTokenAccess"; +export * from "./verifyApiKeyIsRoot"; +export * from "./verifyApiKeyApiKeyAccess"; diff --git a/server/middlewares/integration/verifyAccessTokenAccess.ts b/server/middlewares/integration/verifyAccessTokenAccess.ts new file mode 100644 index 0000000..82badcd --- /dev/null +++ b/server/middlewares/integration/verifyAccessTokenAccess.ts @@ -0,0 +1,115 @@ +// 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 { db } from "@server/db"; +import { resourceAccessToken, resources, apiKeyOrg } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyAccessTokenAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const accessTokenId = req.params.accessTokenId; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + const [accessToken] = await db + .select() + .from(resourceAccessToken) + .where(eq(resourceAccessToken.accessTokenId, accessTokenId)) + .limit(1); + + if (!accessToken) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Access token with ID ${accessTokenId} not found` + ) + ); + } + + const resourceId = accessToken.resourceId; + + if (!resourceId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Access token with ID ${accessTokenId} does not have a resource ID` + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (!resource.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Resource with ID ${resourceId} does not have an organization ID` + ) + ); + } + + // Verify that the API key is linked to the resource's organization + if (!req.apiKeyOrg) { + const apiKeyOrgResult = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, resource.orgId) + ) + ) + .limit(1); + + if (apiKeyOrgResult.length > 0) { + req.apiKeyOrg = apiKeyOrgResult[0]; + } + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + + return next(); + } catch (e) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying access token access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKey.ts b/server/middlewares/integration/verifyApiKey.ts new file mode 100644 index 0000000..39fc3de --- /dev/null +++ b/server/middlewares/integration/verifyApiKey.ts @@ -0,0 +1,65 @@ +// 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 { verifyPassword } from "@server/auth/password"; +import db from "@server/db"; +import { apiKeys } from "@server/db/schemas"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { eq } from "drizzle-orm"; +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; + +export async function verifyApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const authHeader = req.headers["authorization"]; + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "API key required") + ); + } + + const key = authHeader.split(" ")[1]; // Get the token part after "Bearer" + const [apiKeyId, apiKeySecret] = key.split("."); + + const [apiKey] = await db + .select() + .from(apiKeys) + .where(eq(apiKeys.apiKeyId, apiKeyId)) + .limit(1); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Invalid API key") + ); + } + + const secretHash = apiKey.apiKeyHash; + const valid = await verifyPassword(apiKeySecret, secretHash); + + if (!valid) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Invalid API key") + ); + } + + req.apiKey = apiKey; + + return next(); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred checking API key" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyApiKeyAccess.ts b/server/middlewares/integration/verifyApiKeyApiKeyAccess.ts new file mode 100644 index 0000000..aedc60c --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyApiKeyAccess.ts @@ -0,0 +1,86 @@ +// 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 { db } from "@server/db"; +import { apiKeys, apiKeyOrg } from "@server/db/schemas"; +import { and, eq, or } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyApiKeyAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const {apiKey: callerApiKey } = req; + + const apiKeyId = + req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId; + const orgId = req.params.orgId; + + if (!callerApiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!apiKeyId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID") + ); + } + + const [callerApiKeyOrg] = await db + .select() + .from(apiKeyOrg) + .where( + and(eq(apiKeys.apiKeyId, callerApiKey.apiKeyId), eq(apiKeyOrg.orgId, orgId)) + ) + .limit(1); + + if (!callerApiKeyOrg) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `API key with ID ${apiKeyId} does not have an organization ID` + ) + ); + } + + const [otherApiKeyOrg] = await db + .select() + .from(apiKeyOrg) + .where( + and(eq(apiKeys.apiKeyId, apiKeyId), eq(apiKeyOrg.orgId, orgId)) + ) + .limit(1); + + if (!otherApiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `API key with ID ${apiKeyId} does not have access to organization with ID ${orgId}` + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying key access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyHasAction.ts b/server/middlewares/integration/verifyApiKeyHasAction.ts new file mode 100644 index 0000000..0326c46 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyHasAction.ts @@ -0,0 +1,61 @@ +// 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 createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import { ActionsEnum } from "@server/auth/actions"; +import db from "@server/db"; +import { apiKeyActions } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; + +export function verifyApiKeyHasAction(action: ActionsEnum) { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + if (!req.apiKey) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "API Key not authenticated" + ) + ); + } + + const [actionRes] = await db + .select() + .from(apiKeyActions) + .where( + and( + eq(apiKeyActions.apiKeyId, req.apiKey.apiKeyId), + eq(apiKeyActions.actionId, action) + ) + ); + + if (!actionRes) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have permission perform this action" + ) + ); + } + + return next(); + } catch (error) { + logger.error("Error verifying key action access:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying key action access" + ) + ); + } + }; +} diff --git a/server/middlewares/integration/verifyApiKeyIsRoot.ts b/server/middlewares/integration/verifyApiKeyIsRoot.ts new file mode 100644 index 0000000..35cd0fa --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyIsRoot.ts @@ -0,0 +1,44 @@ +// 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 logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; + +export async function verifyApiKeyIsRoot( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { apiKey } = req; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!apiKey.isRoot) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have root access" + ) + ); + } + + return next(); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred checking API key" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyOrgAccess.ts b/server/middlewares/integration/verifyApiKeyOrgAccess.ts new file mode 100644 index 0000000..e1e1e0d --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyOrgAccess.ts @@ -0,0 +1,66 @@ +// 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 { db } from "@server/db"; +import { apiKeyOrg } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; + +export async function verifyApiKeyOrgAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKeyId = req.apiKey?.apiKeyId; + const orgId = req.params.orgId; + + if (!apiKeyId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (e) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying organization access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyResourceAccess.ts b/server/middlewares/integration/verifyApiKeyResourceAccess.ts new file mode 100644 index 0000000..49180b5 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyResourceAccess.ts @@ -0,0 +1,90 @@ +// 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 { db } from "@server/db"; +import { resources, apiKeyOrg } from "@server/db/schemas"; +import { eq, and } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyResourceAccess( + req: Request, + res: Response, + next: NextFunction +) { + const apiKey = req.apiKey; + const resourceId = + req.params.resourceId || req.body.resourceId || req.query.resourceId; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + try { + // Retrieve the resource + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (!resource.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Resource with ID ${resourceId} does not have an organization ID` + ) + ); + } + + // Verify that the API key is linked to the resource's organization + if (!req.apiKeyOrg) { + const apiKeyOrgResult = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, resource.orgId) + ) + ) + .limit(1); + + if (apiKeyOrgResult.length > 0) { + req.apiKeyOrg = apiKeyOrgResult[0]; + } + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying resource access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyRoleAccess.ts b/server/middlewares/integration/verifyApiKeyRoleAccess.ts new file mode 100644 index 0000000..a7abf9a --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyRoleAccess.ts @@ -0,0 +1,132 @@ +// 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 { db } from "@server/db"; +import { roles, apiKeyOrg } from "@server/db/schemas"; +import { and, eq, inArray } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; + +export async function verifyApiKeyRoleAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const singleRoleId = parseInt( + req.params.roleId || req.body.roleId || req.query.roleId + ); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + const { roleIds } = req.body; + const allRoleIds = + roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]); + + if (allRoleIds.length === 0) { + return next(); + } + + const rolesData = await db + .select() + .from(roles) + .where(inArray(roles.roleId, allRoleIds)); + + if (rolesData.length !== allRoleIds.length) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "One or more roles not found" + ) + ); + } + + const orgIds = new Set(rolesData.map((role) => role.orgId)); + + for (const role of rolesData) { + const apiKeyOrgAccess = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, role.orgId!) + ) + ) + .limit(1); + + if (apiKeyOrgAccess.length === 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `Key does not have access to organization for role ID ${role.roleId}` + ) + ); + } + } + + if (orgIds.size > 1) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Roles must belong to the same organization" + ) + ); + } + + const orgId = orgIds.values().next().value; + + if (!orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Roles do not have an organization ID" + ) + ); + } + + if (!req.apiKeyOrg) { + // Retrieve the API key's organization link if not already set + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ) + .limit(1); + + if (apiKeyOrgRes.length === 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + return next(); + } catch (error) { + logger.error("Error verifying role access:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying role access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeySetResourceUsers.ts b/server/middlewares/integration/verifyApiKeySetResourceUsers.ts new file mode 100644 index 0000000..d43021b --- /dev/null +++ b/server/middlewares/integration/verifyApiKeySetResourceUsers.ts @@ -0,0 +1,74 @@ +// 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 { db } from "@server/db"; +import { userOrgs } from "@server/db/schemas"; +import { and, eq, inArray } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeySetResourceUsers( + req: Request, + res: Response, + next: NextFunction +) { + const apiKey = req.apiKey; + const userIds = req.body.userIds; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + if (!userIds) { + return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid user IDs")); + } + + if (userIds.length === 0) { + return next(); + } + + try { + const orgId = req.apiKeyOrg.orgId; + const userOrgsData = await db + .select() + .from(userOrgs) + .where( + and( + inArray(userOrgs.userId, userIds), + eq(userOrgs.orgId, orgId) + ) + ); + + if (userOrgsData.length !== userIds.length) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to one or more specified users" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error checking if key has access to the specified users" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeySiteAccess.ts b/server/middlewares/integration/verifyApiKeySiteAccess.ts new file mode 100644 index 0000000..7d10dde --- /dev/null +++ b/server/middlewares/integration/verifyApiKeySiteAccess.ts @@ -0,0 +1,94 @@ +// 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 { db } from "@server/db"; +import { + sites, + apiKeyOrg +} from "@server/db/schemas"; +import { and, eq, or } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeySiteAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const siteId = parseInt( + req.params.siteId || req.body.siteId || req.query.siteId + ); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (isNaN(siteId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid site ID") + ); + } + + const site = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + + if (site.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site with ID ${siteId} not found` + ) + ); + } + + if (!site[0].orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Site with ID ${siteId} does not have an organization ID` + ) + ); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, site[0].orgId) + ) + ); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying site access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyTargetAccess.ts b/server/middlewares/integration/verifyApiKeyTargetAccess.ts new file mode 100644 index 0000000..bd6e5bc --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyTargetAccess.ts @@ -0,0 +1,117 @@ +// 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 { db } from "@server/db"; +import { resources, targets, apiKeyOrg } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyTargetAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const targetId = parseInt(req.params.targetId); + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (isNaN(targetId)) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID") + ); + } + + const [target] = await db + .select() + .from(targets) + .where(eq(targets.targetId, targetId)) + .limit(1); + + if (!target) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Target with ID ${targetId} not found` + ) + ); + } + + const resourceId = target.resourceId; + if (!resourceId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Target with ID ${targetId} does not have a resource ID` + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if (!resource) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Resource with ID ${resourceId} not found` + ) + ); + } + + if (!resource.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Resource with ID ${resourceId} does not have an organization ID` + ) + ); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgResult = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, resource.orgId) + ) + ) + .limit(1); + if (apiKeyOrgResult.length > 0) { + req.apiKeyOrg = apiKeyOrgResult[0]; + } + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying target access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyUserAccess.ts b/server/middlewares/integration/verifyApiKeyUserAccess.ts new file mode 100644 index 0000000..e1b5d3d --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyUserAccess.ts @@ -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. + +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { userOrgs } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyUserAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const reqUserId = + req.params.userId || req.body.userId || req.query.userId; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!reqUserId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid user ID") + ); + } + + if (!req.apiKeyOrg || !req.apiKeyOrg.orgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have organization access" + ) + ); + } + + const orgId = req.apiKeyOrg.orgId; + + const [userOrgRecord] = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, reqUserId), eq(userOrgs.orgId, orgId)) + ) + .limit(1); + + if (!userOrgRecord) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this user" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error checking if key has access to this user" + ) + ); + } +} diff --git a/server/middlewares/verifyAccessTokenAccess.ts b/server/middlewares/verifyAccessTokenAccess.ts index 66c8439..a437a8a 100644 --- a/server/middlewares/verifyAccessTokenAccess.ts +++ b/server/middlewares/verifyAccessTokenAccess.ts @@ -82,24 +82,24 @@ export async function verifyAccessTokenAccess( ) ); req.userOrg = res[0]; - req.userRoleIds = res.map((r) => r.roleId); } if (!req.userOrg) { - return next( + next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); + } else { + req.userOrgRoleId = req.userOrg.roleId; + req.userOrgId = resource[0].orgId!; } - req.userOrgId = resource[0].orgId!; - const resourceAllowed = await canUserAccessResource({ userId, resourceId, - roleIds: req.userRoleIds! + roleId: req.userOrgRoleId! }); if (!resourceAllowed) { diff --git a/server/middlewares/verifyAdmin.ts b/server/middlewares/verifyAdmin.ts index 240888e..b53f238 100644 --- a/server/middlewares/verifyAdmin.ts +++ b/server/middlewares/verifyAdmin.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { roles, userOrgs } from "@server/db/schemas"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -29,11 +29,9 @@ export async function verifyAdmin( const userOrgRes = await db .select() .from(userOrgs) - .where( - and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId!)) - ); + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId!))) + .limit(1); req.userOrg = userOrgRes[0]; - req.userRoleIds = userOrgRes.map((r) => r.roleId); } if (!req.userOrg) { @@ -45,13 +43,13 @@ export async function verifyAdmin( ); } - const userAdminRole = await db + const userRole = await db .select() .from(roles) - .where(and(inArray(roles.roleId, req.userRoleIds!), roles.isAdmin)) + .where(eq(roles.roleId, req.userOrg.roleId)) .limit(1); - if (userAdminRole.length === 0) { + if (userRole.length === 0 || !userRole[0].isAdmin) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/middlewares/verifyApiKeyAccess.ts b/server/middlewares/verifyApiKeyAccess.ts new file mode 100644 index 0000000..0bba8f4 --- /dev/null +++ b/server/middlewares/verifyApiKeyAccess.ts @@ -0,0 +1,104 @@ +// 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 { db } from "@server/db"; +import { userOrgs, apiKeys, apiKeyOrg } from "@server/db/schemas"; +import { and, eq, or } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const apiKeyId = + req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId; + const orgId = req.params.orgId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!apiKeyId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID") + ); + } + + const [apiKey] = await db + .select() + .from(apiKeys) + .innerJoin(apiKeyOrg, eq(apiKeys.apiKeyId, apiKeyOrg.apiKeyId)) + .where( + and(eq(apiKeys.apiKeyId, apiKeyId), eq(apiKeyOrg.orgId, orgId)) + ) + .limit(1); + + if (!apiKey.apiKeys) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `API key with ID ${apiKeyId} not found` + ) + ); + } + + if (!apiKeyOrg.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `API key with ID ${apiKeyId} does not have an organization ID` + ) + ); + } + + if (!req.userOrg) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq(userOrgs.orgId, apiKeyOrg.orgId) + ) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + const userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleId = userOrgRoleId; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying key access" + ) + ); + } +} diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index 9af4fe5..20018e0 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -34,20 +34,21 @@ export async function verifyOrgAccess( and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) ); req.userOrg = userOrgRes[0]; - req.userRoleIds = userOrgRes.map((r) => r.roleId); } if (!req.userOrg) { - return next( + next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); + } else { + // User has access, attach the user's role to the request for potential future use + req.userOrgRoleId = req.userOrg.roleId; + req.userOrgId = orgId; + return next(); } - - req.userOrgId = orgId; - return next(); } catch (e) { return next( createHttpError( diff --git a/server/middlewares/verifyResourceAccess.ts b/server/middlewares/verifyResourceAccess.ts index 43ab908..dc5fcc2 100644 --- a/server/middlewares/verifyResourceAccess.ts +++ b/server/middlewares/verifyResourceAccess.ts @@ -4,9 +4,9 @@ import { resources, userOrgs, userResources, - roleResources + roleResources, } from "@server/db/schemas"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -59,9 +59,9 @@ export async function verifyResourceAccess( eq(userOrgs.userId, userId), eq(userOrgs.orgId, resource[0].orgId) ) - ); + ) + .limit(1); req.userOrg = userOrgRole[0]; - req.userRoleIds = userOrgRole.map((r) => r.roleId); } if (!req.userOrg) { @@ -73,6 +73,8 @@ export async function verifyResourceAccess( ); } + const userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleId = userOrgRoleId; req.userOrgId = resource[0].orgId; const roleResourceAccess = await db @@ -81,7 +83,7 @@ export async function verifyResourceAccess( .where( and( eq(roleResources.resourceId, resourceId), - inArray(roleResources.roleId, req.userRoleIds!) + eq(roleResources.roleId, userOrgRoleId) ) ) .limit(1); diff --git a/server/middlewares/verifyRoleAccess.ts b/server/middlewares/verifyRoleAccess.ts index fac348d..5491704 100644 --- a/server/middlewares/verifyRoleAccess.ts +++ b/server/middlewares/verifyRoleAccess.ts @@ -98,10 +98,11 @@ export async function verifyRoleAccess( .from(userOrgs) .where( and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId!)) - ); + ) + .limit(1); req.userOrg = userOrg[0]; - req.userRoleIds = userOrg.map((r) => r.roleId); + req.userOrgRoleId = userOrg[0].roleId; } return next(); diff --git a/server/middlewares/verifySiteAccess.ts b/server/middlewares/verifySiteAccess.ts index 640985d..b741e3a 100644 --- a/server/middlewares/verifySiteAccess.ts +++ b/server/middlewares/verifySiteAccess.ts @@ -5,9 +5,9 @@ import { userOrgs, userSites, roleSites, - roles + roles, } from "@server/db/schemas"; -import { and, eq, inArray } from "drizzle-orm"; +import { and, eq, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; @@ -71,7 +71,6 @@ export async function verifySiteAccess( ) .limit(1); req.userOrg = userOrgRole[0]; - req.userRoleIds = userOrgRole.map((r) => r.roleId); } if (!req.userOrg) { @@ -83,6 +82,8 @@ export async function verifySiteAccess( ); } + const userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleId = userOrgRoleId; req.userOrgId = site[0].orgId; // Check role-based site access first @@ -92,7 +93,7 @@ export async function verifySiteAccess( .where( and( eq(roleSites.siteId, siteId), - inArray(roleSites.roleId, req.userRoleIds!) + eq(roleSites.roleId, userOrgRoleId) ) ) .limit(1); diff --git a/server/middlewares/verifyTargetAccess.ts b/server/middlewares/verifyTargetAccess.ts index 4065ce5..f57ba47 100644 --- a/server/middlewares/verifyTargetAccess.ts +++ b/server/middlewares/verifyTargetAccess.ts @@ -88,23 +88,24 @@ export async function verifyTargetAccess( ) ); req.userOrg = res[0]; - req.userRoleIds = res.map((r) => r.roleId); } if (!req.userOrg) { - return next( + next( createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ) ); + } else { + req.userOrgRoleId = req.userOrg.roleId; + req.userOrgId = resource[0].orgId!; } - req.userOrgId = resource[0].orgId!; const resourceAllowed = await canUserAccessResource({ userId, resourceId, - roleIds: req.userRoleIds! + roleId: req.userOrgRoleId! }); if (!resourceAllowed) { diff --git a/server/middlewares/verifyUserAccess.ts b/server/middlewares/verifyUserAccess.ts index 9cc30cf..43ec9cf 100644 --- a/server/middlewares/verifyUserAccess.ts +++ b/server/middlewares/verifyUserAccess.ts @@ -33,9 +33,9 @@ export async function verifyUserAccess( eq(userOrgs.userId, reqUserId), eq(userOrgs.orgId, req.userOrgId!) ) - ); + ) + .limit(1); req.userOrg = res[0]; - req.userRoleIds = res.map((r) => r.roleId); } if (!req.userOrg) { diff --git a/server/middlewares/verifyUserInRole.ts b/server/middlewares/verifyUserInRole.ts index d833d0a..2a15311 100644 --- a/server/middlewares/verifyUserInRole.ts +++ b/server/middlewares/verifyUserInRole.ts @@ -12,7 +12,7 @@ export async function verifyUserInRole( const roleId = parseInt( req.params.roleId || req.body.roleId || req.query.roleId ); - const userRoleIds = req.userRoleIds; + const userRoleId = req.userOrgRoleId; if (isNaN(roleId)) { return next( @@ -20,7 +20,7 @@ export async function verifyUserInRole( ); } - if (!userRoleIds) { + if (!userRoleId) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -29,7 +29,7 @@ export async function verifyUserInRole( ); } - if (userRoleIds.indexOf(roleId) === -1) { + if (userRoleId !== roleId) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/middlewares/verifyUserIsOrgOwner.ts b/server/middlewares/verifyUserIsOrgOwner.ts index c1d766e..ac96f37 100644 --- a/server/middlewares/verifyUserIsOrgOwner.ts +++ b/server/middlewares/verifyUserIsOrgOwner.ts @@ -36,7 +36,6 @@ export async function verifyUserIsOrgOwner( and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) ); req.userOrg = res[0]; - req.userRoleIds = res.map((r) => r.roleId); } if (!req.userOrg) { diff --git a/server/middlewares/verifyValidLicense.ts b/server/middlewares/verifyValidLicense.ts new file mode 100644 index 0000000..7f4de34 --- /dev/null +++ b/server/middlewares/verifyValidLicense.ts @@ -0,0 +1,33 @@ +// 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 createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import license from "@server/license/license"; + +export async function verifyValidLicense( + req: Request, + res: Response, + next: NextFunction +) { + try { + const unlocked = await license.isUnlocked(); + if (!unlocked) { + return next( + createHttpError(HttpCode.FORBIDDEN, "License is not valid") + ); + } + + return next(); + } catch (e) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying license" + ) + ); + } +} diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index daa09a4..07ef9aa 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -208,7 +208,7 @@ export async function listAccessTokens( .where( or( eq(userResources.userId, req.user!.userId), - inArray(roleResources.roleId, req.userRoleIds!) + eq(roleResources.roleId, req.userOrgRoleId!) ) ); } else { diff --git a/server/routers/apiKeys/createOrgApiKey.ts b/server/routers/apiKeys/createOrgApiKey.ts new file mode 100644 index 0000000..2fb9fd2 --- /dev/null +++ b/server/routers/apiKeys/createOrgApiKey.ts @@ -0,0 +1,133 @@ +// 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 { NextFunction, Request, Response } from "express"; +import db from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import { z } from "zod"; +import { apiKeyOrg, apiKeys } from "@server/db/schemas"; +import { fromError } from "zod-validation-error"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import moment from "moment"; +import { + generateId, + generateIdFromEntropySize +} from "@server/auth/sessions/app"; +import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z.object({ + orgId: z.string().nonempty() +}); + +const bodySchema = z.object({ + name: z.string().min(1).max(255) +}); + +export type CreateOrgApiKeyBody = z.infer; + +export type CreateOrgApiKeyResponse = { + apiKeyId: string; + name: string; + apiKey: string; + lastChars: string; + createdAt: string; +}; + +registry.registerPath({ + method: "put", + path: "/org/{orgId}/api-key", + description: "Create a new API key scoped to the organization.", + tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function createOrgApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedParams = paramsSchema.safeParse(req.params); + + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { name } = parsedBody.data; + + const apiKeyId = generateId(15); + const apiKey = generateIdFromEntropySize(25); + const apiKeyHash = await hashPassword(apiKey); + const lastChars = apiKey.slice(-4); + const createdAt = moment().toISOString(); + + await db.transaction(async (trx) => { + await trx.insert(apiKeys).values({ + name, + apiKeyId, + apiKeyHash, + createdAt, + lastChars + }); + + await trx.insert(apiKeyOrg).values({ + apiKeyId, + orgId + }); + }); + + try { + return response(res, { + data: { + apiKeyId, + apiKey, + name, + lastChars, + createdAt + }, + success: true, + error: false, + message: "API key created", + status: HttpCode.CREATED + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create API key" + ) + ); + } +} diff --git a/server/routers/apiKeys/createRootApiKey.ts b/server/routers/apiKeys/createRootApiKey.ts new file mode 100644 index 0000000..775ae57 --- /dev/null +++ b/server/routers/apiKeys/createRootApiKey.ts @@ -0,0 +1,105 @@ +// 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 { NextFunction, Request, Response } from "express"; +import db from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import { z } from "zod"; +import { apiKeyOrg, apiKeys, orgs } from "@server/db/schemas"; +import { fromError } from "zod-validation-error"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import moment from "moment"; +import { + generateId, + generateIdFromEntropySize +} from "@server/auth/sessions/app"; +import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; + +const bodySchema = z + .object({ + name: z.string().min(1).max(255) + }) + .strict(); + +export type CreateRootApiKeyBody = z.infer; + +export type CreateRootApiKeyResponse = { + apiKeyId: string; + name: string; + apiKey: string; + lastChars: string; + createdAt: string; +}; + +export async function createRootApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedBody = bodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { name } = parsedBody.data; + + const apiKeyId = generateId(15); + const apiKey = generateIdFromEntropySize(25); + const apiKeyHash = await hashPassword(apiKey); + const lastChars = apiKey.slice(-4); + const createdAt = moment().toISOString(); + + await db.transaction(async (trx) => { + await trx.insert(apiKeys).values({ + apiKeyId, + name, + apiKeyHash, + createdAt, + lastChars, + isRoot: true + }); + + const allOrgs = await trx.select().from(orgs); + + for (const org of allOrgs) { + await trx.insert(apiKeyOrg).values({ + apiKeyId, + orgId: org.orgId + }); + } + }); + + try { + return response(res, { + data: { + apiKeyId, + name, + apiKey, + lastChars, + createdAt + }, + success: true, + error: false, + message: "API key created", + status: HttpCode.CREATED + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create API key" + ) + ); + } +} diff --git a/server/routers/apiKeys/deleteApiKey.ts b/server/routers/apiKeys/deleteApiKey.ts new file mode 100644 index 0000000..2af4ae2 --- /dev/null +++ b/server/routers/apiKeys/deleteApiKey.ts @@ -0,0 +1,81 @@ +// 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 { z } from "zod"; +import { db } from "@server/db"; +import { apiKeys } from "@server/db/schemas"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty() +}); + +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/api-key/{apiKeyId}", + description: "Delete an API key.", + tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + request: { + params: paramsSchema + }, + responses: {} +}); + +export async function deleteApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { apiKeyId } = parsedParams.data; + + const [apiKey] = await db + .select() + .from(apiKeys) + .where(eq(apiKeys.apiKeyId, apiKeyId)) + .limit(1); + + if (!apiKey) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `API Key with ID ${apiKeyId} not found` + ) + ); + } + + await db.delete(apiKeys).where(eq(apiKeys.apiKeyId, apiKeyId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "API key deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/apiKeys/deleteOrgApiKey.ts b/server/routers/apiKeys/deleteOrgApiKey.ts new file mode 100644 index 0000000..1834c82 --- /dev/null +++ b/server/routers/apiKeys/deleteOrgApiKey.ts @@ -0,0 +1,104 @@ +// 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 { z } from "zod"; +import { db } from "@server/db"; +import { apiKeyOrg, apiKeys } from "@server/db/schemas"; +import { and, eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty(), + orgId: z.string().nonempty() +}); + +export async function deleteOrgApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { apiKeyId, orgId } = parsedParams.data; + + const [apiKey] = await db + .select() + .from(apiKeys) + .where(eq(apiKeys.apiKeyId, apiKeyId)) + .innerJoin( + apiKeyOrg, + and( + eq(apiKeys.apiKeyId, apiKeyOrg.apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ) + .limit(1); + + if (!apiKey) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `API Key with ID ${apiKeyId} not found` + ) + ); + } + + if (apiKey.apiKeys.isRoot) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Cannot delete root API key" + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ); + + const apiKeyOrgs = await db + .select() + .from(apiKeyOrg) + .where(eq(apiKeyOrg.apiKeyId, apiKeyId)); + + if (apiKeyOrgs.length === 0) { + await trx.delete(apiKeys).where(eq(apiKeys.apiKeyId, apiKeyId)); + } + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "API removed from organization", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/apiKeys/getApiKey.ts b/server/routers/apiKeys/getApiKey.ts new file mode 100644 index 0000000..bd495bd --- /dev/null +++ b/server/routers/apiKeys/getApiKey.ts @@ -0,0 +1,81 @@ +// 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 { z } from "zod"; +import { db } from "@server/db"; +import { apiKeys } from "@server/db/schemas"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty() +}); + +async function query(apiKeyId: string) { + return await db + .select({ + apiKeyId: apiKeys.apiKeyId, + lastChars: apiKeys.lastChars, + createdAt: apiKeys.createdAt, + isRoot: apiKeys.isRoot, + name: apiKeys.name + }) + .from(apiKeys) + .where(eq(apiKeys.apiKeyId, apiKeyId)) + .limit(1); +} + +export type GetApiKeyResponse = NonNullable< + Awaited>[0] +>; + +export async function getApiKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { apiKeyId } = parsedParams.data; + + const [apiKey] = await query(apiKeyId); + + if (!apiKey) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `API Key with ID ${apiKeyId} not found` + ) + ); + } + + return response(res, { + data: apiKey, + success: true, + error: false, + message: "API key deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/apiKeys/index.ts b/server/routers/apiKeys/index.ts new file mode 100644 index 0000000..84d4ee6 --- /dev/null +++ b/server/routers/apiKeys/index.ts @@ -0,0 +1,16 @@ +// 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 "./createRootApiKey"; +export * from "./deleteApiKey"; +export * from "./getApiKey"; +export * from "./listApiKeyActions"; +export * from "./listOrgApiKeys"; +export * from "./listApiKeyActions"; +export * from "./listRootApiKeys"; +export * from "./setApiKeyActions"; +export * from "./setApiKeyOrgs"; +export * from "./createOrgApiKey"; +export * from "./deleteOrgApiKey"; diff --git a/server/routers/apiKeys/listApiKeyActions.ts b/server/routers/apiKeys/listApiKeyActions.ts new file mode 100644 index 0000000..0cf694a --- /dev/null +++ b/server/routers/apiKeys/listApiKeyActions.ts @@ -0,0 +1,118 @@ +// 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 { actions, apiKeyActions, apiKeyOrg, apiKeys } from "@server/db/schemas"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { eq } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty() +}); + +const querySchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +function queryActions(apiKeyId: string) { + return db + .select({ + actionId: actions.actionId + }) + .from(apiKeyActions) + .where(eq(apiKeyActions.apiKeyId, apiKeyId)) + .innerJoin(actions, eq(actions.actionId, apiKeyActions.actionId)); +} + +export type ListApiKeyActionsResponse = { + actions: Awaited>; + pagination: { total: number; limit: number; offset: number }; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/api-key/{apiKeyId}/actions", + description: + "List all actions set for an API key.", + tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + request: { + params: paramsSchema, + query: querySchema + }, + responses: {} +}); + +export async function listApiKeyActions( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const { limit, offset } = parsedQuery.data; + const { apiKeyId } = parsedParams.data; + + const baseQuery = queryActions(apiKeyId); + + const actionsList = await baseQuery.limit(limit).offset(offset); + + return response(res, { + data: { + actions: actionsList, + pagination: { + total: actionsList.length, + limit, + offset + } + }, + success: true, + error: false, + message: "API keys retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/apiKeys/listOrgApiKeys.ts b/server/routers/apiKeys/listOrgApiKeys.ts new file mode 100644 index 0000000..a016907 --- /dev/null +++ b/server/routers/apiKeys/listOrgApiKeys.ts @@ -0,0 +1,121 @@ +// 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 { apiKeyOrg, apiKeys } from "@server/db/schemas"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { eq, and } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const querySchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +const paramsSchema = z.object({ + orgId: z.string() +}); + +function queryApiKeys(orgId: string) { + return db + .select({ + apiKeyId: apiKeys.apiKeyId, + orgId: apiKeyOrg.orgId, + lastChars: apiKeys.lastChars, + createdAt: apiKeys.createdAt, + name: apiKeys.name + }) + .from(apiKeyOrg) + .where(and(eq(apiKeyOrg.orgId, orgId), eq(apiKeys.isRoot, false))) + .innerJoin(apiKeys, eq(apiKeys.apiKeyId, apiKeyOrg.apiKeyId)); +} + +export type ListOrgApiKeysResponse = { + apiKeys: Awaited>; + pagination: { total: number; limit: number; offset: number }; +}; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/api-keys", + description: "List all API keys for an organization", + tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + request: { + params: paramsSchema, + query: querySchema + }, + responses: {} +}); + +export async function listOrgApiKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const { limit, offset } = parsedQuery.data; + const { orgId } = parsedParams.data; + + const baseQuery = queryApiKeys(orgId); + + const apiKeysList = await baseQuery.limit(limit).offset(offset); + + return response(res, { + data: { + apiKeys: apiKeysList, + pagination: { + total: apiKeysList.length, + limit, + offset + } + }, + success: true, + error: false, + message: "API keys retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/apiKeys/listRootApiKeys.ts b/server/routers/apiKeys/listRootApiKeys.ts new file mode 100644 index 0000000..7feca73 --- /dev/null +++ b/server/routers/apiKeys/listRootApiKeys.ts @@ -0,0 +1,90 @@ +// 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 { apiKeys } from "@server/db/schemas"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { eq } from "drizzle-orm"; + +const querySchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.number().int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.number().int().nonnegative()) +}); + +function queryApiKeys() { + return db + .select({ + apiKeyId: apiKeys.apiKeyId, + lastChars: apiKeys.lastChars, + createdAt: apiKeys.createdAt, + name: apiKeys.name + }) + .from(apiKeys) + .where(eq(apiKeys.isRoot, true)); +} + +export type ListRootApiKeysResponse = { + apiKeys: Awaited>; + pagination: { total: number; limit: number; offset: number }; +}; + +export async function listRootApiKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { limit, offset } = parsedQuery.data; + + const baseQuery = queryApiKeys(); + + const apiKeysList = await baseQuery.limit(limit).offset(offset); + + return response(res, { + data: { + apiKeys: apiKeysList, + pagination: { + total: apiKeysList.length, + limit, + offset + } + }, + success: true, + error: false, + message: "API keys retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/apiKeys/setApiKeyActions.ts b/server/routers/apiKeys/setApiKeyActions.ts new file mode 100644 index 0000000..187dd11 --- /dev/null +++ b/server/routers/apiKeys/setApiKeyActions.ts @@ -0,0 +1,141 @@ +// 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 { z } from "zod"; +import { db } from "@server/db"; +import { actions, apiKeyActions } from "@server/db/schemas"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and, inArray } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const bodySchema = z + .object({ + actionIds: z + .array(z.string().nonempty()) + .transform((v) => Array.from(new Set(v))) + }) + .strict(); + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty() +}); + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/api-key/{apiKeyId}/actions", + description: + "Set actions for an API key. This will replace any existing actions.", + tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); + +export async function setApiKeyActions( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { actionIds: newActionIds } = parsedBody.data; + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { apiKeyId } = parsedParams.data; + + const actionsExist = await db + .select() + .from(actions) + .where(inArray(actions.actionId, newActionIds)); + + if (actionsExist.length !== newActionIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more actions do not exist" + ) + ); + } + + await db.transaction(async (trx) => { + const existingActions = await trx + .select() + .from(apiKeyActions) + .where(eq(apiKeyActions.apiKeyId, apiKeyId)); + + const existingActionIds = existingActions.map((a) => a.actionId); + + const actionIdsToAdd = newActionIds.filter( + (id) => !existingActionIds.includes(id) + ); + const actionIdsToRemove = existingActionIds.filter( + (id) => !newActionIds.includes(id) + ); + + if (actionIdsToRemove.length > 0) { + await trx + .delete(apiKeyActions) + .where( + and( + eq(apiKeyActions.apiKeyId, apiKeyId), + inArray(apiKeyActions.actionId, actionIdsToRemove) + ) + ); + } + + if (actionIdsToAdd.length > 0) { + const insertValues = actionIdsToAdd.map((actionId) => ({ + apiKeyId, + actionId + })); + await trx.insert(apiKeyActions).values(insertValues); + } + }); + + return response(res, { + data: {}, + success: true, + error: false, + message: "API key actions updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/apiKeys/setApiKeyOrgs.ts b/server/routers/apiKeys/setApiKeyOrgs.ts new file mode 100644 index 0000000..ee0611d --- /dev/null +++ b/server/routers/apiKeys/setApiKeyOrgs.ts @@ -0,0 +1,122 @@ +// 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 { z } from "zod"; +import { db } from "@server/db"; +import { apiKeyOrg, orgs } from "@server/db/schemas"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and, inArray } from "drizzle-orm"; + +const bodySchema = z + .object({ + orgIds: z + .array(z.string().nonempty()) + .transform((v) => Array.from(new Set(v))) + }) + .strict(); + +const paramsSchema = z.object({ + apiKeyId: z.string().nonempty() +}); + +export async function setApiKeyOrgs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgIds: newOrgIds } = parsedBody.data; + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { apiKeyId } = parsedParams.data; + + // make sure all orgs exist + const allOrgs = await db + .select() + .from(orgs) + .where(inArray(orgs.orgId, newOrgIds)); + + if (allOrgs.length !== newOrgIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more orgs do not exist" + ) + ); + } + + await db.transaction(async (trx) => { + const existingOrgs = await trx + .select({ orgId: apiKeyOrg.orgId }) + .from(apiKeyOrg) + .where(eq(apiKeyOrg.apiKeyId, apiKeyId)); + + const existingOrgIds = existingOrgs.map((a) => a.orgId); + + const orgIdsToAdd = newOrgIds.filter( + (id) => !existingOrgIds.includes(id) + ); + const orgIdsToRemove = existingOrgIds.filter( + (id) => !newOrgIds.includes(id) + ); + + if (orgIdsToRemove.length > 0) { + await trx + .delete(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKeyId), + inArray(apiKeyOrg.orgId, orgIdsToRemove) + ) + ); + } + + if (orgIdsToAdd.length > 0) { + const insertValues = orgIdsToAdd.map((orgId) => ({ + apiKeyId, + orgId + })); + await trx.insert(apiKeyOrg).values(insertValues); + } + + return response(res, { + data: {}, + success: true, + error: false, + message: "API key orgs updated successfully", + status: HttpCode.OK + }); + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/external.ts b/server/routers/external.ts index 96f569b..d631c37 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -8,9 +8,11 @@ import * as target from "./target"; import * as user from "./user"; import * as auth from "./auth"; import * as role from "./role"; +import * as supporterKey from "./supporterKey"; import * as accessToken from "./accessToken"; import * as idp from "./idp"; -// import * as apiKeys from "./apiKeys"; +import * as license from "./license"; +import * as apiKeys from "./apiKeys"; import HttpCode from "@server/types/HttpCode"; import { verifyAccessTokenAccess, @@ -26,8 +28,9 @@ import { verifyUserAccess, getUserOrgs, verifyUserIsServerAdmin, - verifyIsLoggedInUser - // verifyApiKeyAccess + verifyIsLoggedInUser, + verifyApiKeyAccess, + verifyValidLicense } from "@server/middlewares"; import { verifyUserHasAction } from "../middlewares/verifyUserHasAction"; import { ActionsEnum } from "@server/auth/actions"; @@ -273,14 +276,6 @@ authenticated.get( verifyUserHasAction(ActionsEnum.listRoles), role.listRoles ); -authenticated.post( - "/org/:orgId/user/:userId/roles", - verifyOrgAccess, - verifyUserAccess, - verifyUserHasAction(ActionsEnum.setUserRoles), - user.setUserRoles -); - // authenticated.get( // "/role/:roleId", // verifyRoleAccess, @@ -411,6 +406,12 @@ authenticated.get( authenticated.get(`/org/:orgId/overview`, verifyOrgAccess, org.getOrgOverview); +authenticated.post( + `/supporter-key/validate`, + supporterKey.validateSupporterKey +); +authenticated.post(`/supporter-key/hide`, supporterKey.hideSupporterKey); + unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo); // authenticated.get( @@ -530,24 +531,28 @@ authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp); authenticated.put( "/idp/:idpId/org/:orgId", + verifyValidLicense, verifyUserIsServerAdmin, idp.createIdpOrgPolicy ); authenticated.post( "/idp/:idpId/org/:orgId", + verifyValidLicense, verifyUserIsServerAdmin, idp.updateIdpOrgPolicy ); authenticated.delete( "/idp/:idpId/org/:orgId", + verifyValidLicense, verifyUserIsServerAdmin, idp.deleteIdpOrgPolicy ); authenticated.get( "/idp/:idpId/org", + verifyValidLicense, verifyUserIsServerAdmin, idp.listIdpOrgPolicies ); @@ -555,45 +560,75 @@ authenticated.get( 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.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 +); + authenticated.get( `/api-key/:apiKeyId`, + verifyValidLicense, verifyUserIsServerAdmin, apiKeys.getApiKey ); authenticated.put( `/api-key`, + verifyValidLicense, verifyUserIsServerAdmin, apiKeys.createRootApiKey ); authenticated.delete( `/api-key/:apiKeyId`, + verifyValidLicense, verifyUserIsServerAdmin, apiKeys.deleteApiKey ); authenticated.get( `/api-keys`, + verifyValidLicense, verifyUserIsServerAdmin, apiKeys.listRootApiKeys ); authenticated.get( `/api-key/:apiKeyId/actions`, + verifyValidLicense, verifyUserIsServerAdmin, apiKeys.listApiKeyActions ); authenticated.post( `/api-key/:apiKeyId/actions`, + verifyValidLicense, verifyUserIsServerAdmin, apiKeys.setApiKeyActions ); authenticated.get( `/org/:orgId/api-keys`, + verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.listApiKeys), apiKeys.listOrgApiKeys @@ -601,6 +636,7 @@ authenticated.get( authenticated.post( `/org/:orgId/api-key/:apiKeyId/actions`, + verifyValidLicense, verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.setApiKeyActions), @@ -609,6 +645,7 @@ authenticated.post( authenticated.get( `/org/:orgId/api-key/:apiKeyId/actions`, + verifyValidLicense, verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.listApiKeyActions), @@ -617,6 +654,7 @@ authenticated.get( authenticated.put( `/org/:orgId/api-key`, + verifyValidLicense, verifyOrgAccess, verifyUserHasAction(ActionsEnum.createApiKey), apiKeys.createOrgApiKey @@ -624,6 +662,7 @@ authenticated.put( authenticated.delete( `/org/:orgId/api-key/:apiKeyId`, + verifyValidLicense, verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.deleteApiKey), @@ -632,12 +671,12 @@ authenticated.delete( authenticated.get( `/org/:orgId/api-key/:apiKeyId`, + verifyValidLicense, verifyOrgAccess, verifyApiKeyAccess, verifyUserHasAction(ActionsEnum.getApiKey), apiKeys.getApiKey ); -*/ // Auth routes export const authRouter = Router(); diff --git a/server/routers/idp/createIdpOrgPolicy.ts b/server/routers/idp/createIdpOrgPolicy.ts index 4f976b4..ae5acce 100644 --- a/server/routers/idp/createIdpOrgPolicy.ts +++ b/server/routers/idp/createIdpOrgPolicy.ts @@ -1,3 +1,8 @@ +// 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 { z } from "zod"; import { db } from "@server/db"; @@ -7,6 +12,7 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import config from "@server/lib/config"; import { eq, and } from "drizzle-orm"; import { idp, idpOrg } from "@server/db/schemas"; @@ -50,17 +56,6 @@ export async function createIdpOrgPolicy( next: NextFunction ): Promise { try { - const parsedParams = paramsSchema.safeParse(req.params); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - const { idpId, orgId } = parsedParams.data; - const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { return next( @@ -70,9 +65,20 @@ export async function createIdpOrgPolicy( ) ); } - let { orgMapping, roleMapping } = parsedBody.data; - // Given identity provider must exist and not have a policy already + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { idpId, orgId } = parsedParams.data; + const { roleMapping, orgMapping } = parsedBody.data; + const [existing] = await db .select() .from(idp) @@ -84,15 +90,18 @@ export async function createIdpOrgPolicy( if (!existing?.idp) { return next( - createHttpError(HttpCode.NOT_FOUND, "Idp does not exist") + createHttpError( + HttpCode.BAD_REQUEST, + "An IDP with this ID does not exist." + ) ); } if (existing.idpOrg) { return next( createHttpError( - HttpCode.CONFLICT, - "Org policy already exists for this idp" + HttpCode.BAD_REQUEST, + "An IDP org policy already exists." ) ); } @@ -100,15 +109,15 @@ export async function createIdpOrgPolicy( await db.insert(idpOrg).values({ idpId, orgId, - orgMapping, - roleMapping + roleMapping, + orgMapping }); return response(res, { data: {}, success: true, error: false, - message: "Idp org policy created successfully", + message: "Idp created successfully", status: HttpCode.CREATED }); } catch (error) { diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index e7fc6a5..d663afe 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -11,6 +11,7 @@ import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db/schemas"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; +import license from "@server/license/license"; const paramsSchema = z.object({}).strict(); @@ -80,6 +81,10 @@ export async function createOidcIdp( autoProvision } = parsedBody.data; + if (!(await license.isUnlocked())) { + autoProvision = false; + } + const key = config.getRawConfig().server.secret; const encryptedSecret = encrypt(clientSecret, key); diff --git a/server/routers/idp/deleteIdpOrgPolicy.ts b/server/routers/idp/deleteIdpOrgPolicy.ts index 51b8255..5c41c95 100644 --- a/server/routers/idp/deleteIdpOrgPolicy.ts +++ b/server/routers/idp/deleteIdpOrgPolicy.ts @@ -1,3 +1,8 @@ +// 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 { z } from "zod"; import { db } from "@server/db"; @@ -6,7 +11,7 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { idp, idpOidcConfig, idpOrg } from "@server/db/schemas"; +import { idp, idpOrg } from "@server/db/schemas"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; @@ -20,7 +25,7 @@ const paramsSchema = z registry.registerPath({ method: "delete", path: "/idp/{idpId}/org/{orgId}", - description: "Delete an IDP policy for an IDP on an organization.", + description: "Create an OIDC IdP for an organization.", tags: [OpenAPITags.Idp], request: { params: paramsSchema @@ -46,27 +51,26 @@ export async function deleteIdpOrgPolicy( const { idpId, orgId } = parsedParams.data; - // Check if IDP policy, exists const [existing] = await db .select() .from(idp) - .leftJoin( - idpOrg, - and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId)) - ) - .where(eq(idp.idpId, idpId)); + .leftJoin(idpOrg, eq(idpOrg.orgId, orgId)) + .where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId))); - if (!existing?.idp) { + if (!existing.idp) { return next( - createHttpError(HttpCode.NOT_FOUND, "Idp does not exist") + createHttpError( + HttpCode.BAD_REQUEST, + "An IDP with this ID does not exist." + ) ); } if (!existing.idpOrg) { return next( createHttpError( - HttpCode.NOT_FOUND, - "Org policy does not exist for this idp" + HttpCode.BAD_REQUEST, + "A policy for this IDP and org does not exist." ) ); } @@ -79,7 +83,7 @@ export async function deleteIdpOrgPolicy( data: null, success: true, error: false, - message: "Idp policy deleted successfully", + message: "Policy deleted successfully", status: HttpCode.OK }); } catch (error) { diff --git a/server/routers/idp/index.ts b/server/routers/idp/index.ts index 3749138..f0dcf02 100644 --- a/server/routers/idp/index.ts +++ b/server/routers/idp/index.ts @@ -7,5 +7,5 @@ export * from "./validateOidcCallback"; export * from "./getIdp"; export * from "./createIdpOrgPolicy"; export * from "./deleteIdpOrgPolicy"; -export * from "./updateIdpOrgPolicy"; export * from "./listIdpOrgPolicies"; +export * from "./updateIdpOrgPolicy"; diff --git a/server/routers/idp/listIdpOrgPolicies.ts b/server/routers/idp/listIdpOrgPolicies.ts index b2105f4..9ff9c97 100644 --- a/server/routers/idp/listIdpOrgPolicies.ts +++ b/server/routers/idp/listIdpOrgPolicies.ts @@ -1,7 +1,12 @@ +// 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 { z } from "zod"; import { db } from "@server/db"; -import { idpOrg, type IdpOrg } from "@server/db/schemas"; +import { idpOrg } from "@server/db/schemas"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -10,11 +15,9 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -const paramsSchema = z - .object({ - idpId: z.coerce.number() - }) - .strict(); +const paramsSchema = z.object({ + idpId: z.coerce.number() +}); const querySchema = z .object({ @@ -44,12 +47,8 @@ async function query(idpId: number, limit: number, offset: number) { } export type ListIdpOrgPoliciesResponse = { - policies: Array; - pagination: { - total: number; - limit: number; - offset: number; - }; + policies: NonNullable>>; + pagination: { total: number; limit: number; offset: number }; }; registry.registerPath({ @@ -79,7 +78,6 @@ export async function listIdpOrgPolicies( ) ); } - const { idpId } = parsedParams.data; const parsedQuery = querySchema.safeParse(req.query); @@ -111,7 +109,7 @@ export async function listIdpOrgPolicies( }, success: true, error: false, - message: "Idp org policies retrieved successfully", + message: "Policies retrieved successfully", status: HttpCode.OK }); } catch (error) { diff --git a/server/routers/idp/oidcAutoProvision.ts b/server/routers/idp/oidcAutoProvision.ts index 2687369..7861fc4 100644 --- a/server/routers/idp/oidcAutoProvision.ts +++ b/server/routers/idp/oidcAutoProvision.ts @@ -1,27 +1,21 @@ -import { Request, Response } from "express"; -import { z } from "zod"; +// 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 { createSession, generateId, generateSessionToken, serializeSessionCookie } from "@server/auth/sessions/app"; -import logger from "@server/logger"; import db from "@server/db"; -import { - Idp, - idpOrg, - orgs, - roles, - User, - userOrgs, - users -} from "@server/db/schemas"; +import { Idp, idpOrg, orgs, roles, User, userOrgs, users } from "@server/db/schemas"; +import logger from "@server/logger"; +import { UserType } from "@server/types/UserTypes"; import { eq, and, inArray } from "drizzle-orm"; import jmespath from "jmespath"; -import { UserType } from "@server/types/UserTypes"; - -const extractedRolesSchema = z.array(z.string()).or(z.string()).nullable(); +import { Request, Response } from "express"; export async function oidcAutoProvision({ idp, @@ -42,92 +36,83 @@ export async function oidcAutoProvision({ req: Request; res: Response; }) { - // Get user's roles of all orgs as stated in the ID token claims const allOrgs = await db.select().from(orgs); - const userOrgInfo: { orgId: string; roleId: number }[] = []; + const defaultRoleMapping = idp.defaultRoleMapping; + const defaultOrgMapping = idp.defaultOrgMapping; + + let userOrgInfo: { orgId: string; roleId: number }[] = []; for (const org of allOrgs) { - const idpOrgs = await db + const [idpOrgRes] = await db .select() .from(idpOrg) .where( and(eq(idpOrg.idpId, idp.idpId), eq(idpOrg.orgId, org.orgId)) ); - if (idpOrgs.length === 0) continue; - const idpOrgRes = idpOrgs[0]; - const orgMapping = hydrateOrgMapping( - idpOrgRes.orgMapping || idp.defaultOrgMapping, - org.orgId - ); - const roleMapping = idpOrgRes.roleMapping || idp.defaultRoleMapping; + let roleId: number | undefined = undefined; - if (orgMapping) { - const orgId = jmespath.search(claims, orgMapping); - logger.debug("Extracted org ID", { orgId }); + const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping; + const hydratedOrgMapping = hydrateOrgMapping(orgMapping, org.orgId); + + if (hydratedOrgMapping) { + logger.debug("Hydrated Org Mapping", { + hydratedOrgMapping + }); + const orgId = jmespath.search(claims, hydratedOrgMapping); + logger.debug("Extraced Org ID", { orgId }); if (orgId !== true && orgId !== org.orgId) { // user not allowed to access this org continue; } } + const roleMapping = idpOrgRes?.roleMapping || defaultRoleMapping; if (roleMapping) { - logger.info("claims", { claims }); - const extractedRoles = extractedRolesSchema.safeParse( - jmespath.search(claims, roleMapping) - ); - if (!extractedRoles.success) { - logger.error("Error extracting roles", { - error: extractedRoles.error + logger.debug("Role Mapping", { roleMapping }); + const roleName = jmespath.search(claims, roleMapping); + + if (!roleName) { + logger.error("Role name not found in the ID token", { + roleName }); continue; } - const rd = extractedRoles.data; - if (!rd) { - continue; - } - const rolesFromToken = typeof rd === "string" ? [rd] : rd; - logger.debug("Extracted roles", { rolesFromToken }); - if (rd.length === 0) { - continue; - } - const rolesFromDb = await db + const [roleRes] = await db .select() .from(roles) .where( - and( - eq(roles.orgId, org.orgId), - inArray(roles.name, rolesFromToken) - ) + and(eq(roles.orgId, org.orgId), eq(roles.name, roleName)) ); - if (rolesFromDb.length === 0) { - logger.error("Role(s) not found", { roles: rolesFromToken }); + + if (!roleRes) { + logger.error("Role not found", { + orgId: org.orgId, + roleName + }); continue; } - if (rolesFromDb.length < rolesFromToken.length) { - logger.warn("Role(s) not found", { - roles: rolesFromToken.filter( - (r) => !rolesFromDb.some((rdb) => rdb.name === r) - ) - }); - } - rolesFromDb.forEach((r) => { - userOrgInfo.push({ - orgId: org.orgId, - roleId: r.roleId - }); + roleId = roleRes.roleId; + + userOrgInfo.push({ + orgId: org.orgId, + roleId }); } } + logger.debug("User org info", { userOrgInfo }); - let userId = existingUser?.userId; + let existingUserId = existingUser?.userId; + // sync the user with the orgs and roles await db.transaction(async (trx) => { - if (!userId) { - // create user if it does not exist + let userId = existingUser?.userId; + + // create user if not exists + if (!existingUser) { userId = generateId(15); await trx.insert(users).values({ @@ -141,7 +126,7 @@ export async function oidcAutoProvision({ dateCreated: new Date().toISOString() }); } else { - // update username/email + // set the name and email await trx .update(users) .set({ @@ -149,60 +134,84 @@ export async function oidcAutoProvision({ email: email || null, name: name || null }) - .where(eq(users.userId, userId)); + .where(eq(users.userId, userId!)); } - // get all current user orgs/roles + existingUserId = userId; + + // get all current user orgs const currentUserOrgs = await trx .select() .from(userOrgs) - .where(eq(userOrgs.userId, userId)); + .where(eq(userOrgs.userId, userId!)); // Delete orgs that are no longer valid - const orgsToDelete = currentUserOrgs - .filter( - (currentOrg) => - !userOrgInfo.some( - (newOrg) => - newOrg.orgId === currentOrg.orgId && - newOrg.roleId === currentOrg.roleId - ) - ) - .map((org) => org.orgId); + const orgsToDelete = currentUserOrgs.filter( + (currentOrg) => + !userOrgInfo.some((newOrg) => newOrg.orgId === currentOrg.orgId) + ); if (orgsToDelete.length > 0) { - await trx - .delete(userOrgs) - .where( - and( - eq(userOrgs.userId, userId!), - inArray(userOrgs.orgId, orgsToDelete) + await trx.delete(userOrgs).where( + and( + eq(userOrgs.userId, userId!), + inArray( + userOrgs.orgId, + orgsToDelete.map((org) => org.orgId) ) + ) + ); + } + + // Update roles for existing orgs where the role has changed + const orgsToUpdate = currentUserOrgs.filter((currentOrg) => { + const newOrg = userOrgInfo.find( + (newOrg) => newOrg.orgId === currentOrg.orgId + ); + return newOrg && newOrg.roleId !== currentOrg.roleId; + }); + + if (orgsToUpdate.length > 0) { + for (const org of orgsToUpdate) { + const newRole = userOrgInfo.find( + (newOrg) => newOrg.orgId === org.orgId ); + if (newRole) { + await trx + .update(userOrgs) + .set({ roleId: newRole.roleId }) + .where( + and( + eq(userOrgs.userId, userId!), + eq(userOrgs.orgId, org.orgId) + ) + ); + } + } } // Add new orgs that don't exist yet const orgsToAdd = userOrgInfo.filter( (newOrg) => !currentUserOrgs.some( - (currentOrg) => - currentOrg.orgId === newOrg.orgId && - currentOrg.roleId === newOrg.roleId + (currentOrg) => currentOrg.orgId === newOrg.orgId ) ); + if (orgsToAdd.length > 0) { await trx.insert(userOrgs).values( orgsToAdd.map((org) => ({ userId: userId!, orgId: org.orgId, - roleId: org.roleId + roleId: org.roleId, + dateCreated: new Date().toISOString() })) ); } }); const token = generateSessionToken(); - const sess = await createSession(token, userId!); + const sess = await createSession(token, existingUserId!); const isSecure = req.protocol === "https"; const cookie = serializeSessionCookie( token, @@ -216,9 +225,9 @@ export async function oidcAutoProvision({ function hydrateOrgMapping( orgMapping: string | null, orgId: string -): string | null { +): string | undefined { if (!orgMapping) { - return null; + return undefined; } - return orgMapping.replaceAll("{{orgId}}", orgId); + return orgMapping.split("{{orgId}}").join(orgId); } diff --git a/server/routers/idp/updateIdpOrgPolicy.ts b/server/routers/idp/updateIdpOrgPolicy.ts index 642837d..6f8580a 100644 --- a/server/routers/idp/updateIdpOrgPolicy.ts +++ b/server/routers/idp/updateIdpOrgPolicy.ts @@ -1,3 +1,8 @@ +// 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 { z } from "zod"; import { db } from "@server/db"; @@ -7,8 +12,8 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { idp, idpOrg } from "@server/db/schemas"; import { eq, and } from "drizzle-orm"; +import { idp, idpOrg } from "@server/db/schemas"; const paramsSchema = z .object({ @@ -59,7 +64,6 @@ export async function updateIdpOrgPolicy( ) ); } - const { idpId, orgId } = parsedParams.data; const parsedBody = bodySchema.safeParse(req.body); if (!parsedBody.success) { @@ -70,9 +74,11 @@ export async function updateIdpOrgPolicy( ) ); } - let { orgMapping, roleMapping } = parsedBody.data; - // Given identity provider must exist and have a policy already + const { idpId, orgId } = parsedParams.data; + const { roleMapping, orgMapping } = parsedBody.data; + + // Check if IDP and policy exist const [existing] = await db .select() .from(idp) @@ -84,36 +90,36 @@ export async function updateIdpOrgPolicy( if (!existing?.idp) { return next( - createHttpError(HttpCode.NOT_FOUND, "Idp does not exist") + createHttpError( + HttpCode.BAD_REQUEST, + "An IDP with this ID does not exist." + ) ); } if (!existing.idpOrg) { return next( createHttpError( - HttpCode.NOT_FOUND, - "Org policy does not exist for this idp" + HttpCode.BAD_REQUEST, + "A policy for this IDP and org does not exist." ) ); } + // Update the policy await db .update(idpOrg) .set({ - idpId, - orgId, - orgMapping, - roleMapping + roleMapping, + orgMapping }) - .where(and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId))); + .where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId))); return response(res, { - data: { - idpId - }, + data: {}, success: true, error: false, - message: "Idp org policy updated successfully", + message: "Policy updated successfully", status: HttpCode.OK }); } catch (error) { diff --git a/server/routers/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts index 49a16a5..d24e319 100644 --- a/server/routers/idp/updateOidcIdp.ts +++ b/server/routers/idp/updateOidcIdp.ts @@ -11,6 +11,7 @@ import { idp, idpOidcConfig } from "@server/db/schemas"; import { eq } from "drizzle-orm"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; +import license from "@server/license/license"; const paramsSchema = z .object({ @@ -99,6 +100,10 @@ export async function updateOidcIdp( defaultOrgMapping } = parsedBody.data; + if (!(await license.isUnlocked())) { + autoProvision = false; + } + // Check if IDP exists and is of type OIDC const [existingIdp] = await db .select() diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index 274350d..7d588fe 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -20,6 +20,7 @@ import { } from "@server/auth/sessions/app"; import { decrypt } from "@server/lib/crypto"; import { oidcAutoProvision } from "./oidcAutoProvision"; +import license from "@server/license/license"; const ensureTrailingSlash = (url: string): string => { return url; @@ -209,6 +210,14 @@ export async function validateOidcCallback( ); if (existingIdp.idp.autoProvision) { + if (!(await license.isUnlocked())) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Auto-provisioning is not available" + ) + ); + } await oidcAutoProvision({ idp: existingIdp.idp, userIdentifier, diff --git a/server/routers/integration.ts b/server/routers/integration.ts new file mode 100644 index 0000000..40ab9aa --- /dev/null +++ b/server/routers/integration.ts @@ -0,0 +1,499 @@ +// 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 site from "./site"; +import * as org from "./org"; +import * as resource from "./resource"; +import * as domain from "./domain"; +import * as target from "./target"; +import * as user from "./user"; +import * as role from "./role"; +// import * as client from "./client"; +import * as accessToken from "./accessToken"; +import * as apiKeys from "./apiKeys"; +import * as idp from "./idp"; +import { + verifyApiKey, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction, + verifyApiKeySiteAccess, + verifyApiKeyResourceAccess, + verifyApiKeyTargetAccess, + verifyApiKeyRoleAccess, + verifyApiKeyUserAccess, + verifyApiKeySetResourceUsers, + verifyApiKeyAccessTokenAccess, + verifyApiKeyIsRoot +} from "@server/middlewares"; +import HttpCode from "@server/types/HttpCode"; +import { Router } from "express"; +import { ActionsEnum } from "@server/auth/actions"; + +export const unauthenticated = Router(); + +unauthenticated.get("/", (_, res) => { + res.status(HttpCode.OK).json({ message: "Healthy" }); +}); + +export const authenticated = Router(); +authenticated.use(verifyApiKey); + +authenticated.get( + "/org/checkId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.checkOrgId), + org.checkId +); + +authenticated.put( + "/org", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.createOrg), + org.createOrg +); + +authenticated.get( + "/orgs", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.listOrgs), + org.listOrgs +); // TODO we need to check the orgs here + +authenticated.get( + "/org/:orgId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getOrg), + org.getOrg +); + +authenticated.post( + "/org/:orgId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.updateOrg), + org.updateOrg +); + +authenticated.delete( + "/org/:orgId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.deleteOrg), + org.deleteOrg +); + +authenticated.put( + "/org/:orgId/site", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createSite), + site.createSite +); + +authenticated.get( + "/org/:orgId/sites", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listSites), + site.listSites +); + +authenticated.get( + "/org/:orgId/site/:niceId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getSite), + site.getSite +); + +authenticated.get( + "/org/:orgId/pick-site-defaults", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createSite), + site.pickSiteDefaults +); + +authenticated.get( + "/site/:siteId", + verifyApiKeySiteAccess, + verifyApiKeyHasAction(ActionsEnum.getSite), + site.getSite +); + +authenticated.post( + "/site/:siteId", + verifyApiKeySiteAccess, + verifyApiKeyHasAction(ActionsEnum.updateSite), + site.updateSite +); + +authenticated.delete( + "/site/:siteId", + verifyApiKeySiteAccess, + verifyApiKeyHasAction(ActionsEnum.deleteSite), + site.deleteSite +); + +authenticated.put( + "/org/:orgId/site/:siteId/resource", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createResource), + resource.createResource +); + +authenticated.get( + "/site/:siteId/resources", + verifyApiKeySiteAccess, + verifyApiKeyHasAction(ActionsEnum.listResources), + resource.listResources +); + +authenticated.get( + "/org/:orgId/resources", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listResources), + resource.listResources +); + +authenticated.get( + "/org/:orgId/domains", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listOrgDomains), + domain.listDomains +); + +authenticated.post( + "/org/:orgId/create-invite", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.inviteUser), + user.inviteUser +); + +authenticated.get( + "/resource/:resourceId/roles", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceRoles), + resource.listResourceRoles +); + +authenticated.get( + "/resource/:resourceId/users", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceUsers), + resource.listResourceUsers +); + +authenticated.get( + "/resource/:resourceId", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.getResource), + resource.getResource +); + +authenticated.post( + "/resource/:resourceId", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.updateResource), + resource.updateResource +); + +authenticated.delete( + "/resource/:resourceId", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.deleteResource), + resource.deleteResource +); + +authenticated.put( + "/resource/:resourceId/target", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.createTarget), + target.createTarget +); + +authenticated.get( + "/resource/:resourceId/targets", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listTargets), + target.listTargets +); + +authenticated.put( + "/resource/:resourceId/rule", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.createResourceRule), + resource.createResourceRule +); + +authenticated.get( + "/resource/:resourceId/rules", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listResourceRules), + resource.listResourceRules +); + +authenticated.post( + "/resource/:resourceId/rule/:ruleId", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.updateResourceRule), + resource.updateResourceRule +); + +authenticated.delete( + "/resource/:resourceId/rule/:ruleId", + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.deleteResourceRule), + resource.deleteResourceRule +); + +authenticated.get( + "/target/:targetId", + verifyApiKeyTargetAccess, + verifyApiKeyHasAction(ActionsEnum.getTarget), + target.getTarget +); + +authenticated.post( + "/target/:targetId", + verifyApiKeyTargetAccess, + verifyApiKeyHasAction(ActionsEnum.updateTarget), + target.updateTarget +); + +authenticated.delete( + "/target/:targetId", + verifyApiKeyTargetAccess, + verifyApiKeyHasAction(ActionsEnum.deleteTarget), + target.deleteTarget +); + +authenticated.put( + "/org/:orgId/role", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createRole), + role.createRole +); + +authenticated.get( + "/org/:orgId/roles", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listRoles), + role.listRoles +); + +authenticated.delete( + "/role/:roleId", + verifyApiKeyRoleAccess, + verifyApiKeyHasAction(ActionsEnum.deleteRole), + role.deleteRole +); + +authenticated.post( + "/role/:roleId/add/:userId", + verifyApiKeyRoleAccess, + verifyApiKeyUserAccess, + verifyApiKeyHasAction(ActionsEnum.addUserRole), + user.addUserRole +); + +authenticated.post( + "/resource/:resourceId/roles", + verifyApiKeyResourceAccess, + verifyApiKeyRoleAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceRoles), + resource.setResourceRoles +); + +authenticated.post( + "/resource/:resourceId/users", + verifyApiKeyResourceAccess, + verifyApiKeySetResourceUsers, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + resource.setResourceUsers +); + +authenticated.post( + `/resource/:resourceId/password`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourcePassword), + resource.setResourcePassword +); + +authenticated.post( + `/resource/:resourceId/pincode`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourcePincode), + resource.setResourcePincode +); + +authenticated.post( + `/resource/:resourceId/whitelist`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), + resource.setResourceWhitelist +); + +authenticated.get( + `/resource/:resourceId/whitelist`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.getResourceWhitelist), + resource.getResourceWhitelist +); + +authenticated.post( + `/resource/:resourceId/transfer`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.updateResource), + resource.transferResource +); + +authenticated.post( + `/resource/:resourceId/access-token`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.generateAccessToken), + accessToken.generateAccessToken +); + +authenticated.delete( + `/access-token/:accessTokenId`, + verifyApiKeyAccessTokenAccess, + verifyApiKeyHasAction(ActionsEnum.deleteAcessToken), + accessToken.deleteAccessToken +); + +authenticated.get( + `/org/:orgId/access-tokens`, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listAccessTokens), + accessToken.listAccessTokens +); + +authenticated.get( + `/resource/:resourceId/access-tokens`, + verifyApiKeyResourceAccess, + verifyApiKeyHasAction(ActionsEnum.listAccessTokens), + accessToken.listAccessTokens +); + +authenticated.get( + "/org/:orgId/user/:userId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getOrgUser), + user.getOrgUser +); + +authenticated.get( + "/org/:orgId/users", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listUsers), + user.listUsers +); + +authenticated.delete( + "/org/:orgId/user/:userId", + verifyApiKeyOrgAccess, + verifyApiKeyUserAccess, + verifyApiKeyHasAction(ActionsEnum.removeUser), + user.removeUserOrg +); + +// authenticated.put( +// "/newt", +// verifyApiKeyHasAction(ActionsEnum.createNewt), +// newt.createNewt +// ); + +authenticated.get( + `/org/:orgId/api-keys`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.listApiKeys), + apiKeys.listOrgApiKeys +); + +authenticated.post( + `/org/:orgId/api-key/:apiKeyId/actions`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.setApiKeyActions), + apiKeys.setApiKeyActions +); + +authenticated.get( + `/org/:orgId/api-key/:apiKeyId/actions`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.listApiKeyActions), + apiKeys.listApiKeyActions +); + +authenticated.put( + `/org/:orgId/api-key`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.createApiKey), + apiKeys.createOrgApiKey +); + +authenticated.delete( + `/org/:orgId/api-key/:apiKeyId`, + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.deleteApiKey), + apiKeys.deleteApiKey +); + +authenticated.put( + "/idp/oidc", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.createIdp), + idp.createOidcIdp +); + +authenticated.post( + "/idp/:idpId/oidc", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.updateIdp), + idp.updateOidcIdp +); + +authenticated.delete( + "/idp/:idpId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.deleteIdp), + idp.deleteIdp +); + +authenticated.get( + "/idp", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.listIdps), + idp.listIdps +); + +authenticated.get( + "/idp/:idpId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.getIdp), + idp.getIdp +); + +authenticated.put( + "/idp/:idpId/org/:orgId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.createIdpOrg), + idp.createIdpOrgPolicy +); + +authenticated.post( + "/idp/:idpId/org/:orgId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.updateIdpOrg), + idp.updateIdpOrgPolicy +); + +authenticated.delete( + "/idp/:idpId/org/:orgId", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.deleteIdpOrg), + idp.deleteIdpOrgPolicy +); + +authenticated.get( + "/idp/:idpId/org", + verifyApiKeyIsRoot, + verifyApiKeyHasAction(ActionsEnum.listIdpOrgs), + idp.listIdpOrgPolicies +); diff --git a/server/routers/internal.ts b/server/routers/internal.ts index fbc3f9e..eee72e9 100644 --- a/server/routers/internal.ts +++ b/server/routers/internal.ts @@ -4,6 +4,8 @@ import * as traefik from "@server/routers/traefik"; import * as resource from "./resource"; import * as badger from "./badger"; import * as auth from "@server/routers/auth"; +import * as supporterKey from "@server/routers/supporterKey"; +import * as license from "@server/routers/license"; import HttpCode from "@server/types/HttpCode"; import { verifyResourceAccess, @@ -31,6 +33,16 @@ internalRouter.post( resource.getExchangeToken ); +internalRouter.get( + `/supporter-key/visible`, + supporterKey.isSupporterKeyVisible +); + +internalRouter.get( + `/license/status`, + license.getLicenseStatus +); + // Gerbil routes const gerbilRouter = Router(); internalRouter.use("/gerbil", gerbilRouter); diff --git a/server/routers/license/activateLicense.ts b/server/routers/license/activateLicense.ts new file mode 100644 index 0000000..da2b76c --- /dev/null +++ b/server/routers/license/activateLicense.ts @@ -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 { + 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") + ); + } +} diff --git a/server/routers/license/deleteLicenseKey.ts b/server/routers/license/deleteLicenseKey.ts new file mode 100644 index 0000000..bea7f9a --- /dev/null +++ b/server/routers/license/deleteLicenseKey.ts @@ -0,0 +1,78 @@ +// 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"; +import { encrypt } from "@server/lib/crypto"; +import config from "@server/lib/config"; + +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 { + 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") + ); + } +} diff --git a/server/routers/license/getLicenseStatus.ts b/server/routers/license/getLicenseStatus.ts new file mode 100644 index 0000000..a4e4151 --- /dev/null +++ b/server/routers/license/getLicenseStatus.ts @@ -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 { + try { + const status = await license.check(); + + return sendResponse(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") + ); + } +} diff --git a/server/routers/license/index.ts b/server/routers/license/index.ts new file mode 100644 index 0000000..6c848c2 --- /dev/null +++ b/server/routers/license/index.ts @@ -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"; diff --git a/server/routers/license/listLicenseKeys.ts b/server/routers/license/listLicenseKeys.ts new file mode 100644 index 0000000..12a1956 --- /dev/null +++ b/server/routers/license/listLicenseKeys.ts @@ -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 { + try { + const keys = license.listKeys(); + + return sendResponse(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") + ); + } +} diff --git a/server/routers/license/recheckStatus.ts b/server/routers/license/recheckStatus.ts new file mode 100644 index 0000000..5f0bd94 --- /dev/null +++ b/server/routers/license/recheckStatus.ts @@ -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 { + 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") + ); + } +} diff --git a/server/routers/newt/createNewt.ts b/server/routers/newt/createNewt.ts index b69ada3..02517db 100644 --- a/server/routers/newt/createNewt.ts +++ b/server/routers/newt/createNewt.ts @@ -49,7 +49,7 @@ export async function createNewt( const { newtId, secret } = parsedBody.data; - if (req.user && !req.userRoleIds) { + if (req.user && !req.userOrgRoleId) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); diff --git a/server/routers/org/getOrgOverview.ts b/server/routers/org/getOrgOverview.ts index 59ae08f..dcde292 100644 --- a/server/routers/org/getOrgOverview.ts +++ b/server/routers/org/getOrgOverview.ts @@ -11,13 +11,12 @@ import { users, userSites } from "@server/db/schemas"; -import { and, count, eq, inArray, countDistinct } from "drizzle-orm"; +import { and, count, eq, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; -import { RoleItem } from "../user/getOrgUser"; const getOrgParamsSchema = z .object({ @@ -28,7 +27,7 @@ const getOrgParamsSchema = z export type GetOrgOverviewResponse = { orgName: string; orgId: string; - roles: RoleItem[]; + userRoleName: string; numSites: number; numUsers: number; numResources: number; @@ -116,25 +115,24 @@ export async function getOrgOverview( ); const [{ numUsers }] = await db - .select({ numUsers: countDistinct(userOrgs.userId) }) + .select({ numUsers: count() }) .from(userOrgs) .where(eq(userOrgs.orgId, orgId)); - const userRoles = await db - .select({ id: roles.roleId, name: roles.name }) + const [role] = await db + .select() .from(roles) - .where(inArray(roles.roleId, req.userRoleIds ?? [])) - .orderBy(roles.name); + .where(eq(roles.roleId, req.userOrg.roleId)); return response(res, { data: { orgName: org[0].name, orgId: org[0].orgId, - roles: userRoles, + userRoleName: role.name, numSites, numUsers, numResources, - isAdmin: userRoles.some((r) => r.name === "Admin"), + isAdmin: role.name === "Admin", isOwner: req.userOrg?.isOwner || false }, success: true, diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index 35dc4bf..e899530 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -130,7 +130,7 @@ export async function createResource( const { siteId, orgId } = parsedParams.data; - if (req.user && !req.userRoleIds) { + if (req.user && !req.userOrgRoleId) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -285,7 +285,7 @@ async function createHttpResource( resourceId: newResource[0].resourceId }); - if (req.user && req.userRoleIds?.indexOf(adminRole[0].roleId) === -1) { + if (req.user && req.userOrgRoleId != adminRole[0].roleId) { // make sure the user can access the resource await trx.insert(userResources).values({ userId: req.user?.userId!, @@ -392,7 +392,7 @@ async function createRawResource( resourceId: newResource[0].resourceId }); - if (req.userRoleIds?.indexOf(adminRole[0].roleId) === -1) { + if (req.userOrgRoleId != adminRole[0].roleId) { // make sure the user can access the resource await trx.insert(userResources).values({ userId: req.user?.userId!, diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 49de7aa..9af2474 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -216,7 +216,7 @@ export async function listResources( .where( or( eq(userResources.userId, req.user!.userId), - inArray(roleResources.roleId, req.userRoleIds!) + eq(roleResources.roleId, req.userOrgRoleId!) ) ); } else { diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index a4444b8..87eaa95 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -99,7 +99,7 @@ export async function createSite( const { orgId } = parsedParams.data; - if (req.user && !req.userRoleIds) { + if (req.user && !req.userOrgRoleId) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -176,7 +176,7 @@ export async function createSite( siteId: newSite.siteId }); - if (req.user && req.userRoleIds?.indexOf(adminRole[0].roleId) === -1) { + if (req.user && req.userOrgRoleId != adminRole[0].roleId) { // make sure the user can access the site trx.insert(userSites).values({ userId: req.user?.userId!, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 8dde88f..1b8791c 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -120,7 +120,7 @@ export async function listSites( .where( or( eq(userSites.userId, req.user!.userId), - inArray(roleSites.roleId, req.userRoleIds!) + eq(roleSites.roleId, req.userOrgRoleId!) ) ); } else { diff --git a/server/routers/supporterKey/hideSupporterKey.ts b/server/routers/supporterKey/hideSupporterKey.ts new file mode 100644 index 0000000..f9d4e89 --- /dev/null +++ b/server/routers/supporterKey/hideSupporterKey.ts @@ -0,0 +1,35 @@ +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 config from "@server/lib/config"; + +export type HideSupporterKeyResponse = { + hidden: boolean; +}; + +export async function hideSupporterKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + config.hideSupporterKey(); + + return sendResponse(res, { + data: { + hidden: true + }, + success: true, + error: false, + message: "Hidden", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/supporterKey/index.ts b/server/routers/supporterKey/index.ts new file mode 100644 index 0000000..4e339a6 --- /dev/null +++ b/server/routers/supporterKey/index.ts @@ -0,0 +1,3 @@ +export * from "./validateSupporterKey"; +export * from "./isSupporterKeyVisible"; +export * from "./hideSupporterKey"; diff --git a/server/routers/supporterKey/isSupporterKeyVisible.ts b/server/routers/supporterKey/isSupporterKeyVisible.ts new file mode 100644 index 0000000..15e313d --- /dev/null +++ b/server/routers/supporterKey/isSupporterKeyVisible.ts @@ -0,0 +1,63 @@ +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 config from "@server/lib/config"; +import db from "@server/db"; +import { count } from "drizzle-orm"; +import { users } from "@server/db/schemas"; +import license from "@server/license/license"; + +export type IsSupporterKeyVisibleResponse = { + visible: boolean; + tier?: string; +}; + +const USER_LIMIT = 5; + +export async function isSupporterKeyVisible( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const hidden = config.isSupporterKeyHidden(); + const key = config.getSupporterData(); + + let visible = !hidden && key?.valid !== true; + + const licenseStatus = await license.check(); + + if (licenseStatus.isLicenseValid) { + visible = false; + } + + if (key?.tier === "Limited Supporter") { + const [numUsers] = await db.select({ count: count() }).from(users); + + if (numUsers.count > USER_LIMIT) { + logger.debug( + `User count ${numUsers.count} exceeds limit ${USER_LIMIT}` + ); + visible = true; + } + } + + return sendResponse(res, { + data: { + visible, + tier: key?.tier || undefined + }, + success: true, + error: false, + message: "Status", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/supporterKey/validateSupporterKey.ts b/server/routers/supporterKey/validateSupporterKey.ts new file mode 100644 index 0000000..fadcdc3 --- /dev/null +++ b/server/routers/supporterKey/validateSupporterKey.ts @@ -0,0 +1,115 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { response as sendResponse } from "@server/lib"; +import { suppressDeprecationWarnings } from "moment"; +import { supporterKey } from "@server/db/schemas"; +import db from "@server/db"; +import { eq } from "drizzle-orm"; +import config from "@server/lib/config"; + +const validateSupporterKeySchema = z + .object({ + githubUsername: z.string().nonempty(), + key: z.string().nonempty() + }) + .strict(); + +export type ValidateSupporterKeyResponse = { + valid: boolean; + githubUsername?: string; + tier?: string; + phrase?: string; +}; + +export async function validateSupporterKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = validateSupporterKeySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { githubUsername, key } = parsedBody.data; + + const response = await fetch( + "https://api.fossorial.io/api/v1/license/validate", + { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + licenseKey: key, + githubUsername: githubUsername + }) + } + ); + + if (!response.ok) { + logger.error(response); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred" + ) + ); + } + + const data = await response.json(); + + if (!data || !data.data.valid) { + return sendResponse(res, { + data: { + valid: false + }, + success: true, + error: false, + message: "Invalid supporter key", + status: HttpCode.OK + }); + } + + await db.transaction(async (trx) => { + await trx.delete(supporterKey); + await trx.insert(supporterKey).values({ + githubUsername: githubUsername, + key: key, + tier: data.data.tier || null, + phrase: data.data.cutePhrase || null, + valid: true + }); + }); + + await config.checkSupporterKey(); + + return sendResponse(res, { + data: { + valid: true, + githubUsername: data.data.githubUsername, + tier: data.data.tier, + phrase: data.data.cutePhrase + }, + success: true, + error: false, + message: "Valid supporter key", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/addUserRole.ts b/server/routers/user/addUserRole.ts index b1c9025..c0ac31b 100644 --- a/server/routers/user/addUserRole.ts +++ b/server/routers/user/addUserRole.ts @@ -105,26 +105,14 @@ export async function addUserRole( ); } - const newUserRole = { orgId, userId, roleId, isOwner: false }; - - await db.transaction(async (trx) => { - const hasRoleAlready = await trx - .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.userId, userId), - eq(userOrgs.orgId, orgId), - eq(userOrgs.roleId, roleId) - ) - ); - if (hasRoleAlready.length === 0) { - await trx.insert(userOrgs).values(newUserRole); - } - }); + const newUserRole = await db + .update(userOrgs) + .set({ roleId }) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .returning(); return response(res, { - data: newUserRole, + data: newUserRole[0], success: true, error: false, message: "Role added to user successfully", diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index 226248a..6ebd33c 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { roles, userOrgs, users } from "@server/db/schemas"; -import { and, eq, sql } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -10,7 +10,6 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { OpenAPITags, registry } from "@server/openApi"; -import { StringDecoder } from "string_decoder"; async function queryUser(orgId: string, userId: string) { const [user] = await db @@ -21,7 +20,8 @@ async function queryUser(orgId: string, userId: string) { username: users.username, name: users.name, type: users.type, - roles: sql`json_group_array(json_object('id', ${roles.roleId}, 'name', ${roles.name}))`, + roleId: userOrgs.roleId, + roleName: roles.name, isOwner: userOrgs.isOwner, isAdmin: roles.isAdmin }) @@ -30,17 +30,9 @@ async function queryUser(orgId: string, userId: string) { .leftJoin(users, eq(userOrgs.userId, users.userId)) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); - if (typeof user.roles === "string") { - user.roles = JSON.parse(user.roles); - } return user; } -export type RoleItem = { - id: number; - name: string; -}; - export type GetOrgUserResponse = NonNullable< Awaited> >; diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index a9400cd..49278c1 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -2,7 +2,6 @@ export * from "./getUser"; export * from "./removeUserOrg"; export * from "./listUsers"; export * from "./addUserRole"; -export * from "./setUserRoles"; export * from "./inviteUser"; export * from "./acceptInvite"; export * from "./getOrgUser"; diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index 89752eb..fd2291d 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -5,11 +5,11 @@ import { idp, roles, userOrgs, users } from "@server/db/schemas"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { AnyColumn, eq, InferColumnsDataTypes, sql } from "drizzle-orm"; +import { and, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { RoleItem } from "./getOrgUser"; +import { eq } from "drizzle-orm"; const listUsersParamsSchema = z .object({ @@ -34,20 +34,8 @@ const listUsersSchema = z }) .strict(); -function jsonAggBuildObject>(shape: T) { - const shapeString = Object.entries(shape) - .map(([key, value]) => { - return `'${key}', ${value}`; - }) - .join(","); - - return sql< - InferColumnsDataTypes[] - >`json_agg(json_build_object(${shapeString}))`; -} - async function queryUsers(orgId: string, limit: number, offset: number) { - const res = await db + return await db .select({ id: users.userId, email: users.email, @@ -57,7 +45,8 @@ async function queryUsers(orgId: string, limit: number, offset: number) { username: users.username, name: users.name, type: users.type, - roles: sql`json_group_array(json_object('id', ${roles.roleId}, 'name', ${roles.name}))`, + roleId: userOrgs.roleId, + roleName: roles.name, isOwner: userOrgs.isOwner, idpName: idp.name, idpId: users.idpId @@ -67,15 +56,8 @@ async function queryUsers(orgId: string, limit: number, offset: number) { .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .where(eq(userOrgs.orgId, orgId)) - .groupBy(users.userId) .limit(limit) .offset(offset); - res.forEach((itm) => { - if (typeof itm.roles === "string") { - itm.roles = JSON.parse(itm.roles); - } - }); - return res; } export type ListUsersResponse = { diff --git a/server/routers/user/setUserRoles.ts b/server/routers/user/setUserRoles.ts deleted file mode 100644 index e89c989..0000000 --- a/server/routers/user/setUserRoles.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db } from "@server/db"; -import { userOrgs, roles } from "@server/db/schemas"; -import { eq, and, inArray } from "drizzle-orm"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; - -const setUserRolesParamsSchema = z - .object({ - orgId: z.string(), - userId: z.string() - }) - .strict(); - -const setUserRolesBodySchema = z.object({ - roleIds: z.array(z.number().int()).min(1) -}); - -export type SetUserRolesResponse = z.infer; - -registry.registerPath({ - method: "post", - path: "/org/{orgId}/user/{userId}/roles", - description: "Set the roles of an user", - tags: [OpenAPITags.Role, OpenAPITags.User], - request: { - params: setUserRolesParamsSchema, - body: { - content: { "application/json": { schema: setUserRolesBodySchema } } - } - }, - responses: {} -}); - -export async function setUserRoles( - req: Request, - res: Response, - next: NextFunction -): Promise { - try { - const parsedParams = setUserRolesParamsSchema.safeParse(req.params); - if (!parsedParams.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedParams.error).toString() - ) - ); - } - const { userId, orgId } = parsedParams.data; - - const parsedBody = setUserRolesBodySchema.safeParse(req.body); - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - let { roleIds: newRoles } = parsedBody.data; - newRoles = [...new Set(newRoles)]; - newRoles.sort((a, b) => a - b); - if (newRoles.length === 0) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "You need to set at least 1 role" - ) - ); - } - - if ((req.userOrg?.orgId || req.apiKeyOrg?.orgId) !== orgId) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "You do not have access to this organization" - ) - ); - } - - const existingRoles = await db - .select() - .from(userOrgs) - .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) - .orderBy(userOrgs.roleId); - - if (existingRoles.length === 0) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - "User not found or does not belong to the specified organization" - ) - ); - } - - if (existingRoles[0].isOwner) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "Cannot change the role of the owner of the organization" - ) - ); - } - - if ( - existingRoles.length === newRoles.length && - existingRoles.every((r, i) => r.roleId === newRoles[i]) - ) { - return response(res, { - data: { roles: newRoles }, - success: true, - error: false, - message: "User roles unchanged", - status: HttpCode.OK - }); - } - - const rolesToCheck = newRoles.filter( - (r) => !existingRoles.some((er) => er.roleId === r) - ); - if (rolesToCheck.length > 0) { - const roleChkRes = await db - .select() - .from(roles) - .where( - and( - eq(roles.orgId, orgId), - inArray(roles.roleId, rolesToCheck) - ) - ); - if (roleChkRes.length !== rolesToCheck.length) { - return next( - createHttpError( - HttpCode.NOT_FOUND, - "Role not found or does not belong to the specified organization" - ) - ); - } - } - - await db.transaction(async (trx) => { - await trx - .delete(userOrgs) - .where( - and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) - ); - const newValues = newRoles.map((roleId) => ({ - userId, - orgId, - roleId, - isOwner: false - })); - await trx.insert(userOrgs).values(newValues); - }); - - return response(res, { - data: { roles: newRoles }, - success: true, - error: false, - message: "User roles set successfully", - status: HttpCode.OK - }); - } catch (error) { - logger.error(error); - return next( - createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") - ); - } -} diff --git a/src/app/[orgId]/OrganizationLandingCard.tsx b/src/app/[orgId]/OrganizationLandingCard.tsx index 6bf0f57..baffc09 100644 --- a/src/app/[orgId]/OrganizationLandingCard.tsx +++ b/src/app/[orgId]/OrganizationLandingCard.tsx @@ -10,8 +10,7 @@ import { CardFooter } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; -import { Users, Settings, Waypoints, Combine } from "lucide-react"; -import { RoleItem } from "@server/routers/user"; +import { Users, Globe, Database, Cog, Settings, Waypoints, Combine } from "lucide-react"; interface OrgStat { label: string; @@ -27,7 +26,7 @@ type OrganizationLandingCardProps = { resources: number; users: number; }; - roles: RoleItem[]; + userRole: string; isAdmin: boolean; isOwner: boolean; orgId: string; @@ -82,21 +81,9 @@ export default function OrganizationLandingCard( ))}
- Your role - {orgData.overview.isOwner || - orgData.overview.isAdmin || - orgData.overview.roles.length === 1 - ? "" - : "s"} - :{" "} + Your role:{" "} - {orgData.overview.isOwner - ? "Owner" - : orgData.overview.isAdmin - ? "Admin" - : orgData.overview.roles - .map((r) => r.name) - .join(", ")} + {orgData.overview.isOwner ? "Owner" : orgData.overview.userRole}
diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index a9d7884..5f91fb6 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -74,7 +74,7 @@ export default async function OrgPage(props: OrgPageProps) { }, isAdmin: overview.isAdmin, isOwner: overview.isOwner, - roles: overview.roles + userRole: overview.userRoleName }} /> diff --git a/src/app/[orgId]/settings/access/users/UsersTable.tsx b/src/app/[orgId]/settings/access/users/UsersTable.tsx index e723985..8036cc8 100644 --- a/src/app/[orgId]/settings/access/users/UsersTable.tsx +++ b/src/app/[orgId]/settings/access/users/UsersTable.tsx @@ -150,7 +150,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { column.toggleSorting(column.getIsSorted() === "asc") } > - Roles + Role ); diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index ca327d6..002febc 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -8,9 +8,17 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; -import { SetUserRolesResponse } from "@server/routers/user"; +import { InviteUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -32,18 +40,10 @@ import { import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; const formSchema = z.object({ email: z.string().email({ message: "Please enter a valid email" }), - roles: z - .array( - z.object({ - id: z.string(), - text: z.string() - }) - ) - .min(1, { message: "Please select a role" }) + roleId: z.string().min(1, { message: "Please select a role" }) }); export default function AccessControlsPage() { @@ -54,18 +54,13 @@ export default function AccessControlsPage() { const { orgId } = useParams(); const [loading, setLoading] = useState(false); - const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>( - [] - ); - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< - number | null - >(null); + const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { email: user.email!, - roles: [] + roleId: user.roleId?.toString() } }); @@ -86,24 +81,13 @@ export default function AccessControlsPage() { }); if (res?.status === 200) { - setAllRoles( - res.data.data.roles.map((role) => ({ - id: role.roleId.toString(), - text: role.name - })) - ); + setRoles(res.data.data.roles); } } fetchRoles(); - form.setValue( - "roles", - user.roles.map((i) => ({ - id: i.id.toString(), - text: i.name - })) - ); + form.setValue("roleId", user.roleId.toString()); }, []); async function onSubmit(values: z.infer) { @@ -111,8 +95,8 @@ export default function AccessControlsPage() { const res = await api .post< - AxiosResponse - >(`/org/${user.orgId}/user/${user.userId}/roles`, { roleIds: values.roles.map((r) => parseInt(r.id)) }) + AxiosResponse + >(`/role/${values.roleId}/add/${user.userId}`) .catch((e) => { toast({ variant: "destructive", @@ -156,44 +140,30 @@ export default function AccessControlsPage() { > ( - - Roles - - { - form.setValue( - "roles", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - enableAutocomplete={true} - autocompleteOptions={ - allRoles - } - allowDuplicates={false} - restrictTagsToAutocompleteOptions={ - true - } - sortTags={true} - /> - + + Role + )} diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index 4f1a150..f82cfdb 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -78,7 +78,7 @@ export default async function UsersPage(props: UsersPageProps) { idpId: user.idpId, idpName: user.idpName || "Internal", status: "Confirmed", - role: user.isOwner ? "Owner" : user.roles.map((r) => r.name).join(", ") || "Member", + role: user.isOwner ? "Owner" : user.roleName || "Member", isOwner: user.isOwner || false }; }); diff --git a/src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx b/src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx new file mode 100644 index 0000000..69fe717 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/OrgApiKeysDataTable.tsx @@ -0,0 +1,33 @@ +// 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 { DataTable } from "@app/components/ui/data-table"; +import { ColumnDef } from "@tanstack/react-table"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + addApiKey?: () => void; +} + +export function OrgApiKeysDataTable({ + addApiKey, + columns, + data +}: DataTableProps) { + return ( + + ); +} diff --git a/src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx b/src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx new file mode 100644 index 0000000..89e4784 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/OrgApiKeysTable.tsx @@ -0,0 +1,204 @@ +// 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 { OrgApiKeysDataTable } from "./OrgApiKeysDataTable"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import moment from "moment"; + +export type OrgApiKeyRow = { + id: string; + key: string; + name: string; + createdAt: string; +}; + +type OrgApiKeyTableProps = { + apiKeys: OrgApiKeyRow[]; + orgId: string; +}; + +export default function OrgApiKeysTable({ + apiKeys, + orgId +}: OrgApiKeyTableProps) { + const router = useRouter(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [rows, setRows] = useState(apiKeys); + + const api = createApiClient(useEnvContext()); + + const deleteSite = (apiKeyId: string) => { + api.delete(`/org/${orgId}/api-key/${apiKeyId}`) + .catch((e) => { + console.error("Error deleting API key", e); + toast({ + variant: "destructive", + title: "Error deleting API key", + description: formatAxiosError(e, "Error deleting API key") + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + + const newRows = rows.filter((row) => row.id !== apiKeyId); + + setRows(newRows); + }); + }; + + const columns: ColumnDef[] = [ + { + id: "dots", + cell: ({ row }) => { + const apiKeyROw = row.original; + const router = useRouter(); + + return ( + + + + + + { + setSelected(apiKeyROw); + }} + > + View settings + + { + setSelected(apiKeyROw); + setIsDeleteModalOpen(true); + }} + > + Delete + + + + ); + } + }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "key", + header: "Key", + cell: ({ row }) => { + const r = row.original; + return {r.key}; + } + }, + { + accessorKey: "createdAt", + header: "Created At", + cell: ({ row }) => { + const r = row.original; + return {moment(r.createdAt).format("lll")} ; + } + }, + { + id: "actions", + cell: ({ row }) => { + const r = row.original; + return ( +
+ + + +
+ ); + } + } + ]; + + return ( + <> + {selected && ( + { + setIsDeleteModalOpen(val); + setSelected(null); + }} + dialog={ +
+

+ Are you sure you want to remove the API key{" "} + {selected?.name || selected?.id} from the + organization? +

+ +

+ + Once removed, the API key will no longer be + able to be used. + +

+ +

+ To confirm, please type the name of the API key + below. +

+
+ } + buttonText="Confirm Delete API Key" + onConfirm={async () => deleteSite(selected!.id)} + string={selected.name} + title="Delete API Key" + /> + )} + + { + router.push(`/${orgId}/settings/api-keys/create`); + }} + /> + + ); +} diff --git a/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx b/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx new file mode 100644 index 0000000..a4c13c9 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/[apiKeyId]/layout.tsx @@ -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 { internal } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { SidebarSettings } from "@app/components/SidebarSettings"; +import Link from "next/link"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from "@app/components/ui/breadcrumb"; +import { GetApiKeyResponse } from "@server/routers/apiKeys"; +import ApiKeyProvider from "@app/providers/ApiKeyProvider"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ apiKeyId: string; orgId: string }>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + + const { children } = props; + + let apiKey = null; + try { + const res = await internal.get>( + `/org/${params.orgId}/api-key/${params.apiKeyId}`, + await authCookieHeader() + ); + apiKey = res.data.data; + } catch (e) { + console.log(e); + redirect(`/${params.orgId}/settings/api-keys`); + } + + const navItems = [ + { + title: "Permissions", + href: "/{orgId}/settings/api-keys/{apiKeyId}/permissions" + } + ]; + + return ( + <> + + + + {children} + + + ); +} diff --git a/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx b/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx new file mode 100644 index 0000000..7df37cd --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/[apiKeyId]/page.tsx @@ -0,0 +1,13 @@ +// 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 { redirect } from "next/navigation"; + +export default async function ApiKeysPage(props: { + params: Promise<{ orgId: string; apiKeyId: string }>; +}) { + const params = await props.params; + redirect(`/${params.orgId}/settings/api-keys/${params.apiKeyId}/permissions`); +} diff --git a/src/app/[orgId]/settings/api-keys/[apiKeyId]/permissions/page.tsx b/src/app/[orgId]/settings/api-keys/[apiKeyId]/permissions/page.tsx new file mode 100644 index 0000000..d1e6f51 --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/[apiKeyId]/permissions/page.tsx @@ -0,0 +1,138 @@ +// 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 PermissionsSelectBox from "@app/components/PermissionsSelectBox"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { ListApiKeyActionsResponse } from "@server/routers/apiKeys"; +import { AxiosResponse } from "axios"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId, apiKeyId } = useParams(); + + const [loadingPage, setLoadingPage] = useState(true); + const [selectedPermissions, setSelectedPermissions] = useState< + Record + >({}); + const [loadingSavePermissions, setLoadingSavePermissions] = + useState(false); + + useEffect(() => { + async function load() { + setLoadingPage(true); + + const res = await api + .get< + AxiosResponse + >(`/org/${orgId}/api-key/${apiKeyId}/actions`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error loading API key actions", + description: formatAxiosError( + e, + "Error loading API key actions" + ) + }); + }); + + if (res && res.status === 200) { + const data = res.data.data; + for (const action of data.actions) { + setSelectedPermissions((prev) => ({ + ...prev, + [action.actionId]: true + })); + } + } + + setLoadingPage(false); + } + + load(); + }, []); + + async function savePermissions() { + setLoadingSavePermissions(true); + + const actionsRes = await api + .post(`/org/${orgId}/api-key/${apiKeyId}/actions`, { + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }) + .catch((e) => { + console.error("Error setting permissions", e); + toast({ + variant: "destructive", + title: "Error setting permissions", + description: formatAxiosError(e) + }); + }); + + if (actionsRes && actionsRes.status === 200) { + toast({ + title: "Permissions updated", + description: "The permissions have been updated." + }); + } + + setLoadingSavePermissions(false); + } + + return ( + <> + {!loadingPage && ( + + + + + Permissions + + + Determine what this API key can do + + + + + + + + + + + + )} + + ); +} diff --git a/src/app/[orgId]/settings/api-keys/create/page.tsx b/src/app/[orgId]/settings/api-keys/create/page.tsx new file mode 100644 index 0000000..3ede2ac --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/create/page.tsx @@ -0,0 +1,412 @@ +// 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 { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { z } from "zod"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@app/components/ui/input"; +import { InfoIcon } from "lucide-react"; +import { Button } from "@app/components/ui/button"; +import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { AxiosResponse } from "axios"; +import { useParams, useRouter } from "next/navigation"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from "@app/components/ui/breadcrumb"; +import Link from "next/link"; +import { + CreateOrgApiKeyBody, + CreateOrgApiKeyResponse +} from "@server/routers/apiKeys"; +import { ApiKey } from "@server/db/schemas"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import moment from "moment"; +import CopyCodeBox from "@server/emails/templates/components/CopyCodeBox"; +import CopyTextBox from "@app/components/CopyTextBox"; +import PermissionsSelectBox from "@app/components/PermissionsSelectBox"; + +const createFormSchema = z.object({ + name: z + .string() + .min(2, { + message: "Name must be at least 2 characters." + }) + .max(255, { + message: "Name must not be longer than 255 characters." + }) +}); + +type CreateFormValues = z.infer; + +const copiedFormSchema = z + .object({ + copied: z.boolean() + }) + .refine( + (data) => { + return data.copied; + }, + { + message: "You must confirm that you have copied the API key.", + path: ["copied"] + } + ); + +type CopiedFormValues = z.infer; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { orgId } = useParams(); + const router = useRouter(); + + const [loadingPage, setLoadingPage] = useState(true); + const [createLoading, setCreateLoading] = useState(false); + const [apiKey, setApiKey] = useState(null); + const [selectedPermissions, setSelectedPermissions] = useState< + Record + >({}); + + const form = useForm({ + resolver: zodResolver(createFormSchema), + defaultValues: { + name: "" + } + }); + + const copiedForm = useForm({ + resolver: zodResolver(copiedFormSchema), + defaultValues: { + copied: false + } + }); + + async function onSubmit(data: CreateFormValues) { + setCreateLoading(true); + + let payload: CreateOrgApiKeyBody = { + name: data.name + }; + + const res = await api + .put< + AxiosResponse + >(`/org/${orgId}/api-key/`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error creating API key", + description: formatAxiosError(e) + }); + }); + + if (res && res.status === 201) { + const data = res.data.data; + + console.log({ + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }); + + const actionsRes = await api + .post(`/org/${orgId}/api-key/${data.apiKeyId}/actions`, { + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }) + .catch((e) => { + console.error("Error setting permissions", e); + toast({ + variant: "destructive", + title: "Error setting permissions", + description: formatAxiosError(e) + }); + }); + + if (actionsRes) { + setApiKey(data); + } + } + + setCreateLoading(false); + } + + async function onCopiedSubmit(data: CopiedFormValues) { + if (!data.copied) { + return; + } + + router.push(`/${orgId}/settings/api-keys`); + } + + const formatLabel = (str: string) => { + return str + .replace(/([a-z0-9])([A-Z])/g, "$1 $2") + .replace(/^./, (char) => char.toUpperCase()); + }; + + useEffect(() => { + const load = async () => { + setLoadingPage(false); + }; + + load(); + }, []); + + return ( + <> +
+ + +
+ + {!loadingPage && ( +
+ + {!apiKey && ( + <> + + + + API Key Information + + + + +
+ + ( + + + Name + + + + + + + )} + /> + + +
+
+
+ + + + + Permissions + + + Determine what this API key can do + + + + + + + + )} + + {apiKey && ( + + + + Your API Key + + + + + + + Name + + + + + + + + Created + + + {moment( + apiKey.createdAt + ).format("lll")} + + + + + + + + Save Your API Key + + + You will only be able to see this + once. Make sure to copy it to a + secure place. + + + +

+ Your API key is: +

+ + + +
+ + ( + +
+ { + copiedForm.setValue( + "copied", + e as boolean + ); + }} + /> + +
+ +
+ )} + /> + + +
+
+ )} +
+ +
+ {!apiKey && ( + + )} + {!apiKey && ( + + )} + + {apiKey && ( + + )} +
+
+ )} + + ); +} diff --git a/src/app/[orgId]/settings/api-keys/page.tsx b/src/app/[orgId]/settings/api-keys/page.tsx new file mode 100644 index 0000000..ef1e3dd --- /dev/null +++ b/src/app/[orgId]/settings/api-keys/page.tsx @@ -0,0 +1,49 @@ +// 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 { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import OrgApiKeysTable, { OrgApiKeyRow } from "./OrgApiKeysTable"; +import { ListOrgApiKeysResponse } from "@server/routers/apiKeys"; + +type ApiKeyPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ApiKeysPage(props: ApiKeyPageProps) { + const params = await props.params; + let apiKeys: ListOrgApiKeysResponse["apiKeys"] = []; + try { + const res = await internal.get>( + `/org/${params.orgId}/api-keys`, + await authCookieHeader() + ); + apiKeys = res.data.data.apiKeys; + } catch (e) {} + + const rows: OrgApiKeyRow[] = apiKeys.map((key) => { + return { + name: key.name, + id: key.apiKeyId, + key: `${key.apiKeyId}••••••••••••••••••••${key.lastChars}`, + createdAt: key.createdAt + }; + }); + + return ( + <> + + + + + ); +} diff --git a/src/app/admin/api-keys/ApiKeysDataTable.tsx b/src/app/admin/api-keys/ApiKeysDataTable.tsx new file mode 100644 index 0000000..f65949a --- /dev/null +++ b/src/app/admin/api-keys/ApiKeysDataTable.tsx @@ -0,0 +1,58 @@ +// 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, + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + SortingState, + getSortedRowModel, + ColumnFiltersState, + getFilteredRowModel +} from "@tanstack/react-table"; + +import { + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow +} from "@/components/ui/table"; +import { Button } from "@app/components/ui/button"; +import { useState } from "react"; +import { Input } from "@app/components/ui/input"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { Plus, Search } from "lucide-react"; +import { DataTable } from "@app/components/ui/data-table"; + +interface DataTableProps { + columns: ColumnDef[]; + data: TData[]; + addApiKey?: () => void; +} + +export function ApiKeysDataTable({ + addApiKey, + columns, + data +}: DataTableProps) { + return ( + + ); +} diff --git a/src/app/admin/api-keys/ApiKeysTable.tsx b/src/app/admin/api-keys/ApiKeysTable.tsx new file mode 100644 index 0000000..c44d43f --- /dev/null +++ b/src/app/admin/api-keys/ApiKeysTable.tsx @@ -0,0 +1,199 @@ +// 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 { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { toast } from "@app/hooks/useToast"; +import { formatAxiosError } from "@app/lib/api"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import moment from "moment"; +import { ApiKeysDataTable } from "./ApiKeysDataTable"; + +export type ApiKeyRow = { + id: string; + key: string; + name: string; + createdAt: string; +}; + +type ApiKeyTableProps = { + apiKeys: ApiKeyRow[]; +}; + +export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { + const router = useRouter(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selected, setSelected] = useState(null); + const [rows, setRows] = useState(apiKeys); + + const api = createApiClient(useEnvContext()); + + const deleteSite = (apiKeyId: string) => { + api.delete(`/api-key/${apiKeyId}`) + .catch((e) => { + console.error("Error deleting API key", e); + toast({ + variant: "destructive", + title: "Error deleting API key", + description: formatAxiosError(e, "Error deleting API key") + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + + const newRows = rows.filter((row) => row.id !== apiKeyId); + + setRows(newRows); + }); + }; + + const columns: ColumnDef[] = [ + { + id: "dots", + cell: ({ row }) => { + const apiKeyROw = row.original; + const router = useRouter(); + + return ( + + + + + + { + setSelected(apiKeyROw); + }} + > + View settings + + { + setSelected(apiKeyROw); + setIsDeleteModalOpen(true); + }} + > + Delete + + + + ); + } + }, + { + accessorKey: "name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "key", + header: "Key", + cell: ({ row }) => { + const r = row.original; + return {r.key}; + } + }, + { + accessorKey: "createdAt", + header: "Created At", + cell: ({ row }) => { + const r = row.original; + return {moment(r.createdAt).format("lll")} ; + } + }, + { + id: "actions", + cell: ({ row }) => { + const r = row.original; + return ( +
+ + + +
+ ); + } + } + ]; + + return ( + <> + {selected && ( + { + setIsDeleteModalOpen(val); + setSelected(null); + }} + dialog={ +
+

+ Are you sure you want to remove the API key{" "} + {selected?.name || selected?.id}? +

+ +

+ + Once removed, the API key will no longer be + able to be used. + +

+ +

+ To confirm, please type the name of the API key + below. +

+
+ } + buttonText="Confirm Delete API Key" + onConfirm={async () => deleteSite(selected!.id)} + string={selected.name} + title="Delete API Key" + /> + )} + + { + router.push(`/admin/api-keys/create`); + }} + /> + + ); +} diff --git a/src/app/admin/api-keys/[apiKeyId]/layout.tsx b/src/app/admin/api-keys/[apiKeyId]/layout.tsx new file mode 100644 index 0000000..be3147e --- /dev/null +++ b/src/app/admin/api-keys/[apiKeyId]/layout.tsx @@ -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 { internal } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { SidebarSettings } from "@app/components/SidebarSettings"; +import Link from "next/link"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from "@app/components/ui/breadcrumb"; +import { GetApiKeyResponse } from "@server/routers/apiKeys"; +import ApiKeyProvider from "@app/providers/ApiKeyProvider"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; + +interface SettingsLayoutProps { + children: React.ReactNode; + params: Promise<{ apiKeyId: string }>; +} + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + + const { children } = props; + + let apiKey = null; + try { + const res = await internal.get>( + `/api-key/${params.apiKeyId}`, + await authCookieHeader() + ); + apiKey = res.data.data; + } catch (e) { + console.error(e); + redirect(`/admin/api-keys`); + } + + const navItems = [ + { + title: "Permissions", + href: "/admin/api-keys/{apiKeyId}/permissions" + } + ]; + + return ( + <> + + + + {children} + + + ); +} diff --git a/src/app/admin/api-keys/[apiKeyId]/page.tsx b/src/app/admin/api-keys/[apiKeyId]/page.tsx new file mode 100644 index 0000000..b0e4c3e --- /dev/null +++ b/src/app/admin/api-keys/[apiKeyId]/page.tsx @@ -0,0 +1,13 @@ +// 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 { redirect } from "next/navigation"; + +export default async function ApiKeysPage(props: { + params: Promise<{ apiKeyId: string }>; +}) { + const params = await props.params; + redirect(`/admin/api-keys/${params.apiKeyId}/permissions`); +} diff --git a/src/app/admin/api-keys/[apiKeyId]/permissions/page.tsx b/src/app/admin/api-keys/[apiKeyId]/permissions/page.tsx new file mode 100644 index 0000000..c468c13 --- /dev/null +++ b/src/app/admin/api-keys/[apiKeyId]/permissions/page.tsx @@ -0,0 +1,139 @@ +// 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 PermissionsSelectBox from "@app/components/PermissionsSelectBox"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { Button } from "@app/components/ui/button"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { ListApiKeyActionsResponse } from "@server/routers/apiKeys"; +import { AxiosResponse } from "axios"; +import { useParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const { apiKeyId } = useParams(); + + const [loadingPage, setLoadingPage] = useState(true); + const [selectedPermissions, setSelectedPermissions] = useState< + Record + >({}); + const [loadingSavePermissions, setLoadingSavePermissions] = + useState(false); + + useEffect(() => { + async function load() { + setLoadingPage(true); + + const res = await api + .get< + AxiosResponse + >(`/api-key/${apiKeyId}/actions`) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error loading API key actions", + description: formatAxiosError( + e, + "Error loading API key actions" + ) + }); + }); + + if (res && res.status === 200) { + const data = res.data.data; + for (const action of data.actions) { + setSelectedPermissions((prev) => ({ + ...prev, + [action.actionId]: true + })); + } + } + + setLoadingPage(false); + } + + load(); + }, []); + + async function savePermissions() { + setLoadingSavePermissions(true); + + const actionsRes = await api + .post(`/api-key/${apiKeyId}/actions`, { + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }) + .catch((e) => { + console.error("Error setting permissions", e); + toast({ + variant: "destructive", + title: "Error setting permissions", + description: formatAxiosError(e) + }); + }); + + if (actionsRes && actionsRes.status === 200) { + toast({ + title: "Permissions updated", + description: "The permissions have been updated." + }); + } + + setLoadingSavePermissions(false); + } + + return ( + <> + {!loadingPage && ( + + + + + Permissions + + + Determine what this API key can do + + + + + + + + + + + + )} + + ); +} diff --git a/src/app/admin/api-keys/create/page.tsx b/src/app/admin/api-keys/create/page.tsx new file mode 100644 index 0000000..c76b185 --- /dev/null +++ b/src/app/admin/api-keys/create/page.tsx @@ -0,0 +1,402 @@ +// 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 { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@app/components/ui/form"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { z } from "zod"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { Input } from "@app/components/ui/input"; +import { InfoIcon } from "lucide-react"; +import { Button } from "@app/components/ui/button"; +import { Checkbox, CheckboxWithLabel } from "@app/components/ui/checkbox"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { AxiosResponse } from "axios"; +import { useParams, useRouter } from "next/navigation"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from "@app/components/ui/breadcrumb"; +import Link from "next/link"; +import { + CreateOrgApiKeyBody, + CreateOrgApiKeyResponse +} from "@server/routers/apiKeys"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import moment from "moment"; +import CopyTextBox from "@app/components/CopyTextBox"; +import PermissionsSelectBox from "@app/components/PermissionsSelectBox"; + +const createFormSchema = z.object({ + name: z + .string() + .min(2, { + message: "Name must be at least 2 characters." + }) + .max(255, { + message: "Name must not be longer than 255 characters." + }) +}); + +type CreateFormValues = z.infer; + +const copiedFormSchema = z + .object({ + copied: z.boolean() + }) + .refine( + (data) => { + return data.copied; + }, + { + message: "You must confirm that you have copied the API key.", + path: ["copied"] + } + ); + +type CopiedFormValues = z.infer; + +export default function Page() { + const { env } = useEnvContext(); + const api = createApiClient({ env }); + const router = useRouter(); + + const [loadingPage, setLoadingPage] = useState(true); + const [createLoading, setCreateLoading] = useState(false); + const [apiKey, setApiKey] = useState(null); + const [selectedPermissions, setSelectedPermissions] = useState< + Record + >({}); + + const form = useForm({ + resolver: zodResolver(createFormSchema), + defaultValues: { + name: "" + } + }); + + const copiedForm = useForm({ + resolver: zodResolver(copiedFormSchema), + defaultValues: { + copied: false + } + }); + + async function onSubmit(data: CreateFormValues) { + setCreateLoading(true); + + let payload: CreateOrgApiKeyBody = { + name: data.name + }; + + const res = await api + .put>(`/api-key`, payload) + .catch((e) => { + toast({ + variant: "destructive", + title: "Error creating API key", + description: formatAxiosError(e) + }); + }); + + if (res && res.status === 201) { + const data = res.data.data; + + console.log({ + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }); + + const actionsRes = await api + .post(`/api-key/${data.apiKeyId}/actions`, { + actionIds: Object.keys(selectedPermissions).filter( + (key) => selectedPermissions[key] + ) + }) + .catch((e) => { + console.error("Error setting permissions", e); + toast({ + variant: "destructive", + title: "Error setting permissions", + description: formatAxiosError(e) + }); + }); + + if (actionsRes) { + setApiKey(data); + } + } + + setCreateLoading(false); + } + + async function onCopiedSubmit(data: CopiedFormValues) { + if (!data.copied) { + return; + } + + router.push(`/admin/api-keys`); + } + + useEffect(() => { + const load = async () => { + setLoadingPage(false); + }; + + load(); + }, []); + + return ( + <> +
+ + +
+ + {!loadingPage && ( +
+ + {!apiKey && ( + <> + + + + API Key Information + + + + +
+ + ( + + + Name + + + + + + + )} + /> + + +
+
+
+ + + + + Permissions + + + Determine what this API key can do + + + + + + + + )} + + {apiKey && ( + + + + Your API Key + + + + + + + Name + + + + + + + + Created + + + {moment( + apiKey.createdAt + ).format("lll")} + + + + + + + + Save Your API Key + + + You will only be able to see this + once. Make sure to copy it to a + secure place. + + + +

+ Your API key is: +

+ + + +
+ + ( + +
+ { + copiedForm.setValue( + "copied", + e as boolean + ); + }} + /> + +
+ +
+ )} + /> + + +
+
+ )} +
+ +
+ {!apiKey && ( + + )} + {!apiKey && ( + + )} + + {apiKey && ( + + )} +
+
+ )} + + ); +} diff --git a/src/app/admin/api-keys/page.tsx b/src/app/admin/api-keys/page.tsx new file mode 100644 index 0000000..b4a0080 --- /dev/null +++ b/src/app/admin/api-keys/page.tsx @@ -0,0 +1,46 @@ +// 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 { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { ListRootApiKeysResponse } from "@server/routers/apiKeys"; +import ApiKeysTable, { ApiKeyRow } from "./ApiKeysTable"; + +type ApiKeyPageProps = {}; + +export const dynamic = "force-dynamic"; + +export default async function ApiKeysPage(props: ApiKeyPageProps) { + let apiKeys: ListRootApiKeysResponse["apiKeys"] = []; + try { + const res = await internal.get>( + `/api-keys`, + await authCookieHeader() + ); + apiKeys = res.data.data.apiKeys; + } catch (e) {} + + const rows: ApiKeyRow[] = apiKeys.map((key) => { + return { + name: key.name, + id: key.apiKeyId, + key: `${key.apiKeyId}••••••••••••••••••••${key.lastChars}`, + createdAt: key.createdAt + }; + }); + + return ( + <> + + + + + ); +} diff --git a/src/app/admin/idp/[idpId]/general/page.tsx b/src/app/admin/idp/[idpId]/general/page.tsx index eba6bae..f7844c7 100644 --- a/src/app/admin/idp/[idpId]/general/page.tsx +++ b/src/app/admin/idp/[idpId]/general/page.tsx @@ -42,6 +42,7 @@ import { } from "@app/components/InfoSection"; import CopyToClipboard from "@app/components/CopyToClipboard"; import { Badge } from "@app/components/ui/badge"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; const GeneralFormSchema = z.object({ 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 [loading, setLoading] = useState(false); const [initialLoading, setInitialLoading] = useState(true); + const { isUnlocked } = useLicenseStatusContext(); const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`; @@ -230,6 +232,7 @@ export default function GeneralPage() { defaultChecked={form.getValues( "autoProvision" )} + disabled={!isUnlocked()} onCheckedChange={(checked) => { form.setValue( "autoProvision", @@ -237,6 +240,14 @@ export default function GeneralPage() { ); }} /> + {!isUnlocked() && ( + + Professional + + )} When enabled, users will be diff --git a/src/app/admin/idp/[idpId]/layout.tsx b/src/app/admin/idp/[idpId]/layout.tsx index 559c87e..d244e13 100644 --- a/src/app/admin/idp/[idpId]/layout.tsx +++ b/src/app/admin/idp/[idpId]/layout.tsx @@ -4,7 +4,17 @@ import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; import { authCookieHeader } from "@app/lib/api/cookies"; import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { ProfessionalContentOverlay } from "@app/components/ProfessionalContentOverlay"; +import Link from "next/link"; +import { ArrowLeft } from "lucide-react"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator +} from "@app/components/ui/breadcrumb"; interface SettingsLayoutProps { children: React.ReactNode; @@ -34,6 +44,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { { title: "Organization Policies", href: `/admin/idp/${params.idpId}/policies`, + showProfessional: true } ]; diff --git a/src/app/admin/idp/[idpId]/policies/EditPolicyForm.tsx b/src/app/admin/idp/[idpId]/policies/EditPolicyForm.tsx deleted file mode 100644 index b967fc8..0000000 --- a/src/app/admin/idp/[idpId]/policies/EditPolicyForm.tsx +++ /dev/null @@ -1,368 +0,0 @@ -import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import { z } from "zod"; -import { PolicyRow } from "./PolicyTable"; -import { Button } from "@app/components/ui/button"; -import { useState } from "react"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { cn } from "@app/lib/cn"; -import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList -} from "@app/components/ui/command"; -import type { Org } from "@server/db/schemas"; -import { AxiosResponse } from "axios"; -import { CreateIdpOrgPolicyResponse } from "@server/routers/idp"; -import { toast } from "@app/hooks/useToast"; - -type EditPolicyFormProps = { - idpId: string; - orgs: Org[]; - policies: PolicyRow[]; - policyToEdit: PolicyRow | null; - open: boolean; - setOpen: (open: boolean) => void; - afterCreate?: (policy: PolicyRow) => void; - afterEdit?: (policy: PolicyRow) => void; -}; - -const formSchema = z.object({ - orgId: z.string(), - roleMapping: z.string().optional(), - orgMapping: z.string().optional() -}); - -export default function EditPolicyForm({ - idpId, - orgs, - policies, - policyToEdit, - open, - setOpen, - afterCreate, - afterEdit -}: EditPolicyFormProps) { - const [loading, setLoading] = useState(false); - const [orgsPopoverOpen, setOrgsPopoverOpen] = useState(false); - - const api = createApiClient(useEnvContext()); - - const defaultValues = { - roleMapping: "", - orgMapping: "" - }; - - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues, - // @ts-ignore - values: policyToEdit - ? { - orgId: policyToEdit.orgId, - roleMapping: policyToEdit.roleMapping || "", - orgMapping: policyToEdit.orgMapping || "" - } - : defaultValues - }); - - async function onSubmit(values: z.infer) { - setLoading(true); - - if (policyToEdit) { - const res = await api - .post>( - `/idp/${idpId}/org/${values.orgId}`, - { - roleMapping: values.roleMapping, - orgMapping: values.orgMapping - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: "Failed to create org policy", - description: formatAxiosError( - e, - "An error occurred while updating the org policy." - ) - }); - }); - - if (res && res.status === 200) { - toast({ - variant: "default", - title: "Org policy created", - description: "The org policy has been successfully updated." - }); - - setOpen(false); - - if (afterEdit) { - afterEdit({ - orgId: values.orgId, - roleMapping: values.roleMapping ?? null, - orgMapping: values.orgMapping ?? null - }); - } - } - } else { - const res = await api - .put>( - `/idp/${idpId}/org/${values.orgId}`, - { - roleMapping: values.roleMapping, - orgMapping: values.orgMapping - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: "Failed to create role", - description: formatAxiosError( - e, - "An error occurred while creating the role." - ) - }); - }); - - if (res && res.status === 201) { - toast({ - variant: "default", - title: "Org policy created", - description: "The org policy has been successfully created." - }); - - setOpen(false); - - if (afterCreate) { - afterCreate({ - orgId: values.orgId, - roleMapping: values.roleMapping ?? null, - orgMapping: values.orgMapping ?? null - }); - } - } - } - - setLoading(false); - } - - return ( - { - setOpen(val); - setLoading(false); - setOrgsPopoverOpen(false); - form.reset(); - }} - > - - - - {policyToEdit ? "Edit" : "Create"} Organization Policy - - - Configure access for an organization - - - -
- - ( - - Organization - {policyToEdit ? ( - - ) : ( - - - - - - - - - - - - No site found. - - - {orgs.map( - (org) => { - if ( - policies.find( - ( - p - ) => - p.orgId === - org.orgId - ) - ) { - return undefined; - } - return ( - { - form.setValue( - "orgId", - org.orgId - ); - setOrgsPopoverOpen( - false - ); - }} - > - - { - org.name - } - - ); - } - )} - - - - - - )} - - - )} - /> - ( - - - Role Mapping Path (Optional) - - - - - - JMESPath to extract role information - from the ID token. The result of - this expression must return the role - name(s) as defined in the - organization as a string/list of - strings. - - - - )} - /> - ( - - - Organization Mapping Path (Optional) - - - - - - JMESPath to extract organization - information from the ID token. This - expression must return thr org ID or - true for the user to be allowed to - access the organization. - - - - )} - /> - - -
- - - - - - -
-
- ); -} diff --git a/src/app/admin/idp/[idpId]/policies/PolicyDataTable.tsx b/src/app/admin/idp/[idpId]/policies/PolicyDataTable.tsx index 73ca2ff..222e98e 100644 --- a/src/app/admin/idp/[idpId]/policies/PolicyDataTable.tsx +++ b/src/app/admin/idp/[idpId]/policies/PolicyDataTable.tsx @@ -1,3 +1,8 @@ +// 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"; @@ -6,7 +11,7 @@ import { DataTable } from "@app/components/ui/data-table"; interface DataTableProps { columns: ColumnDef[]; data: TData[]; - onAdd?: () => void; + onAdd: () => void; } export function PolicyDataTable({ @@ -18,11 +23,11 @@ export function PolicyDataTable({ ); } diff --git a/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx b/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx index f1c8fb2..df78c64 100644 --- a/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx +++ b/src/app/admin/idp/[idpId]/policies/PolicyTable.tsx @@ -1,78 +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 { ColumnDef } from "@tanstack/react-table"; +import { Button } from "@app/components/ui/button"; +import { + ArrowUpDown, + Trash2, + MoreHorizontal, + Pencil, + ArrowRight +} from "lucide-react"; +import { PolicyDataTable } from "./PolicyDataTable"; +import { Badge } from "@app/components/ui/badge"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; -import { Button } from "@app/components/ui/button"; -import { ArrowUpDown, MoreHorizontal } from "lucide-react"; -import { PolicyDataTable } from "./PolicyDataTable"; +import Link from "next/link"; +import { InfoPopup } from "@app/components/ui/info-popup"; export interface PolicyRow { orgId: string; - roleMapping: string | null; - orgMapping: string | null; + roleMapping?: string; + orgMapping?: string; } -type PolicyTableProps = { +interface Props { policies: PolicyRow[]; + onDelete: (orgId: string) => void; onAdd: () => void; - onEdit: (row: PolicyRow) => void; - onDelete: (row: PolicyRow) => void; -}; + onEdit: (policy: PolicyRow) => void; +} -export default function PolicyTable({ - policies, - onAdd, - onEdit, - onDelete -}: PolicyTableProps) { +export default function PolicyTable({ policies, onDelete, onAdd, onEdit }: Props) { const columns: ColumnDef[] = [ { - id: "actions", + id: "dots", cell: ({ row }) => { - const policyRow = row.original; + const r = row.original; return ( - <> -
- - - - - - onEdit(policyRow)} - > - Edit Policy - - onDelete(policyRow)} - > - - Delete Policy - - - - -
- + + + + + + { + onDelete(r.orgId); + }} + > + Delete + + + ); } }, { - id: "orgId", accessorKey: "orgId", header: ({ column }) => { return ( @@ -90,24 +84,74 @@ export default function PolicyTable({ }, { accessorKey: "roleMapping", - header: "Role Mapping" + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const mapping = row.original.roleMapping; + return mapping ? ( + 50 ? `${mapping.substring(0, 50)}...` : mapping} + info={mapping} + /> + ) : ( + "--" + ); + } }, { accessorKey: "orgMapping", - header: "Organization Mapping" + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const mapping = row.original.orgMapping; + return mapping ? ( + 50 ? `${mapping.substring(0, 50)}...` : mapping} + info={mapping} + /> + ) : ( + "--" + ); + } }, { - id: "edit", - cell: ({ row }) => ( -
- -
- ) + id: "actions", + cell: ({ row }) => { + const policy = row.original; + return ( +
+ +
+ ); + } } ]; diff --git a/src/app/admin/idp/[idpId]/policies/page.tsx b/src/app/admin/idp/[idpId]/policies/page.tsx index 7114011..9fb9b49 100644 --- a/src/app/admin/idp/[idpId]/policies/page.tsx +++ b/src/app/admin/idp/[idpId]/policies/page.tsx @@ -1,8 +1,27 @@ +// 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 { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; import { Button } from "@app/components/ui/button"; +import { Input } from "@app/components/ui/input"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; import { Form, FormControl, @@ -12,10 +31,33 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; import { useForm } from "react-hook-form"; -import { toast } from "@app/hooks/useToast"; -import { useRouter, useParams } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; +import { InfoIcon, ExternalLink, CheckIcon } from "lucide-react"; +import PolicyTable, { PolicyRow } from "./PolicyTable"; +import { AxiosResponse } from "axios"; +import { ListOrgsResponse } from "@server/routers/org"; +import { + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { cn } from "@app/lib/cn"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList +} from "@app/components/ui/command"; +import { CaretSortIcon } from "@radix-ui/react-icons"; +import Link from "next/link"; +import { Textarea } from "@app/components/ui/textarea"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { GetIdpResponse } from "@server/routers/idp"; import { SettingsContainer, SettingsSection, @@ -23,51 +65,64 @@ import { SettingsSectionTitle, SettingsSectionDescription, SettingsSectionBody, - SettingsSectionFooter + SettingsSectionFooter, + SettingsSectionForm } from "@app/components/Settings"; -import { formatAxiosError } from "@app/lib/api"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { useState, useEffect } from "react"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { InfoIcon, ExternalLink } from "lucide-react"; -import Link from "next/link"; -import { AxiosResponse } from "axios"; -import { - GetIdpResponse, - ListIdpOrgPoliciesResponse -} from "@server/routers/idp"; -import PolicyTable, { PolicyRow } from "./PolicyTable"; -import EditPolicyForm from "./EditPolicyForm"; -import { ListOrgsResponse } from "@server/routers/org"; -import type { Org } from "@server/db/schemas"; -const DefaultMappingsFormSchema = z.object({ +type Organization = { + orgId: string; + name: string; +}; + +const policyFormSchema = z.object({ + orgId: z.string().min(1, { message: "Organization is required" }), + roleMapping: z.string().optional(), + orgMapping: z.string().optional() +}); + +const defaultMappingsSchema = z.object({ defaultRoleMapping: z.string().optional(), defaultOrgMapping: z.string().optional() }); -type DefaultMappingsFormValues = z.infer; +type PolicyFormValues = z.infer; +type DefaultMappingsValues = z.infer; export default function PoliciesPage() { const { env } = useEnvContext(); const api = createApiClient({ env }); const router = useRouter(); const { idpId } = useParams(); - const [loading, setLoading] = useState(false); + + const [pageLoading, setPageLoading] = useState(true); + const [addPolicyLoading, setAddPolicyLoading] = useState(false); + const [editPolicyLoading, setEditPolicyLoading] = useState(false); + const [deletePolicyLoading, setDeletePolicyLoading] = useState(false); const [updateDefaultMappingsLoading, setUpdateDefaultMappingsLoading] = useState(false); const [policies, setPolicies] = useState([]); - const [editPolicyFormOpen, setEditPolicyFormOpen] = useState(false); - const [policyToEdit, setPolicyToEdit] = useState(null); - const [orgs, setOrgs] = useState([]); + const [organizations, setOrganizations] = useState([]); + const [showAddDialog, setShowAddDialog] = useState(false); + const [editingPolicy, setEditingPolicy] = useState(null); - const defaultMappingsForm = useForm({ - resolver: zodResolver(DefaultMappingsFormSchema), - defaultValues: { defaultRoleMapping: "", defaultOrgMapping: "" } + const form = useForm({ + resolver: zodResolver(policyFormSchema), + defaultValues: { + orgId: "", + roleMapping: "", + orgMapping: "" + } }); - async function loadIdp() { + const defaultMappingsForm = useForm({ + resolver: zodResolver(defaultMappingsSchema), + defaultValues: { + defaultRoleMapping: "", + defaultOrgMapping: "" + } + }); + + const loadIdp = async () => { try { const res = await api.get>( `/idp/${idpId}` @@ -86,13 +141,11 @@ export default function PoliciesPage() { variant: "destructive" }); } - } + }; - async function loadIdpOrgPolicies() { + const loadPolicies = async () => { try { - const res = await api.get< - AxiosResponse - >(`/idp/${idpId}/org`); + const res = await api.get(`/idp/${idpId}/org`); if (res.status === 200) { setPolicies(res.data.data.policies); } @@ -103,13 +156,17 @@ export default function PoliciesPage() { variant: "destructive" }); } - } + }; - async function loadOrgs() { + const loadOrganizations = async () => { try { - const res = await api.get>(`/orgs`); + const res = await api.get>("/orgs"); if (res.status === 200) { - setOrgs(res.data.data.orgs); + const existingOrgIds = policies.map((p) => p.orgId); + const availableOrgs = res.data.data.orgs.filter( + (org) => !existingOrgIds.includes(org.orgId) + ); + setOrganizations(availableOrgs); } } catch (e) { toast({ @@ -118,19 +175,121 @@ export default function PoliciesPage() { variant: "destructive" }); } - } + }; useEffect(() => { - const load = async () => { - setLoading(true); - await Promise.all([loadIdp(), loadIdpOrgPolicies(), loadOrgs()]); - setLoading(false); - }; - + async function load() { + setPageLoading(true); + await loadPolicies(); + await loadIdp(); + setPageLoading(false); + } load(); - }, [idpId, api, router]); + }, [idpId]); - async function onDefaultMappingsSubmit(data: DefaultMappingsFormValues) { + const onAddPolicy = async (data: PolicyFormValues) => { + setAddPolicyLoading(true); + try { + const res = await api.put(`/idp/${idpId}/org/${data.orgId}`, { + roleMapping: data.roleMapping, + orgMapping: data.orgMapping + }); + if (res.status === 201) { + const newPolicy = { + orgId: data.orgId, + name: + organizations.find((org) => org.orgId === data.orgId) + ?.name || "", + roleMapping: data.roleMapping, + orgMapping: data.orgMapping + }; + setPolicies([...policies, newPolicy]); + toast({ + title: "Success", + description: "Policy added successfully" + }); + setShowAddDialog(false); + form.reset(); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setAddPolicyLoading(false); + } + }; + + const onEditPolicy = async (data: PolicyFormValues) => { + if (!editingPolicy) return; + + setEditPolicyLoading(true); + try { + const res = await api.post( + `/idp/${idpId}/org/${editingPolicy.orgId}`, + { + roleMapping: data.roleMapping, + orgMapping: data.orgMapping + } + ); + if (res.status === 200) { + setPolicies( + policies.map((policy) => + policy.orgId === editingPolicy.orgId + ? { + ...policy, + roleMapping: data.roleMapping, + orgMapping: data.orgMapping + } + : policy + ) + ); + toast({ + title: "Success", + description: "Policy updated successfully" + }); + setShowAddDialog(false); + setEditingPolicy(null); + form.reset(); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setEditPolicyLoading(false); + } + }; + + const onDeletePolicy = async (orgId: string) => { + setDeletePolicyLoading(true); + try { + const res = await api.delete(`/idp/${idpId}/org/${orgId}`); + if (res.status === 200) { + setPolicies( + policies.filter((policy) => policy.orgId !== orgId) + ); + toast({ + title: "Success", + description: "Policy deleted successfully" + }); + } + } catch (e) { + toast({ + title: "Error", + description: formatAxiosError(e), + variant: "destructive" + }); + } finally { + setDeletePolicyLoading(false); + } + }; + + const onUpdateDefaultMappings = async (data: DefaultMappingsValues) => { setUpdateDefaultMappingsLoading(true); try { const res = await api.post(`/idp/${idpId}/oidc`, { @@ -152,60 +311,26 @@ export default function PoliciesPage() { } finally { setUpdateDefaultMappingsLoading(false); } - } + }; - // Button clicks - - function onAdd() { - setPolicyToEdit(null); - setEditPolicyFormOpen(true); - } - function onEdit(row: PolicyRow) { - setPolicyToEdit(row); - setEditPolicyFormOpen(true); - } - function onDelete(row: PolicyRow) { - api.delete(`/idp/${idpId}/org/${row.orgId}`) - .then((res) => { - if (res.status === 200) { - toast({ - title: "Success", - description: "Org policy deleted successfully" - }); - const p2 = policies.filter((p) => p.orgId !== row.orgId); - setPolicies(p2); - } - }) - .catch((e) => { - toast({ - title: "Error", - description: formatAxiosError(e), - variant: "destructive" - }); - }); - } - - function afterCreate(row: PolicyRow) { - setPolicies([...policies, row]); - } - - function afterEdit(row: PolicyRow) { - const p2 = policies.map((p) => (p.orgId === row.orgId ? row : p)); - setPolicies(p2); + if (pageLoading) { + return null; } return ( <> - + About Organization Policies - Organization policies are used to configure access - control for a specific organization based on the user's - ID token. For more information, see{" "} + Organization policies are used to control access to + organizations based on the user's ID token. You can + specify JMESPath expressions to extract role and + organization information from the ID token. For more + information, see{" "} - The default mappings are used when there is no - organization policy defined for an organization. You - can specify the default role and organization + The default mappings are used when when there is not + an organization policy defined for an organization. + You can specify the default role and organization mappings to fall back to here. @@ -234,10 +359,10 @@ export default function PoliciesPage() {
)} /> + - - { + loadOrganizations(); + form.reset({ + orgId: "", + roleMapping: "", + orgMapping: "" + }); + setEditingPolicy(null); + setShowAddDialog(true); + }} + onEdit={(policy) => { + setEditingPolicy(policy); + form.reset({ + orgId: policy.orgId, + roleMapping: policy.roleMapping || "", + orgMapping: policy.orgMapping || "" + }); + setShowAddDialog(true); + }} /> + + { + setShowAddDialog(val); + setEditingPolicy(null); + form.reset(); + }} + > + + + + {editingPolicy + ? "Edit Organization Policy" + : "Add Organization Policy"} + + + Configure access for an organization + + + + + + ( + + Organization + {editingPolicy ? ( + + ) : ( + + + + + + + + + + + + No org + found. + + + {organizations.map( + ( + org + ) => ( + { + form.setValue( + "orgId", + org.orgId + ); + }} + > + + { + org.name + } + + ) + )} + + + + + + )} + + + )} + /> + + ( + + + Role Mapping Path (Optional) + + + + + + JMESPath to extract role + information from the ID token. + The result of this expression + must return the role name as + defined in the organization as a + string. + + + + )} + /> + + ( + + + Organization Mapping Path + (Optional) + + + + + + JMESPath to extract organization + information from the ID token. + This expression must return the + org ID or true for the user to + be allowed to access the + organization. + + + + )} + /> + + + + + + + + + + + ); } diff --git a/src/app/admin/idp/create/page.tsx b/src/app/admin/idp/create/page.tsx index 58e6667..034cc69 100644 --- a/src/app/admin/idp/create/page.tsx +++ b/src/app/admin/idp/create/page.tsx @@ -36,6 +36,7 @@ import { InfoIcon, ExternalLink } from "lucide-react"; import { StrategySelect } from "@app/components/StrategySelect"; import { SwitchInput } from "@app/components/SwitchInput"; import { Badge } from "@app/components/ui/badge"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; const createIdpFormSchema = z.object({ 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 router = useRouter(); const [createLoading, setCreateLoading] = useState(false); + const { isUnlocked } = useLicenseStatusContext(); const form = useForm({ resolver: zodResolver(createIdpFormSchema), @@ -190,6 +192,7 @@ export default function Page() { defaultChecked={form.getValues( "autoProvision" )} + disabled={!isUnlocked()} onCheckedChange={(checked) => { form.setValue( "autoProvision", @@ -197,6 +200,14 @@ export default function Page() { ); }} /> + {!isUnlocked() && ( + + Professional + + )}
When enabled, users will be diff --git a/src/app/admin/license/LicenseKeysDataTable.tsx b/src/app/admin/license/LicenseKeysDataTable.tsx new file mode 100644 index 0000000..98ed814 --- /dev/null +++ b/src/app/admin/license/LicenseKeysDataTable.tsx @@ -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: LicenseKeyCache) => 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[] = [ + { + accessorKey: "licenseKey", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const licenseKey = row.original.licenseKey; + return ( + + ); + } + }, + { + accessorKey: "valid", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + return row.original.valid ? "Yes" : "No"; + } + }, + { + accessorKey: "type", + header: ({ column }) => { + return ( + + ); + }, + 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 ? ( + {label} + ) : null; + } + }, + { + accessorKey: "numSites", + header: ({ column }) => { + return ( + + ); + } + }, + { + id: "delete", + cell: ({ row }) => ( +
+ +
+ ) + } + ]; + + return ( + + ); +} diff --git a/src/app/admin/license/components/SitePriceCalculator.tsx b/src/app/admin/license/components/SitePriceCalculator.tsx new file mode 100644 index 0000000..cf771b5 --- /dev/null +++ b/src/app/admin/license/components/SitePriceCalculator.tsx @@ -0,0 +1,166 @@ +// 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(3); + const pricePerSite = 5; + const licenseFlatRate = 125; + + const incrementSites = () => { + setSiteCount((prev) => prev + 1); + }; + + const decrementSites = () => { + setSiteCount((prev) => (prev > 1 ? prev - 1 : 1)); + }; + + function continueToPayment() { + if (mode === "license") { + // open in new tab + window.open( + `https://payment.fossorial.io/buy/dab98d3d-9976-49b1-9e55-1580059d833f?quantity=${siteCount}`, + "_blank" + ); + } else { + window.open( + `https://payment.fossorial.io/buy/2b881c36-ea5d-4c11-8652-9be6810a054f?quantity=${siteCount}`, + "_blank" + ); + } + } + + const totalCost = + mode === "license" + ? licenseFlatRate + siteCount * pricePerSite + : siteCount * pricePerSite; + + return ( + + + + + {mode === "license" + ? "Purchase License" + : "Purchase Additional Sites"} + + + Choose how many sites you want to{" "} + {mode === "license" + ? "purchase a license for. You can always add more sites later." + : "add to your existing license."} + + + +
+
+
+ Number of Sites +
+
+ + + {siteCount} + + +
+
+ +
+ {mode === "license" && ( +
+ + License fee: + + + ${licenseFlatRate.toFixed(2)} + +
+ )} +
+ + Price per site: + + + ${pricePerSite.toFixed(2)} + +
+
+ + Number of sites: + + {siteCount} +
+
+ Total: + ${totalCost.toFixed(2)} / mo +
+ +

+ For the most up-to-date pricing and discounts, + please visit the{" "} + + pricing page + + . +

+
+
+
+ + + + + + +
+
+ ); +} diff --git a/src/app/admin/license/page.tsx b/src/app/admin/license/page.tsx new file mode 100644 index 0000000..a967889 --- /dev/null +++ b/src/app/admin/license/page.tsx @@ -0,0 +1,528 @@ +// 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"; +import Link from "next/link"; +import { Checkbox } from "@app/components/ui/checkbox"; + +const formSchema = z.object({ + licenseKey: z + .string() + .nonempty({ message: "License key is required" }) + .max(255), + agreeToTerms: z.boolean().refine((val) => val === true, { + message: "You must agree to the license terms" + }) +}); + +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([]); + const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedLicenseKey, setSelectedLicenseKey] = + useState(null); + const router = useRouter(); + const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext(); + const [hostLicense, setHostLicense] = useState(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>({ + resolver: zodResolver(formSchema), + defaultValues: { + licenseKey: "", + agreeToTerms: false + } + }); + + useEffect(() => { + async function load() { + setIsInitialLoading(true); + await loadLicenseKeys(); + setIsInitialLoading(false); + } + load(); + }, []); + + async function loadLicenseKeys() { + try { + const response = + await api.get>( + "/license/keys" + ); + const keys = response.data.data; + setRows(keys); + const hostKey = keys.find((key) => key.type === "HOST"); + 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 encodedKey = encodeURIComponent(key); + const res = await api.delete(`/license/${encodedKey}`); + 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) { + 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 ( + <> + { + setIsPurchaseModalOpen(val); + }} + mode={purchaseMode} + /> + + { + setIsCreateModalOpen(val); + form.reset(); + }} + > + + + Activate License Key + + Enter a license key to activate it. + + + +
+ + ( + + License Key + + + + + + )} + /> + ( + + + + +
+ + By checking this box, you + confirm that you have read + and agree to the license + terms corresponding to the + tier associated with your + license key. +
+ + View Fossorial + Commercial License & + Subscription Terms + +
+ +
+
+ )} + /> + + +
+ + + + + + +
+
+ + {selectedLicenseKey && ( + { + setIsDeleteModalOpen(val); + setSelectedLicenseKey(null); + }} + dialog={ +
+

+ Are you sure you want to delete the license key{" "} + + {obfuscateLicenseKey( + selectedLicenseKey.licenseKey + )} + + ? +

+

+ + This will remove the license key and all + associated permissions granted by it. + +

+

+ To confirm, please type the license key below. +

+
+ } + buttonText="Confirm Delete License Key" + onConfirm={async () => + deleteLicenseKey(selectedLicenseKey.licenseKeyEncrypted) + } + string={selectedLicenseKey.licenseKey} + title="Delete License Key" + /> + )} + + + + + + + + Host License + + Manage the main license key for the host. + + +
+
+ {licenseStatus?.isLicenseValid ? ( +
+
+ + {licenseStatus?.tier === + "PROFESSIONAL" + ? "Professional License" + : licenseStatus?.tier === + "ENTERPRISE" + ? "Enterprise License" + : "Licensed"} +
+
+ ) : ( +
+
+ Not Licensed +
+
+ )} +
+ {licenseStatus?.hostId && ( +
+
+ Host ID +
+ +
+ )} + {hostLicense && ( +
+
+ License Key +
+ +
+ )} +
+ + + +
+ + + Sites Usage + + View the number of sites using this license. + + +
+
+
+ {licenseStatus?.usedSites || 0}{" "} + {licenseStatus?.usedSites === 1 + ? "site" + : "sites"}{" "} + in system +
+
+ {!licenseStatus?.isHostLicensed && ( +

+ There is no limit on the number of sites + using an unlicensed host. +

+ )} + {licenseStatus?.maxSites && ( +
+
+ + {licenseStatus.usedSites || 0} of{" "} + {licenseStatus.maxSites} sites used + + + {Math.round( + ((licenseStatus.usedSites || + 0) / + licenseStatus.maxSites) * + 100 + )} + % + +
+ +
+ )} +
+ + {!licenseStatus?.isHostLicensed ? ( + <> + + + ) : ( + <> + + + )} + +
+
+ { + setSelectedLicenseKey(key); + setIsDeleteModalOpen(true); + }} + onCreate={() => setIsCreateModalOpen(true)} + /> +
+ + ); +} diff --git a/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx b/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx index c946869..87a7683 100644 --- a/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx +++ b/src/app/auth/idp/[idpId]/oidc/callback/ValidateOidcToken.tsx @@ -15,6 +15,7 @@ import { } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Loader2, CheckCircle2, AlertCircle } from "lucide-react"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; type ValidateOidcTokenParams = { orgId: string; @@ -33,6 +34,8 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const { licenseStatus, isLicenseViolation } = useLicenseStatusContext(); + useEffect(() => { async function validate() { setLoading(true); @@ -43,6 +46,10 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) { stateCookie: props.stateCookie }); + if (isLicenseViolation()) { + await new Promise((resolve) => setTimeout(resolve, 5000)); + } + try { const res = await api.post< AxiosResponse diff --git a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx index f7d726e..c7eca2c 100644 --- a/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx +++ b/src/app/auth/resource/[resourceId]/ResourceAuthPortal.tsx @@ -43,6 +43,7 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import Link from "next/link"; +import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext"; const pinSchema = z.object({ pin: z @@ -109,6 +110,8 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { const api = createApiClient({ env }); + const { supporterStatus } = useSupporterStatusContext(); + function getDefaultSelectedMethod() { if (props.methods.sso) { return "sso"; @@ -631,6 +634,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { + {supporterStatus?.visible && ( +
+ + Server is running without a supporter key. +
+ Consider supporting the project! +
+
+ )} ) : ( diff --git a/src/app/components/LicenseViolation.tsx b/src/app/components/LicenseViolation.tsx new file mode 100644 index 0000000..75d544d --- /dev/null +++ b/src/app/components/LicenseViolation.tsx @@ -0,0 +1,67 @@ +// 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 { Button } from "@app/components/ui/button"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import { useState } from "react"; + +export default function LicenseViolation() { + const { licenseStatus } = useLicenseStatusContext(); + const [isDismissed, setIsDismissed] = useState(false); + + if (!licenseStatus || isDismissed) return null; + + // Show invalid license banner + if (licenseStatus.isHostLicensed && !licenseStatus.isLicenseValid) { + return ( +
+
+

+ Invalid or expired license keys detected. Follow license + terms to continue using all features. +

+ +
+
+ ); + } + + // Show usage violation banner + if ( + licenseStatus.maxSites && + licenseStatus.usedSites && + licenseStatus.usedSites > licenseStatus.maxSites + ) { + return ( +
+
+

+ License Violation: This server is using{" "} + {licenseStatus.usedSites} sites which exceeds its + licensed limit of {licenseStatus.maxSites} sites. Follow + license terms to continue using all features. +

+ +
+
+ ); + } + + return null; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index d99c026..e0089bc 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,6 +5,13 @@ import { Toaster } from "@/components/ui/toaster"; import { ThemeProvider } from "@app/providers/ThemeProvider"; import EnvProvider from "@app/providers/EnvProvider"; import { pullEnv } from "@app/lib/pullEnv"; +import SupportStatusProvider from "@app/providers/SupporterStatusProvider"; +import { priv } from "@app/lib/api"; +import { AxiosResponse } from "axios"; +import { IsSupporterKeyVisibleResponse } from "@server/routers/supporterKey"; +import LicenseStatusProvider from "@app/providers/LicenseStatusProvider"; +import { GetLicenseStatusResponse } from "@server/routers/license"; +import LicenseViolation from "./components/LicenseViolation"; export const metadata: Metadata = { title: `Dashboard - Pangolin`, @@ -23,6 +30,22 @@ export default async function RootLayout({ }>) { const env = pullEnv(); + let supporterData = { + visible: true + } as any; + + const res = await priv.get>( + "supporter-key/visible" + ); + supporterData.visible = res.data.data.visible; + supporterData.tier = res.data.data.tier; + + const licenseStatusRes = + await priv.get>( + "/license/status" + ); + const licenseStatus = licenseStatusRes.data.data; + return ( @@ -33,12 +56,19 @@ export default async function RootLayout({ disableTransitionOnChange > - {/* Main content */} -
-
- {children} -
-
+ + + {/* Main content */} +
+
+ + {children} +
+
+
+
diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 821f12c..b05bf30 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -65,14 +65,12 @@ export const orgNavItems: SidebarNavItem[] = [ href: "/{orgId}/settings/share-links", icon: }, - /* - TODO: { title: "API Keys", href: "/{orgId}/settings/api-keys", icon: , + showProfessional: true }, - */ { title: "Settings", href: "/{orgId}/settings/general", @@ -86,17 +84,20 @@ export const adminNavItems: SidebarNavItem[] = [ href: "/admin/users", icon: }, - /* - TODO: { title: "API Keys", href: "/admin/api-keys", icon: , + showProfessional: true }, - */ { title: "Identity Providers", href: "/admin/idp", icon: + }, + { + title: "License", + href: "/admin/license", + icon: } ]; diff --git a/src/components/HorizontalTabs.tsx b/src/components/HorizontalTabs.tsx index 5d17fe3..eb590eb 100644 --- a/src/components/HorizontalTabs.tsx +++ b/src/components/HorizontalTabs.tsx @@ -4,11 +4,15 @@ import React from "react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; import { cn } from "@app/lib/cn"; +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 { @@ -24,6 +28,7 @@ export function HorizontalTabs({ }: HorizontalTabsProps) { const pathname = usePathname(); const params = useParams(); + const { licenseStatus, isUnlocked } = useLicenseStatusContext(); function hydrateHref(href: string) { return href @@ -44,34 +49,46 @@ export function HorizontalTabs({ const isActive = pathname.startsWith(hydratedHref) && !pathname.includes("create"); + const isProfessional = + item.showProfessional && !isUnlocked(); + const isDisabled = + disabled || (isProfessional && !isUnlocked()); return ( { - if (disabled) { + if (isDisabled) { e.preventDefault(); } }} - tabIndex={disabled ? -1 : undefined} - aria-disabled={disabled} + tabIndex={isDisabled ? -1 : undefined} + aria-disabled={isDisabled} >
{item.icon && item.icon} {item.title} + {isProfessional && ( + + Professional + + )}
); diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index ad008d7..12cb09d 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -5,6 +5,7 @@ import { SidebarNav } from "@app/components/SidebarNav"; import { OrgSelector } from "@app/components/OrgSelector"; import { cn } from "@app/lib/cn"; import { ListUserOrgsResponse } from "@server/routers/org"; +import SupporterStatus from "@app/components/SupporterStatus"; import { Button } from "@app/components/ui/button"; import { ExternalLink, Menu, X, Server } from "lucide-react"; import Image from "next/image"; @@ -117,6 +118,7 @@ export function Layout({ )}
+
+
diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx new file mode 100644 index 0000000..a6f9add --- /dev/null +++ b/src/components/PermissionsSelectBox.tsx @@ -0,0 +1,235 @@ +// 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 { CheckboxWithLabel } from "@app/components/ui/checkbox"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; + +type PermissionsSelectBoxProps = { + root?: boolean; + selectedPermissions: Record; + onChange: (updated: Record) => void; +}; + +function getActionsCategories(root: boolean) { + const actionsByCategory: Record> = { + Organization: { + "Get Organization": "getOrg", + "Update Organization": "updateOrg", + "Get Organization User": "getOrgUser", + "List Organization Domains": "listOrgDomains", + "Check Org ID": "checkOrgId", + }, + + Site: { + "Create Site": "createSite", + "Delete Site": "deleteSite", + "Get Site": "getSite", + "List Sites": "listSites", + "Update Site": "updateSite", + "List Allowed Site Roles": "listSiteRoles" + }, + + Resource: { + "Create Resource": "createResource", + "Delete Resource": "deleteResource", + "Get Resource": "getResource", + "List Resources": "listResources", + "Update Resource": "updateResource", + "List Resource Users": "listResourceUsers", + "Set Resource Users": "setResourceUsers", + "Set Allowed Resource Roles": "setResourceRoles", + "List Allowed Resource Roles": "listResourceRoles", + "Set Resource Password": "setResourcePassword", + "Set Resource Pincode": "setResourcePincode", + "Set Resource Email Whitelist": "setResourceWhitelist", + "Get Resource Email Whitelist": "getResourceWhitelist" + }, + + Target: { + "Create Target": "createTarget", + "Delete Target": "deleteTarget", + "Get Target": "getTarget", + "List Targets": "listTargets", + "Update Target": "updateTarget" + }, + + Role: { + "Create Role": "createRole", + "Delete Role": "deleteRole", + "Get Role": "getRole", + "List Roles": "listRoles", + "Update Role": "updateRole", + "List Allowed Role Resources": "listRoleResources" + }, + + User: { + "Invite User": "inviteUser", + "Remove User": "removeUser", + "List Users": "listUsers", + "Add User Role": "addUserRole" + }, + + "Access Token": { + "Generate Access Token": "generateAccessToken", + "Delete Access Token": "deleteAcessToken", + "List Access Tokens": "listAccessTokens" + }, + + "Resource Rule": { + "Create Resource Rule": "createResourceRule", + "Delete Resource Rule": "deleteResourceRule", + "List Resource Rules": "listResourceRules", + "Update Resource Rule": "updateResourceRule" + } + }; + + if (root) { + actionsByCategory["Organization"] = { + "List Organizations": "listOrgs", + "Check ID": "checkOrgId", + "Create Organization": "createOrg", + "Delete Organization": "deleteOrg", + "List API Keys": "listApiKeys", + "List API Key Actions": "listApiKeyActions", + "Set API Key Allowed Actions": "setApiKeyActions", + "Create API Key": "createApiKey", + "Delete API Key": "deleteApiKey", + ...actionsByCategory["Organization"] + }; + + actionsByCategory["Identity Provider (IDP)"] = { + "Create IDP": "createIdp", + "Update IDP": "updateIdp", + "Delete IDP": "deleteIdp", + "List IDP": "listIdps", + "Get IDP": "getIdp", + "Create IDP Org Policy": "createIdpOrg", + "Delete IDP Org Policy": "deleteIdpOrg", + "List IDP Orgs": "listIdpOrgs", + "Update IDP Org": "updateIdpOrg" + }; + } + + return actionsByCategory; +} + +export default function PermissionsSelectBox({ + root, + selectedPermissions, + onChange +}: PermissionsSelectBoxProps) { + const actionsByCategory = getActionsCategories(root ?? false); + + const togglePermission = (key: string, checked: boolean) => { + onChange({ + ...selectedPermissions, + [key]: checked + }); + }; + + const areAllCheckedInCategory = (actions: Record) => { + return Object.values(actions).every( + (action) => selectedPermissions[action] + ); + }; + + const toggleAllInCategory = ( + actions: Record, + value: boolean + ) => { + const updated = { ...selectedPermissions }; + Object.values(actions).forEach((action) => { + updated[action] = value; + }); + onChange(updated); + }; + + const allActions = Object.values(actionsByCategory).flatMap(Object.values); + const allPermissionsChecked = allActions.every( + (action) => selectedPermissions[action] + ); + + const toggleAllPermissions = (checked: boolean) => { + const updated: Record = {}; + allActions.forEach((action) => { + updated[action] = checked; + }); + onChange(updated); + }; + + return ( + <> +
+ + toggleAllPermissions(checked as boolean) + } + /> +
+ + {Object.entries(actionsByCategory).map( + ([category, actions]) => { + const allChecked = areAllCheckedInCategory(actions); + return ( + + {category} + +
+ + toggleAllInCategory( + actions, + checked as boolean + ) + } + /> + {Object.entries(actions).map( + ([label, value]) => ( + + togglePermission( + value, + checked as boolean + ) + } + /> + ) + )} +
+
+
+ ); + } + )} +
+ + ); +} diff --git a/src/components/ProfessionalContentOverlay.tsx b/src/components/ProfessionalContentOverlay.tsx new file mode 100644 index 0000000..cd484a2 --- /dev/null +++ b/src/components/ProfessionalContentOverlay.tsx @@ -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 ( +
+ {isProfessional && ( +
+
+

+ Professional Edition Required +

+

+ This feature is only available in the Professional + Edition. +

+
+
+ )} + {children} +
+ ); +} diff --git a/src/components/ProfileIcon.tsx b/src/components/ProfileIcon.tsx index aa43578..55b939f 100644 --- a/src/components/ProfileIcon.tsx +++ b/src/components/ProfileIcon.tsx @@ -14,13 +14,14 @@ import { import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; -import { Laptop, Moon, Sun } from "lucide-react"; +import { Laptop, LogOut, Moon, Sun } from "lucide-react"; import { useTheme } from "next-themes"; import { useRouter } from "next/navigation"; import { useState } from "react"; import { useUserContext } from "@app/hooks/useUserContext"; import Disable2FaForm from "./Disable2FaForm"; import Enable2FaForm from "./Enable2FaForm"; +import SupporterStatus from "./SupporterStatus"; import { UserType } from "@server/types/UserTypes"; export default function ProfileIcon() { diff --git a/src/components/SidebarNav.tsx b/src/components/SidebarNav.tsx index 1df7f71..d6de961 100644 --- a/src/components/SidebarNav.tsx +++ b/src/components/SidebarNav.tsx @@ -6,6 +6,8 @@ import { useParams, usePathname } from "next/navigation"; import { cn } from "@app/lib/cn"; import { ChevronDown, ChevronRight } from "lucide-react"; import { useUserContext } from "@app/hooks/useUserContext"; +import { Badge } from "@app/components/ui/badge"; +import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; export interface SidebarNavItem { href: string; @@ -13,6 +15,7 @@ export interface SidebarNavItem { icon?: React.ReactNode; children?: SidebarNavItem[]; autoExpand?: boolean; + showProfessional?: boolean; } export interface SidebarNavProps extends React.HTMLAttributes { @@ -58,6 +61,7 @@ export function SidebarNav({ findAutoExpandedAndActivePath(items); return autoExpanded; }); + const { licenseStatus, isUnlocked } = useLicenseStatusContext(); const { user } = useUserContext(); @@ -88,6 +92,8 @@ export function SidebarNav({ const hasChildren = item.children && item.children.length > 0; const isExpanded = expandedItems.has(hydratedHref); const indent = level * 28; // Base indent for each level + const isProfessional = item.showProfessional && !isUnlocked(); + const isDisabled = disabled || isProfessional; return (
@@ -102,28 +108,28 @@ export function SidebarNav({ )} > { - if (disabled) { + if (isDisabled) { e.preventDefault(); } else if (onItemClick) { onItemClick(); } }} - tabIndex={disabled ? -1 : undefined} - aria-disabled={disabled} + tabIndex={isDisabled ? -1 : undefined} + aria-disabled={isDisabled} >
{item.icon && ( @@ -133,12 +139,20 @@ export function SidebarNav({ )} {item.title}
+ {isProfessional && ( + + Professional + + )} {hasChildren && ( + + + + + + + Limited Supporter + + +

$25

+
    +
  • + + + For 5 or less users + +
  • +
  • + + + Lifetime purchase + +
  • +
  • + + + Supporter status + +
  • +
+
+ + {supporterStatus?.tier !== + "Limited Supporter" ? ( + + + + ) : ( + + )} + +
+
+
+ +
+ + +
+ + + + + + + + + + { + setKeyOpen(val); + }} + > + + + Enter Supporter Key + + Meet your very own pet Pangolin! + + + +
+ + ( + + + GitHub Username + + + + + + + )} + /> + ( + + Supporter Key + + + + + + )} + /> + + +
+ + + + + + +
+
+ + {supporterStatus?.visible ? ( + + ) : null} + + ); +} diff --git a/src/contexts/apiKeyContext.ts b/src/contexts/apiKeyContext.ts new file mode 100644 index 0000000..dd6c9b8 --- /dev/null +++ b/src/contexts/apiKeyContext.ts @@ -0,0 +1,16 @@ +// 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 { GetApiKeyResponse } from "@server/routers/apiKeys"; +import { createContext } from "react"; + +interface ApiKeyContextType { + apiKey: GetApiKeyResponse; + updateApiKey: (updatedApiKey: Partial) => void; +} + +const ApiKeyContext = createContext(undefined); + +export default ApiKeyContext; diff --git a/src/contexts/licenseStatusContext.ts b/src/contexts/licenseStatusContext.ts new file mode 100644 index 0000000..eca6357 --- /dev/null +++ b/src/contexts/licenseStatusContext.ts @@ -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; diff --git a/src/contexts/supporterStatusContext.ts b/src/contexts/supporterStatusContext.ts new file mode 100644 index 0000000..c5c8454 --- /dev/null +++ b/src/contexts/supporterStatusContext.ts @@ -0,0 +1,17 @@ +import { createContext } from "react"; + +export type SupporterStatus = { + visible: boolean; + tier?: string; +}; + +type SupporterStatusContextType = { + supporterStatus: SupporterStatus | null; + updateSupporterStatus: (updatedSite: Partial) => void; +}; + +const SupporterStatusContext = createContext< + SupporterStatusContextType | undefined +>(undefined); + +export default SupporterStatusContext; diff --git a/src/hooks/useApikeyContext.ts b/src/hooks/useApikeyContext.ts new file mode 100644 index 0000000..3ebcbdd --- /dev/null +++ b/src/hooks/useApikeyContext.ts @@ -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 ApiKeyContext from "@app/contexts/apiKeyContext"; +import { useContext } from "react"; + +export function useApiKeyContext() { + const context = useContext(ApiKeyContext); + if (context === undefined) { + throw new Error( + "useApiKeyContext must be used within a ApiKeyProvider" + ); + } + return context; +} diff --git a/src/hooks/useLicenseStatusContext.ts b/src/hooks/useLicenseStatusContext.ts new file mode 100644 index 0000000..b1da343 --- /dev/null +++ b/src/hooks/useLicenseStatusContext.ts @@ -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; +} diff --git a/src/hooks/useSupporterStatusContext.ts b/src/hooks/useSupporterStatusContext.ts new file mode 100644 index 0000000..359b401 --- /dev/null +++ b/src/hooks/useSupporterStatusContext.ts @@ -0,0 +1,12 @@ +import SupporterStatusContext from "@app/contexts/supporterStatusContext"; +import { useContext } from "react"; + +export function useSupporterStatusContext() { + const context = useContext(SupporterStatusContext); + if (context === undefined) { + throw new Error( + "useSupporterStatusContext must be used within an SupporterStatusProvider" + ); + } + return context; +} diff --git a/src/providers/ApiKeyProvider.tsx b/src/providers/ApiKeyProvider.tsx new file mode 100644 index 0000000..13061da --- /dev/null +++ b/src/providers/ApiKeyProvider.tsx @@ -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 ApiKeyContext from "@app/contexts/apiKeyContext"; +import { GetApiKeyResponse } from "@server/routers/apiKeys"; +import { useState } from "react"; + +interface ApiKeyProviderProps { + children: React.ReactNode; + apiKey: GetApiKeyResponse; +} + +export function ApiKeyProvider({ children, apiKey: ak }: ApiKeyProviderProps) { + const [apiKey, setApiKey] = useState(ak); + + const updateApiKey = (updatedApiKey: Partial) => { + if (!apiKey) { + throw new Error("No API key to update"); + } + setApiKey((prev) => { + if (!prev) { + return prev; + } + return { + ...prev, + ...updatedApiKey + }; + }); + }; + + return ( + + {children} + + ); +} + +export default ApiKeyProvider; diff --git a/src/providers/LicenseStatusProvider.tsx b/src/providers/LicenseStatusProvider.tsx new file mode 100644 index 0000000..c3fe968 --- /dev/null +++ b/src/providers/LicenseStatusProvider.tsx @@ -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); + + 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 ( + + {children} + + ); +} + +export default LicenseStatusProvider; diff --git a/src/providers/SupporterStatusProvider.tsx b/src/providers/SupporterStatusProvider.tsx new file mode 100644 index 0000000..bcb8be2 --- /dev/null +++ b/src/providers/SupporterStatusProvider.tsx @@ -0,0 +1,46 @@ +"use client"; + +import SupportStatusContext, { + SupporterStatus +} from "@app/contexts/supporterStatusContext"; +import { useState } from "react"; + +interface ProviderProps { + children: React.ReactNode; + supporterStatus: SupporterStatus | null; +} + +export function SupporterStatusProvider({ + children, + supporterStatus +}: ProviderProps) { + const [supporterStatusState, setSupporterStatusState] = + useState(supporterStatus); + + const updateSupporterStatus = ( + updatedSupporterStatus: Partial + ) => { + setSupporterStatusState((prev) => { + if (!prev) { + return updatedSupporterStatus as SupporterStatus; + } + return { + ...prev, + ...updatedSupporterStatus + }; + }); + }; + + return ( + + {children} + + ); +} + +export default SupporterStatusProvider;