Compare commits

..

No commits in common. "oss" and "main" have entirely different histories.
oss ... main

88 changed files with 3375 additions and 1293 deletions

View file

@ -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

View file

@ -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

BIN
newt Executable file

Binary file not shown.

529
package-lock.json generated
View file

@ -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",

View file

@ -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 userRoleIds is not available on the request, fetch it
if (userRoleIds === undefined) {
const userOrgRoles = await db
.select({ roleId: userOrgs.roleId })
// If userOrgRoleId is not available on the request, fetch it
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);

View file

@ -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<boolean> {
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);

View file

@ -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<typeof resourceWhitelist>;
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
export type ResourceRule = InferSelectModel<typeof resourceRules>;
export type Domain = InferSelectModel<typeof domains>;
export type SupporterKey = InferSelectModel<typeof supporterKey>;
export type Idp = InferSelectModel<typeof idp>;
export type IdpOrg = InferSelectModel<typeof idpOrg>;
export type ApiKey = InferSelectModel<typeof apiKeys>;
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;

View file

@ -38,7 +38,7 @@ declare global {
session?: Session;
userOrg?: UserOrg;
apiKeyOrg?: ApiKeyOrg;
userRoleIds?: number[];
userOrgRoleId?: number;
userOrgId?: string;
userOrgIds?: string[];
}

View file

@ -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<boolean> {
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);

View file

@ -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);
@ -221,6 +225,10 @@ const configSchema = z.object({
export class Config {
private rawConfig!: z.infer<typeof configSchema>;
supporterData: SupporterKey | null = null;
supporterHiddenUntil: number | null = null;
isDev: boolean = process.env.ENVIRONMENT !== "prod";
constructor() {
@ -309,9 +317,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;
}
@ -325,6 +344,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();

488
server/license/license.ts Normal file
View file

@ -0,0 +1,488 @@
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<LicenseKeyCache>(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<boolean> {
const status = await this.check();
if (status.isHostLicensed) {
if (status.isLicenseValid) {
return true;
}
}
return false;
}
public async check(): Promise<LicenseStatus> {
// Set used sites
const [siteCount] = await db
.select({
value: count()
})
.from(sites);
const status: LicenseStatus = {
hostId: this.hostId,
isHostLicensed: true,
isLicenseValid: false,
maxSites: undefined,
usedSites: 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<TokenPayload>(
decryptedToken,
this.publicKey
);
this.licenseKeyCache.set<LicenseKeyCache>(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<LicenseKeyCache>(
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<LicenseKeyCache>(
key.licenseKey
)!;
const licenseKeyRes =
apiResponse?.data?.licenseKeys[key.licenseKey];
if (!apiResponse || !licenseKeyRes) {
logger.debug(
`No response from server for license key: ${key.licenseKey}`
);
if (cached.iat) {
const exp = moment(cached.iat)
.add(7, "days")
.toDate();
if (exp > new Date()) {
logger.debug(
`Using cached license key: ${key.licenseKey}, valid ${cached.valid}`
);
continue;
}
}
logger.debug(
`Can't trust license key: ${key.licenseKey}`
);
cached.valid = false;
this.licenseKeyCache.set<LicenseKeyCache>(
key.licenseKey,
cached
);
continue;
}
const payload = validateJWT<TokenPayload>(
licenseKeyRes,
this.publicKey
);
cached.valid = payload.valid;
cached.type = payload.type;
cached.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<LicenseKeyCache>(
key.licenseKey,
cached
);
} catch (e) {
logger.error(`Error validating license key: ${key}`);
logger.error(e);
}
}
// Compute host status
for (const key of keys) {
const cached = this.licenseKeyCache.get<LicenseKeyCache>(
key.licenseKey
)!;
logger.debug("Checking key", cached);
if (cached.type === "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<TokenPayload>(
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<ValidateLicenseAPIResponse> {
// 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;

View file

@ -0,0 +1,109 @@
import * as crypto from "crypto";
/**
* Validates a JWT using a public key
* @param token - The JWT to validate
* @param publicKey - The public key used for verification (PEM format)
* @returns The decoded payload if validation succeeds, throws an error otherwise
*/
function validateJWT<Payload>(
token: string,
publicKey: string
): Payload {
// Split the JWT into its three parts
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("Invalid JWT format");
}
const [encodedHeader, encodedPayload, signature] = parts;
// Decode the header to get the algorithm
const header = JSON.parse(Buffer.from(encodedHeader, "base64").toString());
const algorithm = header.alg;
// Verify the signature
const signatureInput = `${encodedHeader}.${encodedPayload}`;
const isValid = verify(signatureInput, signature, publicKey, algorithm);
if (!isValid) {
throw new Error("Invalid signature");
}
// Decode the payload
const payload = JSON.parse(
Buffer.from(encodedPayload, "base64").toString()
);
// Check if the token has expired
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
throw new Error("Token has expired");
}
return payload;
}
/**
* Verifies the signature of a JWT
*/
function verify(
input: string,
signature: string,
publicKey: string,
algorithm: string
): boolean {
let verifyAlgorithm: string;
// Map JWT algorithm name to Node.js crypto algorithm name
switch (algorithm) {
case "RS256":
verifyAlgorithm = "RSA-SHA256";
break;
case "RS384":
verifyAlgorithm = "RSA-SHA384";
break;
case "RS512":
verifyAlgorithm = "RSA-SHA512";
break;
case "ES256":
verifyAlgorithm = "SHA256";
break;
case "ES384":
verifyAlgorithm = "SHA384";
break;
case "ES512":
verifyAlgorithm = "SHA512";
break;
default:
throw new Error(`Unsupported algorithm: ${algorithm}`);
}
// Convert base64url signature to standard base64
const base64Signature = base64URLToBase64(signature);
// Verify the signature
const verifier = crypto.createVerify(verifyAlgorithm);
verifier.update(input);
return verifier.verify(publicKey, base64Signature, "base64");
}
/**
* Converts base64url format to standard base64
*/
function base64URLToBase64(base64url: string): string {
// Add padding if needed
let base64 = base64url.replace(/-/g, "+").replace(/_/g, "/");
const pad = base64.length % 4;
if (pad) {
if (pad === 1) {
throw new Error("Invalid base64url string");
}
base64 += "=".repeat(4 - pad);
}
return base64;
}
export { validateJWT };

View file

@ -17,5 +17,6 @@ export * from "./verifyAccessTokenAccess";
export * from "./verifyUserIsServerAdmin";
export * from "./verifyIsLoggedInUser";
export * from "./integration";
export * from "./verifyValidLicense";
export * from "./verifyUserHasAction";
export * from "./verifyApiKeyAccess";

View file

@ -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) {

View file

@ -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,

View file

@ -70,9 +70,9 @@ export async function verifyApiKeyAccess(
eq(userOrgs.userId, userId),
eq(userOrgs.orgId, apiKeyOrg.orgId)
)
);
)
.limit(1);
req.userOrg = userOrgRole[0];
req.userRoleIds = userOrgRole.map((r) => r.roleId);
}
if (!req.userOrg) {
@ -84,6 +84,9 @@ export async function verifyApiKeyAccess(
);
}
const userOrgRoleId = req.userOrg.roleId;
req.userOrgRoleId = userOrgRoleId;
return next();
} catch (error) {
return next(

View file

@ -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(

View file

@ -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);

View file

@ -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();

View file

@ -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);

View file

@ -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) {

View file

@ -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) {

View file

@ -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,

View file

@ -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) {

View file

@ -0,0 +1,28 @@
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"
)
);
}
}

View file

@ -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 {

View file

@ -8,8 +8,10 @@ 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 license from "./license";
import * as apiKeys from "./apiKeys";
import HttpCode from "@server/types/HttpCode";
import {
@ -273,14 +275,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 +405,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(
@ -555,6 +555,30 @@ 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`,
verifyUserIsServerAdmin,

View file

@ -7,6 +7,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 +51,6 @@ export async function createIdpOrgPolicy(
next: NextFunction
): Promise<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { idpId, orgId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
@ -70,9 +60,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 +85,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 +104,15 @@ export async function createIdpOrgPolicy(
await db.insert(idpOrg).values({
idpId,
orgId,
orgMapping,
roleMapping
roleMapping,
orgMapping
});
return response<CreateIdpOrgPolicyResponse>(res, {
data: {},
success: true,
error: false,
message: "Idp org policy created successfully",
message: "Idp created successfully",
status: HttpCode.CREATED
});
} catch (error) {

View file

@ -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();

View file

@ -6,7 +6,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 +20,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 +46,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 +78,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) {

View file

@ -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";

View file

@ -1,7 +1,7 @@
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 +10,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 +42,8 @@ async function query(idpId: number, limit: number, offset: number) {
}
export type ListIdpOrgPoliciesResponse = {
policies: Array<IdpOrg>;
pagination: {
total: number;
limit: number;
offset: number;
};
policies: NonNullable<Awaited<ReturnType<typeof query>>>;
pagination: { total: number; limit: number; offset: number };
};
registry.registerPath({
@ -79,7 +73,6 @@ export async function listIdpOrgPolicies(
)
);
}
const { idpId } = parsedParams.data;
const parsedQuery = querySchema.safeParse(req.query);
@ -111,7 +104,7 @@ export async function listIdpOrgPolicies(
},
success: true,
error: false,
message: "Idp org policies retrieved successfully",
message: "Policies retrieved successfully",
status: HttpCode.OK
});
} catch (error) {

View file

@ -1,224 +0,0 @@
import { Request, Response } from "express";
import { z } from "zod";
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 { 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();
export async function oidcAutoProvision({
idp,
claims,
existingUser,
userIdentifier,
email,
name,
req,
res
}: {
idp: Idp;
claims: any;
existingUser?: User;
userIdentifier: string;
email?: string;
name?: string;
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 }[] = [];
for (const org of allOrgs) {
const idpOrgs = 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;
if (orgMapping) {
const orgId = jmespath.search(claims, orgMapping);
logger.debug("Extracted org ID", { orgId });
if (orgId !== true && orgId !== org.orgId) {
// user not allowed to access this org
continue;
}
}
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
});
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
.select()
.from(roles)
.where(
and(
eq(roles.orgId, org.orgId),
inArray(roles.name, rolesFromToken)
)
);
if (rolesFromDb.length === 0) {
logger.error("Role(s) not found", { roles: rolesFromToken });
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
});
});
}
}
logger.debug("User org info", { userOrgInfo });
let userId = existingUser?.userId;
// sync the user with the orgs and roles
await db.transaction(async (trx) => {
if (!userId) {
// create user if it does not exist
userId = generateId(15);
await trx.insert(users).values({
userId,
username: userIdentifier,
email: email || null,
name: name || null,
type: UserType.OIDC,
idpId: idp.idpId,
emailVerified: true, // OIDC users are always verified
dateCreated: new Date().toISOString()
});
} else {
// update username/email
await trx
.update(users)
.set({
username: userIdentifier,
email: email || null,
name: name || null
})
.where(eq(users.userId, userId));
}
// get all current user orgs/roles
const currentUserOrgs = await trx
.select()
.from(userOrgs)
.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);
if (orgsToDelete.length > 0) {
await trx
.delete(userOrgs)
.where(
and(
eq(userOrgs.userId, userId!),
inArray(userOrgs.orgId, orgsToDelete)
)
);
}
// Add new orgs that don't exist yet
const orgsToAdd = userOrgInfo.filter(
(newOrg) =>
!currentUserOrgs.some(
(currentOrg) =>
currentOrg.orgId === newOrg.orgId &&
currentOrg.roleId === newOrg.roleId
)
);
if (orgsToAdd.length > 0) {
await trx.insert(userOrgs).values(
orgsToAdd.map((org) => ({
userId: userId!,
orgId: org.orgId,
roleId: org.roleId
}))
);
}
});
const token = generateSessionToken();
const sess = await createSession(token, userId!);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(
token,
isSecure,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
}
function hydrateOrgMapping(
orgMapping: string | null,
orgId: string
): string | null {
if (!orgMapping) {
return null;
}
return orgMapping.replaceAll("{{orgId}}", orgId);
}

View file

@ -7,8 +7,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 +59,6 @@ export async function updateIdpOrgPolicy(
)
);
}
const { idpId, orgId } = parsedParams.data;
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
@ -70,9 +69,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 +85,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<UpdateIdpOrgPolicyResponse>(res, {
data: {
idpId
},
data: {},
success: true,
error: false,
message: "Idp org policy updated successfully",
message: "Policy updated successfully",
status: HttpCode.OK
});
} catch (error) {

View file

@ -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({

View file

@ -9,9 +9,13 @@ import { fromError } from "zod-validation-error";
import {
idp,
idpOidcConfig,
idpOrg,
orgs,
roles,
userOrgs,
users
} from "@server/db/schemas";
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import * as arctic from "arctic";
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
import jmespath from "jmespath";
@ -19,11 +23,12 @@ import jsonwebtoken from "jsonwebtoken";
import config from "@server/lib/config";
import {
createSession,
generateId,
generateSessionToken,
serializeSessionCookie
} from "@server/auth/sessions/app";
import { decrypt } from "@server/lib/crypto";
import { oidcAutoProvision } from "./oidcAutoProvision";
import { UserType } from "@server/types/UserTypes";
const ensureTrailingSlash = (url: string): string => {
return url;
@ -215,17 +220,203 @@ export async function validateOidcCallback(
);
if (existingIdp.idp.autoProvision) {
await oidcAutoProvision({
idp: existingIdp.idp,
userIdentifier,
email,
name,
claims,
existingUser,
req,
res
const allOrgs = await db.select().from(orgs);
const defaultRoleMapping = existingIdp.idp.defaultRoleMapping;
const defaultOrgMapping = existingIdp.idp.defaultOrgMapping;
let userOrgInfo: { orgId: string; roleId: number }[] = [];
for (const org of allOrgs) {
const [idpOrgRes] = await db
.select()
.from(idpOrg)
.where(
and(
eq(idpOrg.idpId, existingIdp.idp.idpId),
eq(idpOrg.orgId, org.orgId)
)
);
let roleId: number | undefined = undefined;
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.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 [roleRes] = await db
.select()
.from(roles)
.where(
and(
eq(roles.orgId, org.orgId),
eq(roles.name, roleName)
)
);
if (!roleRes) {
logger.error("Role not found", {
orgId: org.orgId,
roleName
});
continue;
}
roleId = roleRes.roleId;
userOrgInfo.push({
orgId: org.orgId,
roleId
});
}
}
logger.debug("User org info", { userOrgInfo });
let existingUserId = existingUser?.userId;
// sync the user with the orgs and roles
await db.transaction(async (trx) => {
let userId = existingUser?.userId;
// create user if not exists
if (!existingUser) {
userId = generateId(15);
await trx.insert(users).values({
userId,
username: userIdentifier,
email: email || null,
name: name || null,
type: UserType.OIDC,
idpId: existingIdp.idp.idpId,
emailVerified: true, // OIDC users are always verified
dateCreated: new Date().toISOString()
});
} else {
// set the name and email
await trx
.update(users)
.set({
username: userIdentifier,
email: email || null,
name: name || null
})
.where(eq(users.userId, userId!));
}
existingUserId = userId;
// get all current user orgs
const currentUserOrgs = await trx
.select()
.from(userOrgs)
.where(eq(userOrgs.userId, userId!));
// Delete orgs that are no longer valid
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.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
)
);
if (orgsToAdd.length > 0) {
await trx.insert(userOrgs).values(
orgsToAdd.map((org) => ({
userId: userId!,
orgId: org.orgId,
roleId: org.roleId,
dateCreated: new Date().toISOString()
}))
);
}
});
const token = generateSessionToken();
const sess = await createSession(token, existingUserId!);
const isSecure = req.protocol === "https";
const cookie = serializeSessionCookie(
token,
isSecure,
new Date(sess.expiresAt)
);
res.appendHeader("Set-Cookie", cookie);
return response<ValidateOidcUrlCallbackResponse>(res, {
data: {
redirectUrl: postAuthRedirectUrl
@ -273,3 +464,13 @@ export async function validateOidcCallback(
);
}
}
function hydrateOrgMapping(
orgMapping: string | null,
orgId: string
): string | undefined {
if (!orgMapping) {
return undefined;
}
return orgMapping.split("{{orgId}}").join(orgId);
}

View file

@ -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);

View file

@ -0,0 +1,57 @@
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib";
import license, { LicenseStatus } from "@server/license/license";
import { z } from "zod";
import { fromError } from "zod-validation-error";
const bodySchema = z
.object({
licenseKey: z.string().min(1).max(255)
})
.strict();
export type ActivateLicenseStatus = LicenseStatus;
export async function activateLicense(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const parsedBody = bodySchema.safeParse(req.body);
if (!parsedBody.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedBody.error).toString()
)
);
}
const { licenseKey } = parsedBody.data;
try {
const status = await license.activateLicenseKey(licenseKey);
return sendResponse(res, {
data: status,
success: true,
error: false,
message: "License key activated successfully",
status: HttpCode.OK
});
} catch (e) {
logger.error(e);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`)
);
}
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,73 @@
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<any> {
try {
const parsedParams = paramsSchema.safeParse(req.params);
if (!parsedParams.success) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
fromError(parsedParams.error).toString()
)
);
}
const { licenseKey: key } = parsedParams.data;
const [existing] = await db
.select()
.from(licenseKey)
.where(eq(licenseKey.licenseKeyId, key))
.limit(1);
if (!existing) {
return next(
createHttpError(
HttpCode.NOT_FOUND,
`License key ${key} not found`
)
);
}
await db.delete(licenseKey).where(eq(licenseKey.licenseKeyId, key));
const status = await license.forceRecheck();
return sendResponse(res, {
data: status,
success: true,
error: false,
message: "License key deleted successfully",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,31 @@
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib";
import license, { LicenseStatus } from "@server/license/license";
export type GetLicenseStatusResponse = LicenseStatus;
export async function getLicenseStatus(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const status = await license.check();
return sendResponse<GetLicenseStatusResponse>(res, {
data: status,
success: true,
error: false,
message: "Got status",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,5 @@
export * from "./getLicenseStatus";
export * from "./activateLicense";
export * from "./listLicenseKeys";
export * from "./deleteLicenseKey";
export * from "./recheckStatus";

View file

@ -0,0 +1,31 @@
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib";
import license, { LicenseKeyCache } from "@server/license/license";
export type ListLicenseKeysResponse = LicenseKeyCache[];
export async function listLicenseKeys(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
const keys = license.listKeys();
return sendResponse<ListLicenseKeysResponse>(res, {
data: keys,
success: true,
error: false,
message: "Successfully retrieved license keys",
status: HttpCode.OK
});
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -0,0 +1,37 @@
import { Request, Response, NextFunction } from "express";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import { response as sendResponse } from "@server/lib";
import license, { LicenseStatus } from "@server/license/license";
export type RecheckStatusResponse = LicenseStatus;
export async function recheckStatus(
req: Request,
res: Response,
next: NextFunction
): Promise<any> {
try {
try {
const status = await license.forceRecheck();
return sendResponse(res, {
data: status,
success: true,
error: false,
message: "License status rechecked successfully",
status: HttpCode.OK
});
} catch (e) {
logger.error(e);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, `${e}`)
);
}
} catch (error) {
logger.error(error);
return next(
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
);
}
}

View file

@ -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")
);

View file

@ -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<GetOrgOverviewResponse>(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,

View file

@ -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!,

View file

@ -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 {

View file

@ -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!,

View file

@ -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 {

View file

@ -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<any> {
try {
config.hideSupporterKey();
return sendResponse<HideSupporterKeyResponse>(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")
);
}
}

View file

@ -0,0 +1,3 @@
export * from "./validateSupporterKey";
export * from "./isSupporterKeyVisible";
export * from "./hideSupporterKey";

View file

@ -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<any> {
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<IsSupporterKeyVisibleResponse>(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")
);
}
}

View file

@ -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<any> {
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<ValidateSupporterKeyResponse>(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<ValidateSupporterKeyResponse>(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")
);
}
}

View file

@ -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",

View file

@ -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<RoleItem[]>`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<ReturnType<typeof queryUser>>
>;

View file

@ -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";

View file

@ -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<T extends Record<string, AnyColumn>>(shape: T) {
const shapeString = Object.entries(shape)
.map(([key, value]) => {
return `'${key}', ${value}`;
})
.join(",");
return sql<
InferColumnsDataTypes<T>[]
>`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<RoleItem[]>`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 = {

View file

@ -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<typeof setUserRolesBodySchema>;
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<any> {
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")
);
}
}

View file

@ -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(
))}
</div>
<div className="text-center text-lg">
Your role
{orgData.overview.isOwner ||
orgData.overview.isAdmin ||
orgData.overview.roles.length === 1
? ""
: "s"}
:{" "}
Your role:{" "}
<span className="font-semibold">
{orgData.overview.isOwner
? "Owner"
: orgData.overview.isAdmin
? "Admin"
: orgData.overview.roles
.map((r) => r.name)
.join(", ")}
{orgData.overview.isOwner ? "Owner" : orgData.overview.userRole}
</span>
</div>
</CardContent>

View file

@ -74,7 +74,7 @@ export default async function OrgPage(props: OrgPageProps) {
},
isAdmin: overview.isAdmin,
isOwner: overview.isOwner,
roles: overview.roles
userRole: overview.userRoleName
}}
/>
</div>

View file

@ -150,7 +150,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Roles
Role
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);

View file

@ -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({
username: z.string(),
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<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
username: user.username!,
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<typeof formSchema>) {
@ -111,8 +95,8 @@ export default function AccessControlsPage() {
const res = await api
.post<
AxiosResponse<SetUserRolesResponse>
>(`/org/${user.orgId}/user/${user.userId}/roles`, { roleIds: values.roles.map((r) => parseInt(r.id)) })
AxiosResponse<InviteUserResponse>
>(`/role/${values.roleId}/add/${user.userId}`)
.catch((e) => {
toast({
variant: "destructive",
@ -156,44 +140,30 @@ export default function AccessControlsPage() {
>
<FormField
control={form.control}
name="roles"
name="roleId"
render={({ field }) => (
<FormItem className="flex flex-col items-start">
<FormLabel>Roles</FormLabel>
<FormControl>
<TagInput
{...field}
activeTagIndex={
activeRolesTagIndex
}
setActiveTagIndex={
setActiveRolesTagIndex
}
placeholder="Select a role"
size="sm"
tags={
form.getValues().roles
}
setTags={(newRoles) => {
form.setValue(
"roles",
newRoles as [
Tag,
...Tag[]
]
);
}}
enableAutocomplete={true}
autocompleteOptions={
allRoles
}
allowDuplicates={false}
restrictTagsToAutocompleteOptions={
true
}
sortTags={true}
/>
</FormControl>
<FormItem>
<FormLabel>Role</FormLabel>
<Select
onValueChange={field.onChange}
value={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem
key={role.roleId}
value={role.roleId.toString()}
>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}

View file

@ -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
};
});

View file

@ -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`;

View file

@ -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;

View file

@ -4,15 +4,20 @@ 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 Link from "next/link";
import { InfoPopup } from "@app/components/ui/info-popup";
export interface PolicyRow {

View file

@ -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<CreateIdpFormValues>({
resolver: zodResolver(createIdpFormSchema),

View file

@ -0,0 +1,142 @@
"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<LicenseKeyCache>[] = [
{
accessorKey: "licenseKey",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
License Key
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const licenseKey = row.original.licenseKey;
return (
<CopyToClipboard
text={licenseKey}
displayText={obfuscateLicenseKey(licenseKey)}
/>
);
}
},
{
accessorKey: "valid",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Valid
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
return row.original.valid ? "Yes" : "No";
}
},
{
accessorKey: "type",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Type
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
cell: ({ row }) => {
const type = row.original.type;
const label =
type === "SITES" ? "Additional Sites" : "Host License";
const variant = type === "SITES" ? "secondary" : "default";
return row.original.valid ? (
<Badge variant={variant}>{label}</Badge>
) : null;
}
},
{
accessorKey: "numSites",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
>
Number of Sites
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
}
},
{
id: "delete",
cell: ({ row }) => (
<div className="flex items-center justify-end space-x-2">
<Button
variant="outlinePrimary"
onClick={() => onDelete(row.original)}
>
Delete
</Button>
</div>
)
}
];
return (
<DataTable
columns={columns}
data={licenseKeys}
title="License Keys"
searchPlaceholder="Search license keys..."
searchColumn="licenseKey"
onAdd={onCreate}
addButtonText="Add License Key"
/>
);
}

View file

@ -0,0 +1,132 @@
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 (
<Credenza open={isOpen} onOpenChange={onOpenChange}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{mode === "license"
? "Purchase License"
: "Purchase Additional Sites"}
</CredenzaTitle>
<CredenzaDescription>
Choose how many sites you want to{" "}
{mode === "license"
? "purchase a license for. You can always add more sites later."
: "add to your existing license."}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<div className="space-y-6">
<div className="flex flex-col items-center space-y-4">
<div className="text-sm font-medium text-muted-foreground">
Number of Sites
</div>
<div className="flex items-center space-x-4">
<Button
variant="ghost"
size="icon"
onClick={decrementSites}
disabled={siteCount <= 1}
aria-label="Decrease site count"
>
<MinusCircle className="h-5 w-5" />
</Button>
<span className="text-3xl w-12 text-center">
{siteCount}
</span>
<Button
variant="ghost"
size="icon"
onClick={incrementSites}
aria-label="Increase site count"
>
<PlusCircle className="h-5 w-5" />
</Button>
</div>
</div>
<div className="border-t pt-4">
<p className="text-muted-foreground text-sm mt-2 text-center">
For the most up-to-date pricing and discounts,
please visit the{" "}
<a
href="https://docs.fossorial.io/pricing"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
pricing page
</a>
.
</p>
</div>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Cancel</Button>
</CredenzaClose>
<Button onClick={continueToPayment}>
See Purchase Portal
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}

View file

@ -0,0 +1,545 @@
"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, Heart, InfoIcon, 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";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
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<LicenseKeyCache[]>([]);
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedLicenseKey, setSelectedLicenseKey] =
useState<LicenseKeyCache | null>(null);
const router = useRouter();
const { licenseStatus, updateLicenseStatus } = useLicenseStatusContext();
const [hostLicense, setHostLicense] = useState<string | null>(null);
const [isPurchaseModalOpen, setIsPurchaseModalOpen] = useState(false);
const [purchaseMode, setPurchaseMode] = useState<
"license" | "additional-sites"
>("license");
// Separate loading states for different actions
const [isInitialLoading, setIsInitialLoading] = useState(true);
const [isActivatingLicense, setIsActivatingLicense] = useState(false);
const [isDeletingLicense, setIsDeletingLicense] = useState(false);
const [isRecheckingLicense, setIsRecheckingLicense] = useState(false);
const { supporterStatus } = useSupporterStatusContext();
const form = useForm<z.infer<typeof formSchema>>({
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<AxiosResponse<LicenseKeyCache[]>>(
"/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<typeof formSchema>) {
try {
setIsActivatingLicense(true);
const res = await api.post("/license/activate", {
licenseKey: values.licenseKey
});
if (res.data.data) {
updateLicenseStatus(res.data.data);
}
toast({
title: "License key activated",
description: "The license key has been successfully activated."
});
setIsCreateModalOpen(false);
form.reset();
await loadLicenseKeys();
} catch (e) {
toast({
variant: "destructive",
title: "Failed to activate license key",
description: formatAxiosError(
e,
"An error occurred while activating the license key."
)
});
} finally {
setIsActivatingLicense(false);
}
}
if (isInitialLoading) {
return null;
}
return (
<>
<SitePriceCalculator
isOpen={isPurchaseModalOpen}
onOpenChange={(val) => {
setIsPurchaseModalOpen(val);
}}
mode={purchaseMode}
/>
<Credenza
open={isCreateModalOpen}
onOpenChange={(val) => {
setIsCreateModalOpen(val);
form.reset();
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Activate License Key</CredenzaTitle>
<CredenzaDescription>
Enter a license key to activate it.
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="activate-license-form"
>
<FormField
control={form.control}
name="licenseKey"
render={({ field }) => (
<FormItem>
<FormLabel>License Key</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="agreeToTerms"
render={({ field }) => (
<FormItem className="flex flex-row items-start space-x-3 space-y-0">
<FormControl>
<Checkbox
checked={field.value}
onCheckedChange={
field.onChange
}
/>
</FormControl>
<div className="space-y-1 leading-none">
<FormLabel>
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.
<br />
<Link
href="https://fossorial.io/license.html"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline"
>
View Fossorial
Commercial License &
Subscription Terms
</Link>
</FormLabel>
<FormMessage />
</div>
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button
type="submit"
form="activate-license-form"
loading={isActivatingLicense}
disabled={isActivatingLicense}
>
Activate License
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
{selectedLicenseKey && (
<ConfirmDeleteDialog
open={isDeleteModalOpen}
setOpen={(val) => {
setIsDeleteModalOpen(val);
setSelectedLicenseKey(null);
}}
dialog={
<div className="space-y-4">
<p>
Are you sure you want to delete the license key{" "}
<b>
{obfuscateLicenseKey(
selectedLicenseKey.licenseKey
)}
</b>
?
</p>
<p>
<b>
This will remove the license key and all
associated permissions granted by it.
</b>
</p>
<p>
To confirm, please type the license key below.
</p>
</div>
}
buttonText="Confirm Delete License Key"
onConfirm={async () =>
deleteLicenseKey(selectedLicenseKey.licenseKeyEncrypted)
}
string={selectedLicenseKey.licenseKey}
title="Delete License Key"
/>
)}
<SettingsSectionTitle
title="Manage License Status"
description="View and manage license keys in the system"
/>
<Alert variant="neutral" className="mb-6">
<InfoIcon className="h-4 w-4" />
<AlertTitle className="font-semibold">
About Licensing
</AlertTitle>
<AlertDescription>
This is for business and enterprise users who are using
Pangolin in a commercial environment. If you are using
Pangolin for personal use, you can ignore this section.
</AlertDescription>
</Alert>
<SettingsContainer>
<SettingsSectionGrid cols={2}>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>Host License</SSTitle>
<SettingsSectionDescription>
Manage the main license key for the host.
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
<div className="flex items-center space-x-4">
{licenseStatus?.isLicenseValid ? (
<div className="space-y-2 text-green-500">
<div className="text-2xl flex items-center gap-2">
<Check />
{licenseStatus?.tier ===
"PROFESSIONAL"
? "Commercial License"
: licenseStatus?.tier ===
"ENTERPRISE"
? "Commercial License"
: "Licensed"}
</div>
</div>
) : (
<div className="space-y-2">
{supporterStatus?.visible ? (
<div className="text-2xl">
Community Edition
</div>
) : (
<div className="text-2xl flex items-center gap-2 text-pink-500">
<Heart />
Community Edition
</div>
)}
</div>
)}
</div>
{licenseStatus?.hostId && (
<div className="space-y-2">
<div className="text-sm font-medium">
Host ID
</div>
<CopyTextBox text={licenseStatus.hostId} />
</div>
)}
{hostLicense && (
<div className="space-y-2">
<div className="text-sm font-medium">
License Key
</div>
<CopyTextBox
text={hostLicense}
displayText={obfuscateLicenseKey(
hostLicense
)}
/>
</div>
)}
</div>
<SettingsSectionFooter>
<Button
variant="outline"
onClick={recheck}
disabled={isRecheckingLicense}
loading={isRecheckingLicense}
>
Recheck All Keys
</Button>
</SettingsSectionFooter>
</SettingsSection>
<SettingsSection>
<SettingsSectionHeader>
<SSTitle>Sites Usage</SSTitle>
<SettingsSectionDescription>
View the number of sites using this license.
</SettingsSectionDescription>
</SettingsSectionHeader>
<div className="space-y-4">
<div className="space-y-2">
<div className="text-2xl">
{licenseStatus?.usedSites || 0}{" "}
{licenseStatus?.usedSites === 1
? "site"
: "sites"}{" "}
in system
</div>
</div>
{!licenseStatus?.isHostLicensed && (
<p className="text-sm text-muted-foreground">
There is no limit on the number of sites
using an unlicensed host.
</p>
)}
{licenseStatus?.maxSites && (
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">
{licenseStatus.usedSites || 0} of{" "}
{licenseStatus.maxSites} sites used
</span>
<span className="text-muted-foreground">
{Math.round(
((licenseStatus.usedSites ||
0) /
licenseStatus.maxSites) *
100
)}
%
</span>
</div>
<Progress
value={
((licenseStatus.usedSites || 0) /
licenseStatus.maxSites) *
100
}
className="h-5"
/>
</div>
)}
</div>
<SettingsSectionFooter>
{!licenseStatus?.isHostLicensed ? (
<>
<Button
onClick={() => {
setPurchaseMode("license");
setIsPurchaseModalOpen(true);
}}
>
Purchase License
</Button>
</>
) : (
<>
<Button
variant="outline"
onClick={() => {
setPurchaseMode("additional-sites");
setIsPurchaseModalOpen(true);
}}
>
Purchase Additional Sites
</Button>
</>
)}
</SettingsSectionFooter>
</SettingsSection>
</SettingsSectionGrid>
<LicenseKeysDataTable
licenseKeys={rows}
onDelete={(key) => {
setSelectedLicenseKey(key);
setIsDeleteModalOpen(true);
}}
onCreate={() => setIsCreateModalOpen(true)}
/>
</SettingsContainer>
</>
);
}

View file

@ -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<string | null>(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<ValidateOidcUrlCallbackResponse>

View file

@ -1,7 +1,10 @@
import ProfileIcon from "@app/components/ProfileIcon";
import { Separator } from "@app/components/ui/separator";
import { priv } from "@app/lib/api";
import { verifySession } from "@app/lib/auth/verifySession";
import UserProvider from "@app/providers/UserProvider";
import { GetLicenseStatusResponse } from "@server/routers/license";
import { AxiosResponse } from "axios";
import { ExternalLink } from "lucide-react";
import { Metadata } from "next";
import { cache } from "react";
@ -19,6 +22,14 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
const getUser = cache(verifySession);
const user = await getUser();
const licenseStatusRes = await cache(
async () =>
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
)
)();
const licenseStatus = licenseStatusRes.data.data;
return (
<div className="h-full flex flex-col">
{user && (
@ -33,42 +44,46 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
<div className="w-full max-w-md p-3">{children}</div>
</div>
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600">
<div className="flex items-center space-x-2 whitespace-nowrap">
<span>Pangolin</span>
</div>
<Separator orientation="vertical" />
<a
href="https://fossorial.io/"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>Fossorial</span>
<ExternalLink className="w-3 h-3" />
</a>
<Separator orientation="vertical" />
<a
href="https://code.thetadev.de/ThetaDev/pangolin"
target="_blank"
rel="noopener noreferrer"
aria-label="Repository"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>Open Source</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-3 h-3"
{!(
licenseStatus.isHostLicensed && licenseStatus.isLicenseValid
) && (
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
<div className="container mx-auto flex flex-wrap justify-center items-center h-3 space-x-4 text-sm text-neutral-400 dark:text-neutral-600">
<div className="flex items-center space-x-2 whitespace-nowrap">
<span>Pangolin</span>
</div>
<Separator orientation="vertical" />
<a
href="https://fossorial.io/"
target="_blank"
rel="noopener noreferrer"
aria-label="Built by Fossorial"
className="flex items-center space-x-2 whitespace-nowrap"
>
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg>
</a>
</div>
</footer>
<span>Fossorial</span>
<ExternalLink className="w-3 h-3" />
</a>
<Separator orientation="vertical" />
<a
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
className="flex items-center space-x-2 whitespace-nowrap"
>
<span>Community Edition</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className="w-3 h-3"
>
<path d="M12 0C5.37 0 0 5.373 0 12c0 5.303 3.438 9.8 8.207 11.385.6.11.82-.26.82-.577v-2.17c-3.338.726-4.042-1.61-4.042-1.61-.546-1.385-1.333-1.755-1.333-1.755-1.09-.744.082-.73.082-.73 1.205.085 1.84 1.24 1.84 1.24 1.07 1.835 2.807 1.305 3.492.997.107-.775.42-1.305.763-1.605-2.665-.305-5.467-1.335-5.467-5.93 0-1.31.468-2.382 1.236-3.22-.123-.303-.535-1.523.117-3.176 0 0 1.008-.322 3.3 1.23a11.52 11.52 0 013.006-.403c1.02.005 2.045.137 3.006.403 2.29-1.552 3.295-1.23 3.295-1.23.654 1.653.242 2.873.12 3.176.77.838 1.235 1.91 1.235 3.22 0 4.605-2.805 5.623-5.475 5.92.43.37.814 1.1.814 2.22v3.293c0 .32.217.693.825.576C20.565 21.795 24 17.298 24 12 24 5.373 18.627 0 12 0z" />
</svg>
</a>
</div>
</footer>
)}
</div>
);
}

View file

@ -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";
@ -279,7 +282,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
<span className="text-sm text-muted-foreground">
Powered by{" "}
<Link
href="https://code.thetadev.de/ThetaDev/pangolin"
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="underline"
@ -631,6 +634,15 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</Tabs>
</CardContent>
</Card>
{supporterStatus?.visible && (
<div className="text-center mt-2">
<span className="text-sm text-muted-foreground opacity-50">
Server is running without a supporter key.
<br />
Consider supporting the project!
</span>
</div>
)}
</div>
) : (
<ResourceAccessDenied />

View file

@ -0,0 +1,62 @@
"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 (
<div className="fixed bottom-0 left-0 right-0 w-full bg-red-500 text-white p-4 text-center z-50">
<div className="flex justify-between items-center">
<p>
Invalid or expired license keys detected. Follow license
terms to continue using all features.
</p>
<Button
variant={"ghost"}
className="hover:bg-yellow-500"
onClick={() => setIsDismissed(true)}
>
Dismiss
</Button>
</div>
</div>
);
}
// Show usage violation banner
if (
licenseStatus.maxSites &&
licenseStatus.usedSites &&
licenseStatus.usedSites > licenseStatus.maxSites
) {
return (
<div className="fixed bottom-0 left-0 right-0 w-full bg-yellow-500 text-black p-4 text-center z-50">
<div className="flex justify-between items-center">
<p>
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.
</p>
<Button
variant={"ghost"}
className="hover:bg-yellow-500"
onClick={() => setIsDismissed(true)}
>
Dismiss
</Button>
</div>
</div>
);
}
return null;
}

View file

@ -5,6 +5,14 @@ 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";
import { cache } from "react";
export const metadata: Metadata = {
title: `Dashboard - Pangolin`,
@ -23,6 +31,24 @@ export default async function RootLayout({
}>) {
const env = pullEnv();
let supporterData = {
visible: true
} as any;
const res = await priv.get<AxiosResponse<IsSupporterKeyVisibleResponse>>(
"supporter-key/visible"
);
supporterData.visible = res.data.data.visible;
supporterData.tier = res.data.data.tier;
const licenseStatusRes = await cache(
async () =>
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
"/license/status"
)
)();
const licenseStatus = licenseStatusRes.data.data;
return (
<html suppressHydrationWarning>
<body className={`${font.className} h-screen overflow-hidden`}>
@ -33,12 +59,19 @@ export default async function RootLayout({
disableTransitionOnChange
>
<EnvProvider env={pullEnv()}>
{/* Main content */}
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
{children}
</div>
</div>
<LicenseStatusProvider licenseStatus={licenseStatus}>
<SupportStatusProvider
supporterStatus={supporterData}
>
{/* Main content */}
<div className="h-full flex flex-col">
<div className="flex-1 overflow-auto">
<LicenseViolation />
{children}
</div>
</div>
</SupportStatusProvider>
</LicenseStatusProvider>
</EnvProvider>
<Toaster />
</ThemeProvider>

View file

@ -8,6 +8,7 @@ import {
Combine,
Fingerprint,
KeyRound,
TicketCheck
} from "lucide-react";
export const orgLangingNavItems: SidebarNavItem[] = [
@ -91,5 +92,10 @@ export const adminNavItems: SidebarNavItem[] = [
title: "Identity Providers",
href: "/admin/idp",
icon: <Fingerprint className="h-4 w-4" />
},
{
title: "License",
href: "/admin/license",
icon: <TicketCheck className="h-4 w-4" />
}
];

View file

@ -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 (
<Link
key={hydratedHref}
href={hydratedHref}
href={isProfessional ? "#" : hydratedHref}
className={cn(
"px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap",
isActive
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground",
disabled && "cursor-not-allowed"
isDisabled && "cursor-not-allowed"
)}
onClick={(e) => {
if (disabled) {
if (isDisabled) {
e.preventDefault();
}
}}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
tabIndex={isDisabled ? -1 : undefined}
aria-disabled={isDisabled}
>
<div
className={cn(
"flex items-center space-x-2",
disabled && "opacity-60"
isDisabled && "opacity-60"
)}
>
{item.icon && item.icon}
<span>{item.title}</span>
{isProfessional && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
Professional
</Badge>
)}
</div>
</Link>
);

View file

@ -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";
@ -21,6 +22,7 @@ import { Breadcrumbs } from "@app/components/Breadcrumbs";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useUserContext } from "@app/hooks/useUserContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
interface LayoutProps {
children: React.ReactNode;
@ -57,6 +59,7 @@ export function Layout({
const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext();
const { isUnlocked } = useLicenseStatusContext();
return (
<div className="flex flex-col h-screen overflow-hidden">
@ -117,6 +120,7 @@ export function Layout({
)}
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
<OrgSelector
orgId={orgId}
orgs={orgs}
@ -195,16 +199,19 @@ export function Layout({
)}
</div>
<div className="p-4 space-y-4 border-t shrink-0">
<SupporterStatus />
<OrgSelector orgId={orgId} orgs={orgs} />
<div className="space-y-2">
<div className="text-xs text-muted-foreground text-center">
<Link
href="https://code.thetadev.de/ThetaDev/pangolin"
href="https://github.com/fosrl/pangolin"
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-center gap-1"
>
Open Source
{!isUnlocked()
? "Community Edition"
: "Commercial Edition"}
<ExternalLink size={12} />
</Link>
</div>

View file

@ -0,0 +1,37 @@
"use client";
import { cn } from "@app/lib/cn";
type ProfessionalContentOverlayProps = {
children: React.ReactNode;
isProfessional?: boolean;
};
export function ProfessionalContentOverlay({
children,
isProfessional = false
}: ProfessionalContentOverlayProps) {
return (
<div
className={cn(
"relative",
isProfessional && "opacity-60 pointer-events-none"
)}
>
{isProfessional && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-50">
<div className="text-center p-6 bg-primary/10 rounded-lg">
<h3 className="text-lg font-semibold mb-2">
Professional Edition Required
</h3>
<p className="text-muted-foreground">
This feature is only available in the Professional
Edition.
</p>
</div>
</div>
)}
{children}
</div>
);
}

View file

@ -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() {

View file

@ -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<HTMLElement> {
@ -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 (
<div key={hydratedHref}>
@ -102,28 +108,28 @@ export function SidebarNav({
)}
>
<Link
href={hydratedHref}
href={isProfessional ? "#" : hydratedHref}
className={cn(
"flex items-center w-full px-3 py-2",
isActive
? "text-primary font-medium"
: "text-muted-foreground group-hover:text-foreground",
disabled && "cursor-not-allowed"
isDisabled && "cursor-not-allowed"
)}
onClick={(e) => {
if (disabled) {
if (isDisabled) {
e.preventDefault();
} else if (onItemClick) {
onItemClick();
}
}}
tabIndex={disabled ? -1 : undefined}
aria-disabled={disabled}
tabIndex={isDisabled ? -1 : undefined}
aria-disabled={isDisabled}
>
<div
className={cn(
"flex items-center",
disabled && "opacity-60"
isDisabled && "opacity-60"
)}
>
{item.icon && (
@ -133,12 +139,20 @@ export function SidebarNav({
)}
{item.title}
</div>
{isProfessional && (
<Badge
variant="outlinePrimary"
className="ml-2"
>
Professional
</Badge>
)}
</Link>
{hasChildren && (
<button
onClick={() => toggleItem(hydratedHref)}
className="p-2 rounded-md text-muted-foreground hover:text-foreground cursor-pointer"
disabled={disabled}
disabled={isDisabled}
>
{isExpanded ? (
<ChevronDown className="h-5 w-5" />

View file

@ -0,0 +1,434 @@
"use client";
import Image from "next/image";
import { Separator } from "@app/components/ui/separator";
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
import { useState } from "react";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import { Button } from "./ui/button";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "./Credenza";
import { z } from "zod";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "./ui/form";
import { Input } from "./ui/input";
import { toast } from "@app/hooks/useToast";
import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { AxiosResponse } from "axios";
import { ValidateSupporterKeyResponse } from "@server/routers/supporterKey";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle
} from "./ui/card";
import { Check, ExternalLink } from "lucide-react";
import confetti from "canvas-confetti";
const formSchema = z.object({
githubUsername: z
.string()
.nonempty({ message: "GitHub username is required" }),
key: z.string().nonempty({ message: "Supporter key is required" })
});
export default function SupporterStatus() {
const { supporterStatus, updateSupporterStatus } =
useSupporterStatusContext();
const [supportOpen, setSupportOpen] = useState(false);
const [keyOpen, setKeyOpen] = useState(false);
const [purchaseOptionsOpen, setPurchaseOptionsOpen] = useState(false);
const api = createApiClient(useEnvContext());
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
githubUsername: "",
key: ""
}
});
async function hide() {
await api.post("/supporter-key/hide");
updateSupporterStatus({
visible: false
});
}
async function onSubmit(values: z.infer<typeof formSchema>) {
try {
const res = await api.post<
AxiosResponse<ValidateSupporterKeyResponse>
>("/supporter-key/validate", {
githubUsername: values.githubUsername,
key: values.key
});
const data = res.data.data;
if (!data || !data.valid) {
toast({
variant: "destructive",
title: "Invalid Key",
description: "Your supporter key is invalid."
});
return;
}
// Trigger the toast
toast({
variant: "default",
title: "Valid Key",
description:
"Your supporter key has been validated. Thank you for your support!"
});
// Fireworks-style confetti
const duration = 5 * 1000; // 5 seconds
const animationEnd = Date.now() + duration;
const defaults = {
startVelocity: 30,
spread: 360,
ticks: 60,
zIndex: 0,
colors: ["#FFA500", "#FF4500", "#FFD700"] // Orange hues
};
function randomInRange(min: number, max: number) {
return Math.random() * (max - min) + min;
}
const interval = setInterval(() => {
const timeLeft = animationEnd - Date.now();
if (timeLeft <= 0) {
clearInterval(interval);
return;
}
const particleCount = 50 * (timeLeft / duration);
// Launch confetti from two random horizontal positions
confetti({
...defaults,
particleCount,
origin: {
x: randomInRange(0.1, 0.3),
y: Math.random() - 0.2
}
});
confetti({
...defaults,
particleCount,
origin: {
x: randomInRange(0.7, 0.9),
y: Math.random() - 0.2
}
});
}, 250);
setPurchaseOptionsOpen(false);
setKeyOpen(false);
updateSupporterStatus({
visible: false
});
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description: formatAxiosError(
error,
"Failed to validate supporter key."
)
});
return;
}
}
return (
<>
<Credenza
open={purchaseOptionsOpen}
onOpenChange={(val) => {
setPurchaseOptionsOpen(val);
}}
>
<CredenzaContent className="max-w-3xl">
<CredenzaHeader>
<CredenzaTitle>
Support Development and Adopt a Pangolin!
</CredenzaTitle>
</CredenzaHeader>
<CredenzaBody>
<p>
Purchase a supporter key to help us continue
developing Pangolin for the community. Your
contribution allows us to commit more time to
maintain and add new features to the application for
everyone. We will never use this to paywall
features. This is separate from any Commercial
Edition.
</p>
<p>
You will also get to adopt and meet your very own
pet Pangolin!
</p>
<p>
Payments are processed via GitHub. Afterward, you
can retrieve your key on{" "}
<Link
href="https://supporters.fossorial.io/"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
our website
</Link>{" "}
and redeem it here.{" "}
<Link
href="https://docs.fossorial.io/supporter-program"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Learn more.
</Link>
</p>
<div className="py-6">
<p className="mb-3 text-center">
Please select the option that best suits you.
</p>
<div className="grid md:grid-cols-2 grid-cols-1 gap-8">
<Card>
<CardHeader>
<CardTitle>Full Supporter</CardTitle>
</CardHeader>
<CardContent>
<p className="text-4xl mb-6">$95</p>
<ul className="space-y-3">
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
For the whole server
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
Lifetime purchase
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
Supporter status
</span>
</li>
</ul>
</CardContent>
<CardFooter>
<Link
href="https://github.com/sponsors/fosrl/sponsorships?tier_id=474929"
target="_blank"
rel="noopener noreferrer"
className="w-full"
>
<Button className="w-full">
Buy
</Button>
</Link>
</CardFooter>
</Card>
<Card
className={`${supporterStatus?.tier === "Limited Supporter" ? "opacity-50" : ""}`}
>
<CardHeader>
<CardTitle>Limited Supporter</CardTitle>
</CardHeader>
<CardContent>
<p className="text-4xl mb-6">$25</p>
<ul className="space-y-3">
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
For 5 or less users
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
Lifetime purchase
</span>
</li>
<li className="flex items-center gap-2">
<Check className="h-6 w-6 text-green-500" />
<span className="text-muted-foreground">
Supporter status
</span>
</li>
</ul>
</CardContent>
<CardFooter>
{supporterStatus?.tier !==
"Limited Supporter" ? (
<Link
href="https://github.com/sponsors/fosrl/sponsorships?tier_id=463100"
target="_blank"
rel="noopener noreferrer"
className="w-full"
>
<Button className="w-full">
Buy
</Button>
</Link>
) : (
<Button
className="w-full"
disabled={
supporterStatus?.tier ===
"Limited Supporter"
}
>
Buy
</Button>
)}
</CardFooter>
</Card>
</div>
</div>
<div className="w-full space-y-2">
<Button
className="w-full"
variant="outlinePrimary"
onClick={() => {
setKeyOpen(true);
}}
>
Redeem Supporter Key
</Button>
<Button
variant="ghost"
className="w-full"
onClick={() => hide()}
>
Hide for 7 days
</Button>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
<Credenza
open={keyOpen}
onOpenChange={(val) => {
setKeyOpen(val);
}}
>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>Enter Supporter Key</CredenzaTitle>
<CredenzaDescription>
Meet your very own pet Pangolin!
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
id="form"
>
<FormField
control={form.control}
name="githubUsername"
render={({ field }) => (
<FormItem>
<FormLabel>
GitHub Username
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="key"
render={({ field }) => (
<FormItem>
<FormLabel>Supporter Key</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">Close</Button>
</CredenzaClose>
<Button type="submit" form="form">
Submit
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
{supporterStatus?.visible ? (
<Button
variant="outlinePrimary"
size="sm"
className="gap-2 w-full"
onClick={() => {
setPurchaseOptionsOpen(true);
}}
>
Buy Supporter Key
</Button>
) : null}
</>
);
}

View file

@ -0,0 +1,15 @@
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;

View file

@ -0,0 +1,17 @@
import { createContext } from "react";
export type SupporterStatus = {
visible: boolean;
tier?: string;
};
type SupporterStatusContextType = {
supporterStatus: SupporterStatus | null;
updateSupporterStatus: (updatedSite: Partial<SupporterStatus>) => void;
};
const SupporterStatusContext = createContext<
SupporterStatusContextType | undefined
>(undefined);
export default SupporterStatusContext;

View file

@ -0,0 +1,12 @@
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;
}

View file

@ -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;
}

View file

@ -0,0 +1,67 @@
"use client";
import LicenseStatusContext from "@app/contexts/licenseStatusContext";
import { LicenseStatus } from "@server/license/license";
import { useState } from "react";
interface ProviderProps {
children: React.ReactNode;
licenseStatus: LicenseStatus | null;
}
export function LicenseStatusProvider({
children,
licenseStatus
}: ProviderProps) {
const [licenseStatusState, setLicenseStatusState] =
useState<LicenseStatus | null>(licenseStatus);
const updateLicenseStatus = (updatedLicenseStatus: LicenseStatus) => {
setLicenseStatusState((prev) => {
return {
...updatedLicenseStatus
};
});
};
const isUnlocked = () => {
if (licenseStatusState?.isHostLicensed) {
if (licenseStatusState?.isLicenseValid) {
return true;
}
}
return false;
};
const isLicenseViolation = () => {
if (
licenseStatusState?.isHostLicensed &&
!licenseStatusState?.isLicenseValid
) {
return true;
}
if (
licenseStatusState?.maxSites &&
licenseStatusState?.usedSites &&
licenseStatusState.usedSites > licenseStatusState.maxSites
) {
return true;
}
return false;
};
return (
<LicenseStatusContext.Provider
value={{
licenseStatus: licenseStatusState,
updateLicenseStatus,
isLicenseViolation,
isUnlocked
}}
>
{children}
</LicenseStatusContext.Provider>
);
}
export default LicenseStatusProvider;

View file

@ -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 | null>(supporterStatus);
const updateSupporterStatus = (
updatedSupporterStatus: Partial<SupporterStatus>
) => {
setSupporterStatusState((prev) => {
if (!prev) {
return updatedSupporterStatus as SupporterStatus;
}
return {
...prev,
...updatedSupporterStatus
};
});
};
return (
<SupportStatusContext.Provider
value={{
supporterStatus: supporterStatusState,
updateSupporterStatus
}}
>
{children}
</SupportStatusContext.Provider>
);
}
export default SupporterStatusProvider;