Compare commits
10 commits
Author | SHA1 | Date | |
---|---|---|---|
a727626807 | |||
1d9c11cb8c | |||
7098c5e513 | |||
9bed44dcfd | |||
13f2054ab0 | |||
0b4c4a5d4a | |||
13cc20e07b | |||
49bba46c72 | |||
5670cc8e63 | |||
31595738b8 |
88 changed files with 1292 additions and 3374 deletions
|
@ -2,8 +2,7 @@ FROM node:20-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
COPY package.json ./
|
|
||||||
RUN npm install
|
RUN npm install
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
@ -21,7 +20,7 @@ RUN apk add --no-cache curl
|
||||||
|
|
||||||
# COPY package.json package-lock.json ./
|
# COPY package.json package-lock.json ./
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
RUN npm install --only=production && npm cache clean --force
|
RUN npm install --omit=dev && npm cache clean --force
|
||||||
|
|
||||||
COPY --from=builder /app/.next/standalone ./
|
COPY --from=builder /app/.next/standalone ./
|
||||||
COPY --from=builder /app/.next/static ./.next/static
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
|
|
@ -38,6 +38,9 @@ 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._
|
_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
|
## Key Features
|
||||||
|
|
||||||
### Reverse Proxy Through WireGuard Tunnel
|
### Reverse Proxy Through WireGuard Tunnel
|
||||||
|
|
BIN
newt
BIN
newt
Binary file not shown.
529
package-lock.json
generated
529
package-lock.json
generated
|
@ -383,11 +383,30 @@
|
||||||
"@noble/ciphers": "^1.0.0"
|
"@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": {
|
"node_modules/@emnapi/runtime": {
|
||||||
"version": "1.3.1",
|
"version": "1.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz",
|
||||||
"integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==",
|
"integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==",
|
||||||
"dev": true,
|
"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==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
@ -1618,6 +1637,39 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@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": {
|
"node_modules/@next/env": {
|
||||||
"version": "15.2.4",
|
"version": "15.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.2.4.tgz",
|
||||||
|
@ -1828,6 +1880,38 @@
|
||||||
"@node-rs/argon2-win32-x64-msvc": "2.0.2"
|
"@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": {
|
"node_modules/@node-rs/argon2-darwin-arm64": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.0.2.tgz",
|
||||||
|
@ -1844,6 +1928,387 @@
|
||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@node-rs/bcrypt": {
|
||||||
"version": "1.9.0",
|
"version": "1.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt/-/bcrypt-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt/-/bcrypt-1.9.0.tgz",
|
||||||
|
@ -3679,6 +4144,16 @@
|
||||||
"url": "https://github.com/sponsors/tannerlinsley"
|
"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": {
|
"node_modules/@types/better-sqlite3": {
|
||||||
"version": "7.6.12",
|
"version": "7.6.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.12.tgz",
|
||||||
|
@ -7522,6 +7997,13 @@
|
||||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
@ -9183,6 +9665,29 @@
|
||||||
"node": ">= 0.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": {
|
"node_modules/merge-descriptors": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||||
|
@ -12360,6 +12865,22 @@
|
||||||
"node": ">= 10"
|
"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": {
|
"node_modules/own-keys": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { userActions, roleActions, userOrgs } from "@server/db/schemas";
|
import { userActions, roleActions, userOrgs } from "@server/db/schemas";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
|
||||||
|
@ -51,6 +51,7 @@ export enum ActionsEnum {
|
||||||
listRoleResources = "listRoleResources",
|
listRoleResources = "listRoleResources",
|
||||||
// listRoleActions = "listRoleActions",
|
// listRoleActions = "listRoleActions",
|
||||||
addUserRole = "addUserRole",
|
addUserRole = "addUserRole",
|
||||||
|
setUserRoles = "setUserRoles",
|
||||||
// addUserSite = "addUserSite",
|
// addUserSite = "addUserSite",
|
||||||
// addUserAction = "addUserAction",
|
// addUserAction = "addUserAction",
|
||||||
// removeUserAction = "removeUserAction",
|
// removeUserAction = "removeUserAction",
|
||||||
|
@ -106,29 +107,28 @@ export async function checkUserActionPermission(
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let userOrgRoleId = req.userOrgRoleId;
|
let userRoleIds = req.userRoleIds;
|
||||||
|
|
||||||
// If userOrgRoleId is not available on the request, fetch it
|
// If userRoleIds is not available on the request, fetch it
|
||||||
if (userOrgRoleId === undefined) {
|
if (userRoleIds === undefined) {
|
||||||
const userOrgRole = await db
|
const userOrgRoles = await db
|
||||||
.select()
|
.select({ roleId: userOrgs.roleId })
|
||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(userOrgs.userId, userId),
|
eq(userOrgs.userId, userId),
|
||||||
eq(userOrgs.orgId, req.userOrgId!)
|
eq(userOrgs.orgId, req.userOrgId!)
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (userOrgRole.length === 0) {
|
if (userOrgRoles.length === 0) {
|
||||||
throw createHttpError(
|
throw createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"User does not have access to this organization"
|
"User does not have access to this organization"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
userOrgRoleId = userOrgRole[0].roleId;
|
userRoleIds = userOrgRoles.map((r) => r.roleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the user has direct permission for the action in the current org
|
// Check if the user has direct permission for the action in the current org
|
||||||
|
@ -155,8 +155,8 @@ export async function checkUserActionPermission(
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(roleActions.actionId, actionId),
|
eq(roleActions.actionId, actionId),
|
||||||
eq(roleActions.roleId, userOrgRoleId!),
|
eq(roleActions.orgId, req.userOrgId!),
|
||||||
eq(roleActions.orgId, req.userOrgId!)
|
inArray(roleActions.roleId, userRoleIds!)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { roleResources, userResources } from "@server/db/schemas";
|
import { roleResources, userResources } from "@server/db/schemas";
|
||||||
|
|
||||||
export async function canUserAccessResource({
|
export async function canUserAccessResource({
|
||||||
userId,
|
userId,
|
||||||
resourceId,
|
resourceId,
|
||||||
roleId
|
roleIds
|
||||||
}: {
|
}: {
|
||||||
userId: string;
|
userId: string;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
roleId: number;
|
roleIds: number[];
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const roleResourceAccess = await db
|
const roleResourceAccess = await db
|
||||||
.select()
|
.select()
|
||||||
|
@ -17,7 +17,7 @@ export async function canUserAccessResource({
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(roleResources.resourceId, resourceId),
|
eq(roleResources.resourceId, resourceId),
|
||||||
eq(roleResources.roleId, roleId)
|
inArray(roleResources.roleId, roleIds)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
|
@ -417,15 +417,6 @@ export const resourceRules = sqliteTable("resourceRules", {
|
||||||
value: text("value").notNull()
|
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
|
// Identity Providers
|
||||||
export const idp = sqliteTable("idp", {
|
export const idp = sqliteTable("idp", {
|
||||||
idpId: integer("idpId").primaryKey({ autoIncrement: true }),
|
idpId: integer("idpId").primaryKey({ autoIncrement: true }),
|
||||||
|
@ -458,12 +449,6 @@ export const idpOidcConfig = sqliteTable("idpOidcConfig", {
|
||||||
scopes: text("scopes").notNull()
|
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", {
|
export const hostMeta = sqliteTable("hostMeta", {
|
||||||
hostMetaId: text("hostMetaId").primaryKey().notNull(),
|
hostMetaId: text("hostMetaId").primaryKey().notNull(),
|
||||||
createdAt: integer("createdAt").notNull()
|
createdAt: integer("createdAt").notNull()
|
||||||
|
@ -543,8 +528,8 @@ export type ResourceWhitelist = InferSelectModel<typeof resourceWhitelist>;
|
||||||
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
export type VersionMigration = InferSelectModel<typeof versionMigrations>;
|
||||||
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
export type ResourceRule = InferSelectModel<typeof resourceRules>;
|
||||||
export type Domain = InferSelectModel<typeof domains>;
|
export type Domain = InferSelectModel<typeof domains>;
|
||||||
export type SupporterKey = InferSelectModel<typeof supporterKey>;
|
|
||||||
export type Idp = InferSelectModel<typeof idp>;
|
export type Idp = InferSelectModel<typeof idp>;
|
||||||
|
export type IdpOrg = InferSelectModel<typeof idpOrg>;
|
||||||
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
export type ApiKey = InferSelectModel<typeof apiKeys>;
|
||||||
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
export type ApiKeyAction = InferSelectModel<typeof apiKeyActions>;
|
||||||
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
export type ApiKeyOrg = InferSelectModel<typeof apiKeyOrg>;
|
||||||
|
|
|
@ -38,7 +38,7 @@ declare global {
|
||||||
session?: Session;
|
session?: Session;
|
||||||
userOrg?: UserOrg;
|
userOrg?: UserOrg;
|
||||||
apiKeyOrg?: ApiKeyOrg;
|
apiKeyOrg?: ApiKeyOrg;
|
||||||
userOrgRoleId?: number;
|
userRoleIds?: number[];
|
||||||
userOrgId?: string;
|
userOrgId?: string;
|
||||||
userOrgIds?: string[];
|
userOrgIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
import db from "@server/db";
|
import db from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import { roleResources, userResources } from "@server/db/schemas";
|
import { roleResources, userResources } from "@server/db/schemas";
|
||||||
|
|
||||||
export async function canUserAccessResource({
|
export async function canUserAccessResource({
|
||||||
userId,
|
userId,
|
||||||
resourceId,
|
resourceId,
|
||||||
roleId
|
roleIds
|
||||||
}: {
|
}: {
|
||||||
userId: string;
|
userId: string;
|
||||||
resourceId: number;
|
resourceId: number;
|
||||||
roleId: number;
|
roleIds: number[];
|
||||||
}): Promise<boolean> {
|
}): Promise<boolean> {
|
||||||
const roleResourceAccess = await db
|
const roleResourceAccess = await db
|
||||||
.select()
|
.select()
|
||||||
|
@ -17,7 +17,7 @@ export async function canUserAccessResource({
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(roleResources.resourceId, resourceId),
|
eq(roleResources.resourceId, resourceId),
|
||||||
eq(roleResources.roleId, roleId)
|
inArray(roleResources.roleId, roleIds)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
|
@ -10,10 +10,6 @@ import {
|
||||||
} from "@server/lib/consts";
|
} from "@server/lib/consts";
|
||||||
import { passwordSchema } from "@server/auth/passwordSchema";
|
import { passwordSchema } from "@server/auth/passwordSchema";
|
||||||
import stoi from "./stoi";
|
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);
|
const portSchema = z.number().positive().gt(0).lte(65535);
|
||||||
|
|
||||||
|
@ -225,10 +221,6 @@ const configSchema = z.object({
|
||||||
export class Config {
|
export class Config {
|
||||||
private rawConfig!: z.infer<typeof configSchema>;
|
private rawConfig!: z.infer<typeof configSchema>;
|
||||||
|
|
||||||
supporterData: SupporterKey | null = null;
|
|
||||||
|
|
||||||
supporterHiddenUntil: number | null = null;
|
|
||||||
|
|
||||||
isDev: boolean = process.env.ENVIRONMENT !== "prod";
|
isDev: boolean = process.env.ENVIRONMENT !== "prod";
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -317,20 +309,9 @@ export class Config {
|
||||||
: "false";
|
: "false";
|
||||||
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
process.env.DASHBOARD_URL = parsedConfig.data.app.dashboard_url;
|
||||||
|
|
||||||
license.setServerSecret(parsedConfig.data.server.secret);
|
|
||||||
|
|
||||||
this.checkKeyStatus();
|
|
||||||
|
|
||||||
this.rawConfig = parsedConfig.data;
|
this.rawConfig = parsedConfig.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async checkKeyStatus() {
|
|
||||||
const licenseStatus = await license.check();
|
|
||||||
if (!licenseStatus.isHostLicensed) {
|
|
||||||
this.checkSupporterKey();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public getRawConfig() {
|
public getRawConfig() {
|
||||||
return this.rawConfig;
|
return this.rawConfig;
|
||||||
}
|
}
|
||||||
|
@ -344,90 +325,6 @@ export class Config {
|
||||||
public getDomain(domainId: string) {
|
public getDomain(domainId: string) {
|
||||||
return this.rawConfig.domains[domainId];
|
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();
|
export const config = new Config();
|
||||||
|
|
|
@ -1,488 +0,0 @@
|
||||||
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;
|
|
|
@ -1,109 +0,0 @@
|
||||||
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 };
|
|
|
@ -17,6 +17,5 @@ export * from "./verifyAccessTokenAccess";
|
||||||
export * from "./verifyUserIsServerAdmin";
|
export * from "./verifyUserIsServerAdmin";
|
||||||
export * from "./verifyIsLoggedInUser";
|
export * from "./verifyIsLoggedInUser";
|
||||||
export * from "./integration";
|
export * from "./integration";
|
||||||
export * from "./verifyValidLicense";
|
|
||||||
export * from "./verifyUserHasAction";
|
export * from "./verifyUserHasAction";
|
||||||
export * from "./verifyApiKeyAccess";
|
export * from "./verifyApiKeyAccess";
|
||||||
|
|
|
@ -82,24 +82,24 @@ export async function verifyAccessTokenAccess(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
req.userOrg = res[0];
|
req.userOrg = res[0];
|
||||||
|
req.userRoleIds = res.map((r) => r.roleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg) {
|
||||||
next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"User does not have access to this organization"
|
"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({
|
const resourceAllowed = await canUserAccessResource({
|
||||||
userId,
|
userId,
|
||||||
resourceId,
|
resourceId,
|
||||||
roleId: req.userOrgRoleId!
|
roleIds: req.userRoleIds!
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resourceAllowed) {
|
if (!resourceAllowed) {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { roles, userOrgs } from "@server/db/schemas";
|
import { roles, userOrgs } from "@server/db/schemas";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
|
||||||
|
@ -29,9 +29,11 @@ export async function verifyAdmin(
|
||||||
const userOrgRes = await db
|
const userOrgRes = await db
|
||||||
.select()
|
.select()
|
||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId!)))
|
.where(
|
||||||
.limit(1);
|
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId!))
|
||||||
|
);
|
||||||
req.userOrg = userOrgRes[0];
|
req.userOrg = userOrgRes[0];
|
||||||
|
req.userRoleIds = userOrgRes.map((r) => r.roleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg) {
|
||||||
|
@ -43,13 +45,13 @@ export async function verifyAdmin(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRole = await db
|
const userAdminRole = await db
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(eq(roles.roleId, req.userOrg.roleId))
|
.where(and(inArray(roles.roleId, req.userRoleIds!), roles.isAdmin))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (userRole.length === 0 || !userRole[0].isAdmin) {
|
if (userAdminRole.length === 0) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
|
|
|
@ -70,9 +70,9 @@ export async function verifyApiKeyAccess(
|
||||||
eq(userOrgs.userId, userId),
|
eq(userOrgs.userId, userId),
|
||||||
eq(userOrgs.orgId, apiKeyOrg.orgId)
|
eq(userOrgs.orgId, apiKeyOrg.orgId)
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
.limit(1);
|
|
||||||
req.userOrg = userOrgRole[0];
|
req.userOrg = userOrgRole[0];
|
||||||
|
req.userRoleIds = userOrgRole.map((r) => r.roleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg) {
|
||||||
|
@ -84,9 +84,6 @@ export async function verifyApiKeyAccess(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userOrgRoleId = req.userOrg.roleId;
|
|
||||||
req.userOrgRoleId = userOrgRoleId;
|
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return next(
|
return next(
|
||||||
|
|
|
@ -34,21 +34,20 @@ export async function verifyOrgAccess(
|
||||||
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
|
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
|
||||||
);
|
);
|
||||||
req.userOrg = userOrgRes[0];
|
req.userOrg = userOrgRes[0];
|
||||||
|
req.userRoleIds = userOrgRes.map((r) => r.roleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg) {
|
||||||
next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"User does not have access to this organization"
|
"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;
|
req.userOrgId = orgId;
|
||||||
return next();
|
return next();
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|
|
@ -4,9 +4,9 @@ import {
|
||||||
resources,
|
resources,
|
||||||
userOrgs,
|
userOrgs,
|
||||||
userResources,
|
userResources,
|
||||||
roleResources,
|
roleResources
|
||||||
} from "@server/db/schemas";
|
} from "@server/db/schemas";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
|
||||||
|
@ -59,9 +59,9 @@ export async function verifyResourceAccess(
|
||||||
eq(userOrgs.userId, userId),
|
eq(userOrgs.userId, userId),
|
||||||
eq(userOrgs.orgId, resource[0].orgId)
|
eq(userOrgs.orgId, resource[0].orgId)
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
.limit(1);
|
|
||||||
req.userOrg = userOrgRole[0];
|
req.userOrg = userOrgRole[0];
|
||||||
|
req.userRoleIds = userOrgRole.map((r) => r.roleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg) {
|
||||||
|
@ -73,8 +73,6 @@ export async function verifyResourceAccess(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userOrgRoleId = req.userOrg.roleId;
|
|
||||||
req.userOrgRoleId = userOrgRoleId;
|
|
||||||
req.userOrgId = resource[0].orgId;
|
req.userOrgId = resource[0].orgId;
|
||||||
|
|
||||||
const roleResourceAccess = await db
|
const roleResourceAccess = await db
|
||||||
|
@ -83,7 +81,7 @@ export async function verifyResourceAccess(
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(roleResources.resourceId, resourceId),
|
eq(roleResources.resourceId, resourceId),
|
||||||
eq(roleResources.roleId, userOrgRoleId)
|
inArray(roleResources.roleId, req.userRoleIds!)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
|
@ -98,11 +98,10 @@ export async function verifyRoleAccess(
|
||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.where(
|
.where(
|
||||||
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId!))
|
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId!))
|
||||||
)
|
);
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
req.userOrg = userOrg[0];
|
req.userOrg = userOrg[0];
|
||||||
req.userOrgRoleId = userOrg[0].roleId;
|
req.userRoleIds = userOrg.map((r) => r.roleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
|
|
|
@ -5,9 +5,9 @@ import {
|
||||||
userOrgs,
|
userOrgs,
|
||||||
userSites,
|
userSites,
|
||||||
roleSites,
|
roleSites,
|
||||||
roles,
|
roles
|
||||||
} from "@server/db/schemas";
|
} from "@server/db/schemas";
|
||||||
import { and, eq, or } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
@ -71,6 +71,7 @@ export async function verifySiteAccess(
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
req.userOrg = userOrgRole[0];
|
req.userOrg = userOrgRole[0];
|
||||||
|
req.userRoleIds = userOrgRole.map((r) => r.roleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg) {
|
||||||
|
@ -82,8 +83,6 @@ export async function verifySiteAccess(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userOrgRoleId = req.userOrg.roleId;
|
|
||||||
req.userOrgRoleId = userOrgRoleId;
|
|
||||||
req.userOrgId = site[0].orgId;
|
req.userOrgId = site[0].orgId;
|
||||||
|
|
||||||
// Check role-based site access first
|
// Check role-based site access first
|
||||||
|
@ -93,7 +92,7 @@ export async function verifySiteAccess(
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(roleSites.siteId, siteId),
|
eq(roleSites.siteId, siteId),
|
||||||
eq(roleSites.roleId, userOrgRoleId)
|
inArray(roleSites.roleId, req.userRoleIds!)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
|
@ -88,24 +88,23 @@ export async function verifyTargetAccess(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
req.userOrg = res[0];
|
req.userOrg = res[0];
|
||||||
|
req.userRoleIds = res.map((r) => r.roleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg) {
|
||||||
next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"User does not have access to this organization"
|
"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({
|
const resourceAllowed = await canUserAccessResource({
|
||||||
userId,
|
userId,
|
||||||
resourceId,
|
resourceId,
|
||||||
roleId: req.userOrgRoleId!
|
roleIds: req.userRoleIds!
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resourceAllowed) {
|
if (!resourceAllowed) {
|
||||||
|
|
|
@ -33,9 +33,9 @@ export async function verifyUserAccess(
|
||||||
eq(userOrgs.userId, reqUserId),
|
eq(userOrgs.userId, reqUserId),
|
||||||
eq(userOrgs.orgId, req.userOrgId!)
|
eq(userOrgs.orgId, req.userOrgId!)
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
.limit(1);
|
|
||||||
req.userOrg = res[0];
|
req.userOrg = res[0];
|
||||||
|
req.userRoleIds = res.map((r) => r.roleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg) {
|
||||||
|
|
|
@ -12,7 +12,7 @@ export async function verifyUserInRole(
|
||||||
const roleId = parseInt(
|
const roleId = parseInt(
|
||||||
req.params.roleId || req.body.roleId || req.query.roleId
|
req.params.roleId || req.body.roleId || req.query.roleId
|
||||||
);
|
);
|
||||||
const userRoleId = req.userOrgRoleId;
|
const userRoleIds = req.userRoleIds;
|
||||||
|
|
||||||
if (isNaN(roleId)) {
|
if (isNaN(roleId)) {
|
||||||
return next(
|
return next(
|
||||||
|
@ -20,7 +20,7 @@ export async function verifyUserInRole(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!userRoleId) {
|
if (!userRoleIds) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
|
@ -29,7 +29,7 @@ export async function verifyUserInRole(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userRoleId !== roleId) {
|
if (userRoleIds.indexOf(roleId) === -1) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
|
|
|
@ -36,6 +36,7 @@ export async function verifyUserIsOrgOwner(
|
||||||
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
|
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
|
||||||
);
|
);
|
||||||
req.userOrg = res[0];
|
req.userOrg = res[0];
|
||||||
|
req.userRoleIds = res.map((r) => r.roleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!req.userOrg) {
|
if (!req.userOrg) {
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
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"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -208,7 +208,7 @@ export async function listAccessTokens(
|
||||||
.where(
|
.where(
|
||||||
or(
|
or(
|
||||||
eq(userResources.userId, req.user!.userId),
|
eq(userResources.userId, req.user!.userId),
|
||||||
eq(roleResources.roleId, req.userOrgRoleId!)
|
inArray(roleResources.roleId, req.userRoleIds!)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -8,10 +8,8 @@ import * as target from "./target";
|
||||||
import * as user from "./user";
|
import * as user from "./user";
|
||||||
import * as auth from "./auth";
|
import * as auth from "./auth";
|
||||||
import * as role from "./role";
|
import * as role from "./role";
|
||||||
import * as supporterKey from "./supporterKey";
|
|
||||||
import * as accessToken from "./accessToken";
|
import * as accessToken from "./accessToken";
|
||||||
import * as idp from "./idp";
|
import * as idp from "./idp";
|
||||||
import * as license from "./license";
|
|
||||||
import * as apiKeys from "./apiKeys";
|
import * as apiKeys from "./apiKeys";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import {
|
import {
|
||||||
|
@ -275,6 +273,14 @@ authenticated.get(
|
||||||
verifyUserHasAction(ActionsEnum.listRoles),
|
verifyUserHasAction(ActionsEnum.listRoles),
|
||||||
role.listRoles
|
role.listRoles
|
||||||
);
|
);
|
||||||
|
authenticated.post(
|
||||||
|
"/org/:orgId/user/:userId/roles",
|
||||||
|
verifyOrgAccess,
|
||||||
|
verifyUserAccess,
|
||||||
|
verifyUserHasAction(ActionsEnum.setUserRoles),
|
||||||
|
user.setUserRoles
|
||||||
|
);
|
||||||
|
|
||||||
// authenticated.get(
|
// authenticated.get(
|
||||||
// "/role/:roleId",
|
// "/role/:roleId",
|
||||||
// verifyRoleAccess,
|
// verifyRoleAccess,
|
||||||
|
@ -405,12 +411,6 @@ authenticated.get(
|
||||||
|
|
||||||
authenticated.get(`/org/:orgId/overview`, verifyOrgAccess, org.getOrgOverview);
|
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);
|
unauthenticated.get("/resource/:resourceId/auth", resource.getResourceAuthInfo);
|
||||||
|
|
||||||
// authenticated.get(
|
// authenticated.get(
|
||||||
|
@ -555,30 +555,6 @@ authenticated.get(
|
||||||
authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids
|
authenticated.get("/idp", idp.listIdps); // anyone can see this; it's just a list of idp names and ids
|
||||||
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
|
authenticated.get("/idp/:idpId", verifyUserIsServerAdmin, idp.getIdp);
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/license/activate",
|
|
||||||
verifyUserIsServerAdmin,
|
|
||||||
license.activateLicense
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/license/keys",
|
|
||||||
verifyUserIsServerAdmin,
|
|
||||||
license.listLicenseKeys
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
|
||||||
"/license/:licenseKey",
|
|
||||||
verifyUserIsServerAdmin,
|
|
||||||
license.deleteLicenseKey
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/license/recheck",
|
|
||||||
verifyUserIsServerAdmin,
|
|
||||||
license.recheckStatus
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
`/api-key/:apiKeyId`,
|
`/api-key/:apiKeyId`,
|
||||||
verifyUserIsServerAdmin,
|
verifyUserIsServerAdmin,
|
||||||
|
|
|
@ -7,7 +7,6 @@ import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import config from "@server/lib/config";
|
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { idp, idpOrg } from "@server/db/schemas";
|
import { idp, idpOrg } from "@server/db/schemas";
|
||||||
|
|
||||||
|
@ -51,16 +50,6 @@ export async function createIdpOrgPolicy(
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const parsedBody = bodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
const parsedParams = paramsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
return next(
|
return next(
|
||||||
|
@ -70,10 +59,20 @@ export async function createIdpOrgPolicy(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { idpId, orgId } = parsedParams.data;
|
const { idpId, orgId } = parsedParams.data;
|
||||||
const { roleMapping, orgMapping } = parsedBody.data;
|
|
||||||
|
|
||||||
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
|
if (!parsedBody.success) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
fromError(parsedBody.error).toString()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let { orgMapping, roleMapping } = parsedBody.data;
|
||||||
|
|
||||||
|
// Given identity provider must exist and not have a policy already
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(idp)
|
.from(idp)
|
||||||
|
@ -85,18 +84,15 @@ export async function createIdpOrgPolicy(
|
||||||
|
|
||||||
if (!existing?.idp) {
|
if (!existing?.idp) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(HttpCode.NOT_FOUND, "Idp does not exist")
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"An IDP with this ID does not exist."
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existing.idpOrg) {
|
if (existing.idpOrg) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.CONFLICT,
|
||||||
"An IDP org policy already exists."
|
"Org policy already exists for this idp"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -104,15 +100,15 @@ export async function createIdpOrgPolicy(
|
||||||
await db.insert(idpOrg).values({
|
await db.insert(idpOrg).values({
|
||||||
idpId,
|
idpId,
|
||||||
orgId,
|
orgId,
|
||||||
roleMapping,
|
orgMapping,
|
||||||
orgMapping
|
roleMapping
|
||||||
});
|
});
|
||||||
|
|
||||||
return response<CreateIdpOrgPolicyResponse>(res, {
|
return response<CreateIdpOrgPolicyResponse>(res, {
|
||||||
data: {},
|
data: {},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Idp created successfully",
|
message: "Idp org policy created successfully",
|
||||||
status: HttpCode.CREATED
|
status: HttpCode.CREATED
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -11,7 +11,6 @@ import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db/schemas";
|
||||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||||
import { encrypt } from "@server/lib/crypto";
|
import { encrypt } from "@server/lib/crypto";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import license from "@server/license/license";
|
|
||||||
|
|
||||||
const paramsSchema = z.object({}).strict();
|
const paramsSchema = z.object({}).strict();
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { idp, idpOrg } from "@server/db/schemas";
|
import { idp, idpOidcConfig, idpOrg } from "@server/db/schemas";
|
||||||
import { eq, and } from "drizzle-orm";
|
import { eq, and } from "drizzle-orm";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@ const paramsSchema = z
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "delete",
|
method: "delete",
|
||||||
path: "/idp/{idpId}/org/{orgId}",
|
path: "/idp/{idpId}/org/{orgId}",
|
||||||
description: "Create an OIDC IdP for an organization.",
|
description: "Delete an IDP policy for an IDP on an organization.",
|
||||||
tags: [OpenAPITags.Idp],
|
tags: [OpenAPITags.Idp],
|
||||||
request: {
|
request: {
|
||||||
params: paramsSchema
|
params: paramsSchema
|
||||||
|
@ -46,26 +46,27 @@ export async function deleteIdpOrgPolicy(
|
||||||
|
|
||||||
const { idpId, orgId } = parsedParams.data;
|
const { idpId, orgId } = parsedParams.data;
|
||||||
|
|
||||||
|
// Check if IDP policy, exists
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(idp)
|
.from(idp)
|
||||||
.leftJoin(idpOrg, eq(idpOrg.orgId, orgId))
|
.leftJoin(
|
||||||
.where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId)));
|
idpOrg,
|
||||||
|
and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId))
|
||||||
if (!existing.idp) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"An IDP with this ID does not exist."
|
|
||||||
)
|
)
|
||||||
|
.where(eq(idp.idpId, idpId));
|
||||||
|
|
||||||
|
if (!existing?.idp) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.NOT_FOUND, "Idp does not exist")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existing.idpOrg) {
|
if (!existing.idpOrg) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.NOT_FOUND,
|
||||||
"A policy for this IDP and org does not exist."
|
"Org policy does not exist for this idp"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -78,7 +79,7 @@ export async function deleteIdpOrgPolicy(
|
||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Policy deleted successfully",
|
message: "Idp policy deleted successfully",
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -7,5 +7,5 @@ export * from "./validateOidcCallback";
|
||||||
export * from "./getIdp";
|
export * from "./getIdp";
|
||||||
export * from "./createIdpOrgPolicy";
|
export * from "./createIdpOrgPolicy";
|
||||||
export * from "./deleteIdpOrgPolicy";
|
export * from "./deleteIdpOrgPolicy";
|
||||||
export * from "./listIdpOrgPolicies";
|
|
||||||
export * from "./updateIdpOrgPolicy";
|
export * from "./updateIdpOrgPolicy";
|
||||||
|
export * from "./listIdpOrgPolicies";
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { idpOrg } from "@server/db/schemas";
|
import { idpOrg, type IdpOrg } from "@server/db/schemas";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
|
@ -10,9 +10,11 @@ import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
|
||||||
const paramsSchema = z.object({
|
const paramsSchema = z
|
||||||
|
.object({
|
||||||
idpId: z.coerce.number()
|
idpId: z.coerce.number()
|
||||||
});
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
const querySchema = z
|
const querySchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -42,8 +44,12 @@ async function query(idpId: number, limit: number, offset: number) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListIdpOrgPoliciesResponse = {
|
export type ListIdpOrgPoliciesResponse = {
|
||||||
policies: NonNullable<Awaited<ReturnType<typeof query>>>;
|
policies: Array<IdpOrg>;
|
||||||
pagination: { total: number; limit: number; offset: number };
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
|
@ -73,6 +79,7 @@ export async function listIdpOrgPolicies(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { idpId } = parsedParams.data;
|
const { idpId } = parsedParams.data;
|
||||||
|
|
||||||
const parsedQuery = querySchema.safeParse(req.query);
|
const parsedQuery = querySchema.safeParse(req.query);
|
||||||
|
@ -104,7 +111,7 @@ export async function listIdpOrgPolicies(
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Policies retrieved successfully",
|
message: "Idp org policies retrieved successfully",
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
224
server/routers/idp/oidcAutoProvision.ts
Normal file
224
server/routers/idp/oidcAutoProvision.ts
Normal file
|
@ -0,0 +1,224 @@
|
||||||
|
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);
|
||||||
|
}
|
|
@ -7,8 +7,8 @@ import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { eq, and } from "drizzle-orm";
|
|
||||||
import { idp, idpOrg } from "@server/db/schemas";
|
import { idp, idpOrg } from "@server/db/schemas";
|
||||||
|
import { eq, and } from "drizzle-orm";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -59,6 +59,7 @@ export async function updateIdpOrgPolicy(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const { idpId, orgId } = parsedParams.data;
|
||||||
|
|
||||||
const parsedBody = bodySchema.safeParse(req.body);
|
const parsedBody = bodySchema.safeParse(req.body);
|
||||||
if (!parsedBody.success) {
|
if (!parsedBody.success) {
|
||||||
|
@ -69,11 +70,9 @@ export async function updateIdpOrgPolicy(
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
let { orgMapping, roleMapping } = parsedBody.data;
|
||||||
|
|
||||||
const { idpId, orgId } = parsedParams.data;
|
// Given identity provider must exist and have a policy already
|
||||||
const { roleMapping, orgMapping } = parsedBody.data;
|
|
||||||
|
|
||||||
// Check if IDP and policy exist
|
|
||||||
const [existing] = await db
|
const [existing] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(idp)
|
.from(idp)
|
||||||
|
@ -85,36 +84,36 @@ export async function updateIdpOrgPolicy(
|
||||||
|
|
||||||
if (!existing?.idp) {
|
if (!existing?.idp) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(HttpCode.NOT_FOUND, "Idp does not exist")
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"An IDP with this ID does not exist."
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!existing.idpOrg) {
|
if (!existing.idpOrg) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.NOT_FOUND,
|
||||||
"A policy for this IDP and org does not exist."
|
"Org policy does not exist for this idp"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the policy
|
|
||||||
await db
|
await db
|
||||||
.update(idpOrg)
|
.update(idpOrg)
|
||||||
.set({
|
.set({
|
||||||
roleMapping,
|
idpId,
|
||||||
orgMapping
|
orgId,
|
||||||
|
orgMapping,
|
||||||
|
roleMapping
|
||||||
})
|
})
|
||||||
.where(and(eq(idpOrg.idpId, idpId), eq(idpOrg.orgId, orgId)));
|
.where(and(eq(idpOrg.orgId, orgId), eq(idpOrg.idpId, idpId)));
|
||||||
|
|
||||||
return response<UpdateIdpOrgPolicyResponse>(res, {
|
return response<UpdateIdpOrgPolicyResponse>(res, {
|
||||||
data: {},
|
data: {
|
||||||
|
idpId
|
||||||
|
},
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Policy updated successfully",
|
message: "Idp org policy updated successfully",
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
@ -11,7 +11,6 @@ import { idp, idpOidcConfig } from "@server/db/schemas";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { encrypt } from "@server/lib/crypto";
|
import { encrypt } from "@server/lib/crypto";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import license from "@server/license/license";
|
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|
|
@ -9,13 +9,9 @@ import { fromError } from "zod-validation-error";
|
||||||
import {
|
import {
|
||||||
idp,
|
idp,
|
||||||
idpOidcConfig,
|
idpOidcConfig,
|
||||||
idpOrg,
|
|
||||||
orgs,
|
|
||||||
roles,
|
|
||||||
userOrgs,
|
|
||||||
users
|
users
|
||||||
} from "@server/db/schemas";
|
} from "@server/db/schemas";
|
||||||
import { and, eq, inArray } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import * as arctic from "arctic";
|
import * as arctic from "arctic";
|
||||||
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl";
|
||||||
import jmespath from "jmespath";
|
import jmespath from "jmespath";
|
||||||
|
@ -23,12 +19,11 @@ import jsonwebtoken from "jsonwebtoken";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import {
|
import {
|
||||||
createSession,
|
createSession,
|
||||||
generateId,
|
|
||||||
generateSessionToken,
|
generateSessionToken,
|
||||||
serializeSessionCookie
|
serializeSessionCookie
|
||||||
} from "@server/auth/sessions/app";
|
} from "@server/auth/sessions/app";
|
||||||
import { decrypt } from "@server/lib/crypto";
|
import { decrypt } from "@server/lib/crypto";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { oidcAutoProvision } from "./oidcAutoProvision";
|
||||||
|
|
||||||
const ensureTrailingSlash = (url: string): string => {
|
const ensureTrailingSlash = (url: string): string => {
|
||||||
return url;
|
return url;
|
||||||
|
@ -220,202 +215,16 @@ export async function validateOidcCallback(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (existingIdp.idp.autoProvision) {
|
if (existingIdp.idp.autoProvision) {
|
||||||
const allOrgs = await db.select().from(orgs);
|
await oidcAutoProvision({
|
||||||
|
idp: existingIdp.idp,
|
||||||
const defaultRoleMapping = existingIdp.idp.defaultRoleMapping;
|
userIdentifier,
|
||||||
const defaultOrgMapping = existingIdp.idp.defaultOrgMapping;
|
email,
|
||||||
|
name,
|
||||||
let userOrgInfo: { orgId: string; roleId: number }[] = [];
|
claims,
|
||||||
for (const org of allOrgs) {
|
existingUser,
|
||||||
const [idpOrgRes] = await db
|
req,
|
||||||
.select()
|
res
|
||||||
.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, {
|
return response<ValidateOidcUrlCallbackResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
|
@ -464,13 +273,3 @@ export async function validateOidcCallback(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hydrateOrgMapping(
|
|
||||||
orgMapping: string | null,
|
|
||||||
orgId: string
|
|
||||||
): string | undefined {
|
|
||||||
if (!orgMapping) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
return orgMapping.split("{{orgId}}").join(orgId);
|
|
||||||
}
|
|
||||||
|
|
|
@ -4,8 +4,6 @@ import * as traefik from "@server/routers/traefik";
|
||||||
import * as resource from "./resource";
|
import * as resource from "./resource";
|
||||||
import * as badger from "./badger";
|
import * as badger from "./badger";
|
||||||
import * as auth from "@server/routers/auth";
|
import * as auth from "@server/routers/auth";
|
||||||
import * as supporterKey from "@server/routers/supporterKey";
|
|
||||||
import * as license from "@server/routers/license";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import {
|
import {
|
||||||
verifyResourceAccess,
|
verifyResourceAccess,
|
||||||
|
@ -33,16 +31,6 @@ internalRouter.post(
|
||||||
resource.getExchangeToken
|
resource.getExchangeToken
|
||||||
);
|
);
|
||||||
|
|
||||||
internalRouter.get(
|
|
||||||
`/supporter-key/visible`,
|
|
||||||
supporterKey.isSupporterKeyVisible
|
|
||||||
);
|
|
||||||
|
|
||||||
internalRouter.get(
|
|
||||||
`/license/status`,
|
|
||||||
license.getLicenseStatus
|
|
||||||
);
|
|
||||||
|
|
||||||
// Gerbil routes
|
// Gerbil routes
|
||||||
const gerbilRouter = Router();
|
const gerbilRouter = Router();
|
||||||
internalRouter.use("/gerbil", gerbilRouter);
|
internalRouter.use("/gerbil", gerbilRouter);
|
||||||
|
|
|
@ -1,57 +0,0 @@
|
||||||
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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,73 +0,0 @@
|
||||||
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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,31 +0,0 @@
|
||||||
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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,5 +0,0 @@
|
||||||
export * from "./getLicenseStatus";
|
|
||||||
export * from "./activateLicense";
|
|
||||||
export * from "./listLicenseKeys";
|
|
||||||
export * from "./deleteLicenseKey";
|
|
||||||
export * from "./recheckStatus";
|
|
|
@ -1,31 +0,0 @@
|
||||||
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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,37 +0,0 @@
|
||||||
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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -49,7 +49,7 @@ export async function createNewt(
|
||||||
|
|
||||||
const { newtId, secret } = parsedBody.data;
|
const { newtId, secret } = parsedBody.data;
|
||||||
|
|
||||||
if (req.user && !req.userOrgRoleId) {
|
if (req.user && !req.userRoleIds) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||||
);
|
);
|
||||||
|
|
|
@ -11,12 +11,13 @@ import {
|
||||||
users,
|
users,
|
||||||
userSites
|
userSites
|
||||||
} from "@server/db/schemas";
|
} from "@server/db/schemas";
|
||||||
import { and, count, eq, inArray } from "drizzle-orm";
|
import { and, count, eq, inArray, countDistinct } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromZodError } from "zod-validation-error";
|
import { fromZodError } from "zod-validation-error";
|
||||||
|
import { RoleItem } from "../user/getOrgUser";
|
||||||
|
|
||||||
const getOrgParamsSchema = z
|
const getOrgParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -27,7 +28,7 @@ const getOrgParamsSchema = z
|
||||||
export type GetOrgOverviewResponse = {
|
export type GetOrgOverviewResponse = {
|
||||||
orgName: string;
|
orgName: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
userRoleName: string;
|
roles: RoleItem[];
|
||||||
numSites: number;
|
numSites: number;
|
||||||
numUsers: number;
|
numUsers: number;
|
||||||
numResources: number;
|
numResources: number;
|
||||||
|
@ -115,24 +116,25 @@ export async function getOrgOverview(
|
||||||
);
|
);
|
||||||
|
|
||||||
const [{ numUsers }] = await db
|
const [{ numUsers }] = await db
|
||||||
.select({ numUsers: count() })
|
.select({ numUsers: countDistinct(userOrgs.userId) })
|
||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.where(eq(userOrgs.orgId, orgId));
|
.where(eq(userOrgs.orgId, orgId));
|
||||||
|
|
||||||
const [role] = await db
|
const userRoles = await db
|
||||||
.select()
|
.select({ id: roles.roleId, name: roles.name })
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(eq(roles.roleId, req.userOrg.roleId));
|
.where(inArray(roles.roleId, req.userRoleIds ?? []))
|
||||||
|
.orderBy(roles.name);
|
||||||
|
|
||||||
return response<GetOrgOverviewResponse>(res, {
|
return response<GetOrgOverviewResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
orgName: org[0].name,
|
orgName: org[0].name,
|
||||||
orgId: org[0].orgId,
|
orgId: org[0].orgId,
|
||||||
userRoleName: role.name,
|
roles: userRoles,
|
||||||
numSites,
|
numSites,
|
||||||
numUsers,
|
numUsers,
|
||||||
numResources,
|
numResources,
|
||||||
isAdmin: role.name === "Admin",
|
isAdmin: userRoles.some((r) => r.name === "Admin"),
|
||||||
isOwner: req.userOrg?.isOwner || false
|
isOwner: req.userOrg?.isOwner || false
|
||||||
},
|
},
|
||||||
success: true,
|
success: true,
|
||||||
|
|
|
@ -130,7 +130,7 @@ export async function createResource(
|
||||||
|
|
||||||
const { siteId, orgId } = parsedParams.data;
|
const { siteId, orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (req.user && !req.userOrgRoleId) {
|
if (req.user && !req.userRoleIds) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||||
);
|
);
|
||||||
|
@ -285,7 +285,7 @@ async function createHttpResource(
|
||||||
resourceId: newResource[0].resourceId
|
resourceId: newResource[0].resourceId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
|
if (req.user && req.userRoleIds?.indexOf(adminRole[0].roleId) === -1) {
|
||||||
// make sure the user can access the resource
|
// make sure the user can access the resource
|
||||||
await trx.insert(userResources).values({
|
await trx.insert(userResources).values({
|
||||||
userId: req.user?.userId!,
|
userId: req.user?.userId!,
|
||||||
|
@ -392,7 +392,7 @@ async function createRawResource(
|
||||||
resourceId: newResource[0].resourceId
|
resourceId: newResource[0].resourceId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (req.userOrgRoleId != adminRole[0].roleId) {
|
if (req.userRoleIds?.indexOf(adminRole[0].roleId) === -1) {
|
||||||
// make sure the user can access the resource
|
// make sure the user can access the resource
|
||||||
await trx.insert(userResources).values({
|
await trx.insert(userResources).values({
|
||||||
userId: req.user?.userId!,
|
userId: req.user?.userId!,
|
||||||
|
|
|
@ -216,7 +216,7 @@ export async function listResources(
|
||||||
.where(
|
.where(
|
||||||
or(
|
or(
|
||||||
eq(userResources.userId, req.user!.userId),
|
eq(userResources.userId, req.user!.userId),
|
||||||
eq(roleResources.roleId, req.userOrgRoleId!)
|
inArray(roleResources.roleId, req.userRoleIds!)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -99,7 +99,7 @@ export async function createSite(
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
const { orgId } = parsedParams.data;
|
||||||
|
|
||||||
if (req.user && !req.userOrgRoleId) {
|
if (req.user && !req.userRoleIds) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
createHttpError(HttpCode.FORBIDDEN, "User does not have a role")
|
||||||
);
|
);
|
||||||
|
@ -176,7 +176,7 @@ export async function createSite(
|
||||||
siteId: newSite.siteId
|
siteId: newSite.siteId
|
||||||
});
|
});
|
||||||
|
|
||||||
if (req.user && req.userOrgRoleId != adminRole[0].roleId) {
|
if (req.user && req.userRoleIds?.indexOf(adminRole[0].roleId) === -1) {
|
||||||
// make sure the user can access the site
|
// make sure the user can access the site
|
||||||
trx.insert(userSites).values({
|
trx.insert(userSites).values({
|
||||||
userId: req.user?.userId!,
|
userId: req.user?.userId!,
|
||||||
|
|
|
@ -120,7 +120,7 @@ export async function listSites(
|
||||||
.where(
|
.where(
|
||||||
or(
|
or(
|
||||||
eq(userSites.userId, req.user!.userId),
|
eq(userSites.userId, req.user!.userId),
|
||||||
eq(roleSites.roleId, req.userOrgRoleId!)
|
inArray(roleSites.roleId, req.userRoleIds!)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -1,35 +0,0 @@
|
||||||
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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,3 +0,0 @@
|
||||||
export * from "./validateSupporterKey";
|
|
||||||
export * from "./isSupporterKeyVisible";
|
|
||||||
export * from "./hideSupporterKey";
|
|
|
@ -1,63 +0,0 @@
|
||||||
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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,115 +0,0 @@
|
||||||
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")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -105,14 +105,26 @@ export async function addUserRole(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const newUserRole = await db
|
const newUserRole = { orgId, userId, roleId, isOwner: false };
|
||||||
.update(userOrgs)
|
|
||||||
.set({ roleId })
|
await db.transaction(async (trx) => {
|
||||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
const hasRoleAlready = await trx
|
||||||
.returning();
|
.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return response(res, {
|
return response(res, {
|
||||||
data: newUserRole[0],
|
data: newUserRole,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Role added to user successfully",
|
message: "Role added to user successfully",
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { roles, userOrgs, users } from "@server/db/schemas";
|
import { roles, userOrgs, users } from "@server/db/schemas";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, sql } from "drizzle-orm";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
|
@ -10,6 +10,7 @@ import logger from "@server/logger";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { StringDecoder } from "string_decoder";
|
||||||
|
|
||||||
async function queryUser(orgId: string, userId: string) {
|
async function queryUser(orgId: string, userId: string) {
|
||||||
const [user] = await db
|
const [user] = await db
|
||||||
|
@ -20,8 +21,7 @@ async function queryUser(orgId: string, userId: string) {
|
||||||
username: users.username,
|
username: users.username,
|
||||||
name: users.name,
|
name: users.name,
|
||||||
type: users.type,
|
type: users.type,
|
||||||
roleId: userOrgs.roleId,
|
roles: sql<RoleItem[]>`json_group_array(json_object('id', ${roles.roleId}, 'name', ${roles.name}))`,
|
||||||
roleName: roles.name,
|
|
||||||
isOwner: userOrgs.isOwner,
|
isOwner: userOrgs.isOwner,
|
||||||
isAdmin: roles.isAdmin
|
isAdmin: roles.isAdmin
|
||||||
})
|
})
|
||||||
|
@ -30,9 +30,17 @@ async function queryUser(orgId: string, userId: string) {
|
||||||
.leftJoin(users, eq(userOrgs.userId, users.userId))
|
.leftJoin(users, eq(userOrgs.userId, users.userId))
|
||||||
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
.where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
if (typeof user.roles === "string") {
|
||||||
|
user.roles = JSON.parse(user.roles);
|
||||||
|
}
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type RoleItem = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type GetOrgUserResponse = NonNullable<
|
export type GetOrgUserResponse = NonNullable<
|
||||||
Awaited<ReturnType<typeof queryUser>>
|
Awaited<ReturnType<typeof queryUser>>
|
||||||
>;
|
>;
|
||||||
|
|
|
@ -2,6 +2,7 @@ export * from "./getUser";
|
||||||
export * from "./removeUserOrg";
|
export * from "./removeUserOrg";
|
||||||
export * from "./listUsers";
|
export * from "./listUsers";
|
||||||
export * from "./addUserRole";
|
export * from "./addUserRole";
|
||||||
|
export * from "./setUserRoles";
|
||||||
export * from "./inviteUser";
|
export * from "./inviteUser";
|
||||||
export * from "./acceptInvite";
|
export * from "./acceptInvite";
|
||||||
export * from "./getOrgUser";
|
export * from "./getOrgUser";
|
||||||
|
|
|
@ -5,11 +5,11 @@ import { idp, roles, userOrgs, users } from "@server/db/schemas";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { and, sql } from "drizzle-orm";
|
import { AnyColumn, eq, InferColumnsDataTypes, sql } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromZodError } from "zod-validation-error";
|
import { fromZodError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { eq } from "drizzle-orm";
|
import { RoleItem } from "./getOrgUser";
|
||||||
|
|
||||||
const listUsersParamsSchema = z
|
const listUsersParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
@ -34,8 +34,20 @@ const listUsersSchema = z
|
||||||
})
|
})
|
||||||
.strict();
|
.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) {
|
async function queryUsers(orgId: string, limit: number, offset: number) {
|
||||||
return await db
|
const res = await db
|
||||||
.select({
|
.select({
|
||||||
id: users.userId,
|
id: users.userId,
|
||||||
email: users.email,
|
email: users.email,
|
||||||
|
@ -45,8 +57,7 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
|
||||||
username: users.username,
|
username: users.username,
|
||||||
name: users.name,
|
name: users.name,
|
||||||
type: users.type,
|
type: users.type,
|
||||||
roleId: userOrgs.roleId,
|
roles: sql<RoleItem[]>`json_group_array(json_object('id', ${roles.roleId}, 'name', ${roles.name}))`,
|
||||||
roleName: roles.name,
|
|
||||||
isOwner: userOrgs.isOwner,
|
isOwner: userOrgs.isOwner,
|
||||||
idpName: idp.name,
|
idpName: idp.name,
|
||||||
idpId: users.idpId
|
idpId: users.idpId
|
||||||
|
@ -56,8 +67,15 @@ async function queryUsers(orgId: string, limit: number, offset: number) {
|
||||||
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
.leftJoin(roles, eq(userOrgs.roleId, roles.roleId))
|
||||||
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
.leftJoin(idp, eq(users.idpId, idp.idpId))
|
||||||
.where(eq(userOrgs.orgId, orgId))
|
.where(eq(userOrgs.orgId, orgId))
|
||||||
|
.groupBy(users.userId)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset);
|
.offset(offset);
|
||||||
|
res.forEach((itm) => {
|
||||||
|
if (typeof itm.roles === "string") {
|
||||||
|
itm.roles = JSON.parse(itm.roles);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ListUsersResponse = {
|
export type ListUsersResponse = {
|
||||||
|
|
175
server/routers/user/setUserRoles.ts
Normal file
175
server/routers/user/setUserRoles.ts
Normal file
|
@ -0,0 +1,175 @@
|
||||||
|
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")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -10,7 +10,8 @@ import {
|
||||||
CardFooter
|
CardFooter
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Users, Globe, Database, Cog, Settings, Waypoints, Combine } from "lucide-react";
|
import { Users, Settings, Waypoints, Combine } from "lucide-react";
|
||||||
|
import { RoleItem } from "@server/routers/user";
|
||||||
|
|
||||||
interface OrgStat {
|
interface OrgStat {
|
||||||
label: string;
|
label: string;
|
||||||
|
@ -26,7 +27,7 @@ type OrganizationLandingCardProps = {
|
||||||
resources: number;
|
resources: number;
|
||||||
users: number;
|
users: number;
|
||||||
};
|
};
|
||||||
userRole: string;
|
roles: RoleItem[];
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
|
@ -81,9 +82,21 @@ export default function OrganizationLandingCard(
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center text-lg">
|
<div className="text-center text-lg">
|
||||||
Your role:{" "}
|
Your role
|
||||||
|
{orgData.overview.isOwner ||
|
||||||
|
orgData.overview.isAdmin ||
|
||||||
|
orgData.overview.roles.length === 1
|
||||||
|
? ""
|
||||||
|
: "s"}
|
||||||
|
:{" "}
|
||||||
<span className="font-semibold">
|
<span className="font-semibold">
|
||||||
{orgData.overview.isOwner ? "Owner" : orgData.overview.userRole}
|
{orgData.overview.isOwner
|
||||||
|
? "Owner"
|
||||||
|
: orgData.overview.isAdmin
|
||||||
|
? "Admin"
|
||||||
|
: orgData.overview.roles
|
||||||
|
.map((r) => r.name)
|
||||||
|
.join(", ")}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
@ -74,7 +74,7 @@ export default async function OrgPage(props: OrgPageProps) {
|
||||||
},
|
},
|
||||||
isAdmin: overview.isAdmin,
|
isAdmin: overview.isAdmin,
|
||||||
isOwner: overview.isOwner,
|
isOwner: overview.isOwner,
|
||||||
userRole: overview.userRoleName
|
roles: overview.roles
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -150,7 +150,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||||
column.toggleSorting(column.getIsSorted() === "asc")
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Role
|
Roles
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
|
@ -8,17 +8,9 @@ import {
|
||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage
|
FormMessage
|
||||||
} from "@app/components/ui/form";
|
} 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 { toast } from "@app/hooks/useToast";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { InviteUserResponse } from "@server/routers/user";
|
import { SetUserRolesResponse } from "@server/routers/user";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
@ -40,10 +32,18 @@ import {
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
|
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
roleId: z.string().min(1, { message: "Please select a role" })
|
roles: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
text: z.string()
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.min(1, { message: "Please select a role" })
|
||||||
});
|
});
|
||||||
|
|
||||||
export default function AccessControlsPage() {
|
export default function AccessControlsPage() {
|
||||||
|
@ -54,13 +54,18 @@ export default function AccessControlsPage() {
|
||||||
const { orgId } = useParams();
|
const { orgId } = useParams();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>(
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const [activeRolesTagIndex, setActiveRolesTagIndex] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: user.username!,
|
username: user.username!,
|
||||||
roleId: user.roleId?.toString()
|
roles: []
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -81,13 +86,24 @@ export default function AccessControlsPage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res?.status === 200) {
|
if (res?.status === 200) {
|
||||||
setRoles(res.data.data.roles);
|
setAllRoles(
|
||||||
|
res.data.data.roles.map((role) => ({
|
||||||
|
id: role.roleId.toString(),
|
||||||
|
text: role.name
|
||||||
|
}))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchRoles();
|
fetchRoles();
|
||||||
|
|
||||||
form.setValue("roleId", user.roleId.toString());
|
form.setValue(
|
||||||
|
"roles",
|
||||||
|
user.roles.map((i) => ({
|
||||||
|
id: i.id.toString(),
|
||||||
|
text: i.name
|
||||||
|
}))
|
||||||
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
@ -95,8 +111,8 @@ export default function AccessControlsPage() {
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
.post<
|
.post<
|
||||||
AxiosResponse<InviteUserResponse>
|
AxiosResponse<SetUserRolesResponse>
|
||||||
>(`/role/${values.roleId}/add/${user.userId}`)
|
>(`/org/${user.orgId}/user/${user.userId}/roles`, { roleIds: values.roles.map((r) => parseInt(r.id)) })
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
|
@ -140,30 +156,44 @@ export default function AccessControlsPage() {
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="roleId"
|
name="roles"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem className="flex flex-col items-start">
|
||||||
<FormLabel>Role</FormLabel>
|
<FormLabel>Roles</FormLabel>
|
||||||
<Select
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
value={field.value}
|
|
||||||
>
|
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<SelectTrigger>
|
<TagInput
|
||||||
<SelectValue placeholder="Select role" />
|
{...field}
|
||||||
</SelectTrigger>
|
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>
|
</FormControl>
|
||||||
<SelectContent>
|
|
||||||
{roles.map((role) => (
|
|
||||||
<SelectItem
|
|
||||||
key={role.roleId}
|
|
||||||
value={role.roleId.toString()}
|
|
||||||
>
|
|
||||||
{role.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -78,7 +78,7 @@ export default async function UsersPage(props: UsersPageProps) {
|
||||||
idpId: user.idpId,
|
idpId: user.idpId,
|
||||||
idpName: user.idpName || "Internal",
|
idpName: user.idpName || "Internal",
|
||||||
status: "Confirmed",
|
status: "Confirmed",
|
||||||
role: user.isOwner ? "Owner" : user.roleName || "Member",
|
role: user.isOwner ? "Owner" : user.roles.map((r) => r.name).join(", ") || "Member",
|
||||||
isOwner: user.isOwner || false
|
isOwner: user.isOwner || false
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -42,7 +42,6 @@ import {
|
||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
|
||||||
|
|
||||||
const GeneralFormSchema = z.object({
|
const GeneralFormSchema = z.object({
|
||||||
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
|
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
|
||||||
|
@ -68,7 +67,6 @@ export default function GeneralPage() {
|
||||||
const { idpId } = useParams();
|
const { idpId } = useParams();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [initialLoading, setInitialLoading] = useState(true);
|
const [initialLoading, setInitialLoading] = useState(true);
|
||||||
const { isUnlocked } = useLicenseStatusContext();
|
|
||||||
|
|
||||||
const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
|
const redirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`;
|
||||||
|
|
||||||
|
|
|
@ -4,17 +4,7 @@ import { AxiosResponse } from "axios";
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
import { authCookieHeader } from "@app/lib/api/cookies";
|
||||||
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
import { HorizontalTabs } from "@app/components/HorizontalTabs";
|
||||||
import { ProfessionalContentOverlay } from "@app/components/ProfessionalContentOverlay";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { ArrowLeft } from "lucide-react";
|
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator
|
|
||||||
} from "@app/components/ui/breadcrumb";
|
|
||||||
|
|
||||||
interface SettingsLayoutProps {
|
interface SettingsLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
|
@ -4,20 +4,15 @@ import { ColumnDef } from "@tanstack/react-table";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import {
|
import {
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
Trash2,
|
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Pencil,
|
|
||||||
ArrowRight
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { PolicyDataTable } from "./PolicyDataTable";
|
import { PolicyDataTable } from "./PolicyDataTable";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import Link from "next/link";
|
|
||||||
import { InfoPopup } from "@app/components/ui/info-popup";
|
import { InfoPopup } from "@app/components/ui/info-popup";
|
||||||
|
|
||||||
export interface PolicyRow {
|
export interface PolicyRow {
|
||||||
|
|
|
@ -36,7 +36,6 @@ import { InfoIcon, ExternalLink } from "lucide-react";
|
||||||
import { StrategySelect } from "@app/components/StrategySelect";
|
import { StrategySelect } from "@app/components/StrategySelect";
|
||||||
import { SwitchInput } from "@app/components/SwitchInput";
|
import { SwitchInput } from "@app/components/SwitchInput";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
|
||||||
|
|
||||||
const createIdpFormSchema = z.object({
|
const createIdpFormSchema = z.object({
|
||||||
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
|
name: z.string().min(2, { message: "Name must be at least 2 characters." }),
|
||||||
|
@ -75,7 +74,6 @@ export default function Page() {
|
||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const { isUnlocked } = useLicenseStatusContext();
|
|
||||||
|
|
||||||
const form = useForm<CreateIdpFormValues>({
|
const form = useForm<CreateIdpFormValues>({
|
||||||
resolver: zodResolver(createIdpFormSchema),
|
resolver: zodResolver(createIdpFormSchema),
|
||||||
|
|
|
@ -1,142 +0,0 @@
|
||||||
"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"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,132 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,545 +0,0 @@
|
||||||
"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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -15,7 +15,6 @@ import {
|
||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
import { Loader2, CheckCircle2, AlertCircle } from "lucide-react";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
|
||||||
|
|
||||||
type ValidateOidcTokenParams = {
|
type ValidateOidcTokenParams = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
|
@ -34,8 +33,6 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const { licenseStatus, isLicenseViolation } = useLicenseStatusContext();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function validate() {
|
async function validate() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
@ -46,10 +43,6 @@ export default function ValidateOidcToken(props: ValidateOidcTokenParams) {
|
||||||
stateCookie: props.stateCookie
|
stateCookie: props.stateCookie
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isLicenseViolation()) {
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await api.post<
|
const res = await api.post<
|
||||||
AxiosResponse<ValidateOidcUrlCallbackResponse>
|
AxiosResponse<ValidateOidcUrlCallbackResponse>
|
||||||
|
|
|
@ -1,10 +1,7 @@
|
||||||
import ProfileIcon from "@app/components/ProfileIcon";
|
import ProfileIcon from "@app/components/ProfileIcon";
|
||||||
import { Separator } from "@app/components/ui/separator";
|
import { Separator } from "@app/components/ui/separator";
|
||||||
import { priv } from "@app/lib/api";
|
|
||||||
import { verifySession } from "@app/lib/auth/verifySession";
|
import { verifySession } from "@app/lib/auth/verifySession";
|
||||||
import UserProvider from "@app/providers/UserProvider";
|
import UserProvider from "@app/providers/UserProvider";
|
||||||
import { GetLicenseStatusResponse } from "@server/routers/license";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import { Metadata } from "next";
|
import { Metadata } from "next";
|
||||||
import { cache } from "react";
|
import { cache } from "react";
|
||||||
|
@ -22,14 +19,6 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||||
const getUser = cache(verifySession);
|
const getUser = cache(verifySession);
|
||||||
const user = await getUser();
|
const user = await getUser();
|
||||||
|
|
||||||
const licenseStatusRes = await cache(
|
|
||||||
async () =>
|
|
||||||
await priv.get<AxiosResponse<GetLicenseStatusResponse>>(
|
|
||||||
"/license/status"
|
|
||||||
)
|
|
||||||
)();
|
|
||||||
const licenseStatus = licenseStatusRes.data.data;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
{user && (
|
{user && (
|
||||||
|
@ -44,9 +33,6 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||||
<div className="w-full max-w-md p-3">{children}</div>
|
<div className="w-full max-w-md p-3">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!(
|
|
||||||
licenseStatus.isHostLicensed && licenseStatus.isLicenseValid
|
|
||||||
) && (
|
|
||||||
<footer className="hidden md:block w-full mt-12 py-3 mb-6 px-4">
|
<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="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">
|
<div className="flex items-center space-x-2 whitespace-nowrap">
|
||||||
|
@ -65,13 +51,13 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||||
</a>
|
</a>
|
||||||
<Separator orientation="vertical" />
|
<Separator orientation="vertical" />
|
||||||
<a
|
<a
|
||||||
href="https://github.com/fosrl/pangolin"
|
href="https://code.thetadev.de/ThetaDev/pangolin"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
aria-label="GitHub"
|
aria-label="Repository"
|
||||||
className="flex items-center space-x-2 whitespace-nowrap"
|
className="flex items-center space-x-2 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<span>Community Edition</span>
|
<span>Open Source</span>
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
@ -83,7 +69,6 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,7 +43,6 @@ import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSupporterStatusContext } from "@app/hooks/useSupporterStatusContext";
|
|
||||||
|
|
||||||
const pinSchema = z.object({
|
const pinSchema = z.object({
|
||||||
pin: z
|
pin: z
|
||||||
|
@ -110,8 +109,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
|
|
||||||
const api = createApiClient({ env });
|
const api = createApiClient({ env });
|
||||||
|
|
||||||
const { supporterStatus } = useSupporterStatusContext();
|
|
||||||
|
|
||||||
function getDefaultSelectedMethod() {
|
function getDefaultSelectedMethod() {
|
||||||
if (props.methods.sso) {
|
if (props.methods.sso) {
|
||||||
return "sso";
|
return "sso";
|
||||||
|
@ -282,7 +279,7 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
Powered by{" "}
|
Powered by{" "}
|
||||||
<Link
|
<Link
|
||||||
href="https://github.com/fosrl/pangolin"
|
href="https://code.thetadev.de/ThetaDev/pangolin"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="underline"
|
className="underline"
|
||||||
|
@ -634,15 +631,6 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ResourceAccessDenied />
|
<ResourceAccessDenied />
|
||||||
|
|
|
@ -1,62 +0,0 @@
|
||||||
"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;
|
|
||||||
}
|
|
|
@ -5,14 +5,6 @@ import { Toaster } from "@/components/ui/toaster";
|
||||||
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
import { ThemeProvider } from "@app/providers/ThemeProvider";
|
||||||
import EnvProvider from "@app/providers/EnvProvider";
|
import EnvProvider from "@app/providers/EnvProvider";
|
||||||
import { pullEnv } from "@app/lib/pullEnv";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: `Dashboard - Pangolin`,
|
title: `Dashboard - Pangolin`,
|
||||||
|
@ -31,24 +23,6 @@ export default async function RootLayout({
|
||||||
}>) {
|
}>) {
|
||||||
const env = pullEnv();
|
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 (
|
return (
|
||||||
<html suppressHydrationWarning>
|
<html suppressHydrationWarning>
|
||||||
<body className={`${font.className} h-screen overflow-hidden`}>
|
<body className={`${font.className} h-screen overflow-hidden`}>
|
||||||
|
@ -59,19 +33,12 @@ export default async function RootLayout({
|
||||||
disableTransitionOnChange
|
disableTransitionOnChange
|
||||||
>
|
>
|
||||||
<EnvProvider env={pullEnv()}>
|
<EnvProvider env={pullEnv()}>
|
||||||
<LicenseStatusProvider licenseStatus={licenseStatus}>
|
|
||||||
<SupportStatusProvider
|
|
||||||
supporterStatus={supporterData}
|
|
||||||
>
|
|
||||||
{/* Main content */}
|
{/* Main content */}
|
||||||
<div className="h-full flex flex-col">
|
<div className="h-full flex flex-col">
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<LicenseViolation />
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SupportStatusProvider>
|
|
||||||
</LicenseStatusProvider>
|
|
||||||
</EnvProvider>
|
</EnvProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|
|
@ -8,7 +8,6 @@ import {
|
||||||
Combine,
|
Combine,
|
||||||
Fingerprint,
|
Fingerprint,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
TicketCheck
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
export const orgLangingNavItems: SidebarNavItem[] = [
|
export const orgLangingNavItems: SidebarNavItem[] = [
|
||||||
|
@ -92,10 +91,5 @@ export const adminNavItems: SidebarNavItem[] = [
|
||||||
title: "Identity Providers",
|
title: "Identity Providers",
|
||||||
href: "/admin/idp",
|
href: "/admin/idp",
|
||||||
icon: <Fingerprint className="h-4 w-4" />
|
icon: <Fingerprint className="h-4 w-4" />
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "License",
|
|
||||||
href: "/admin/license",
|
|
||||||
icon: <TicketCheck className="h-4 w-4" />
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
|
@ -4,15 +4,11 @@ import React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams, usePathname } from "next/navigation";
|
import { useParams, usePathname } from "next/navigation";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { buttonVariants } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@app/components/ui/badge";
|
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
|
||||||
|
|
||||||
export type HorizontalTabs = Array<{
|
export type HorizontalTabs = Array<{
|
||||||
title: string;
|
title: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
showProfessional?: boolean;
|
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
interface HorizontalTabsProps {
|
interface HorizontalTabsProps {
|
||||||
|
@ -28,7 +24,6 @@ export function HorizontalTabs({
|
||||||
}: HorizontalTabsProps) {
|
}: HorizontalTabsProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
|
||||||
|
|
||||||
function hydrateHref(href: string) {
|
function hydrateHref(href: string) {
|
||||||
return href
|
return href
|
||||||
|
@ -49,46 +44,34 @@ export function HorizontalTabs({
|
||||||
const isActive =
|
const isActive =
|
||||||
pathname.startsWith(hydratedHref) &&
|
pathname.startsWith(hydratedHref) &&
|
||||||
!pathname.includes("create");
|
!pathname.includes("create");
|
||||||
const isProfessional =
|
|
||||||
item.showProfessional && !isUnlocked();
|
|
||||||
const isDisabled =
|
|
||||||
disabled || (isProfessional && !isUnlocked());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={hydratedHref}
|
key={hydratedHref}
|
||||||
href={isProfessional ? "#" : hydratedHref}
|
href={hydratedHref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap",
|
"px-4 py-2 text-sm font-medium transition-colors whitespace-nowrap",
|
||||||
isActive
|
isActive
|
||||||
? "border-b-2 border-primary text-primary"
|
? "border-b-2 border-primary text-primary"
|
||||||
: "text-muted-foreground hover:text-foreground",
|
: "text-muted-foreground hover:text-foreground",
|
||||||
isDisabled && "cursor-not-allowed"
|
disabled && "cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (isDisabled) {
|
if (disabled) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
tabIndex={isDisabled ? -1 : undefined}
|
tabIndex={disabled ? -1 : undefined}
|
||||||
aria-disabled={isDisabled}
|
aria-disabled={disabled}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center space-x-2",
|
"flex items-center space-x-2",
|
||||||
isDisabled && "opacity-60"
|
disabled && "opacity-60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon && item.icon}
|
{item.icon && item.icon}
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
{isProfessional && (
|
|
||||||
<Badge
|
|
||||||
variant="outlinePrimary"
|
|
||||||
className="ml-2"
|
|
||||||
>
|
|
||||||
Professional
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
|
@ -5,7 +5,6 @@ import { SidebarNav } from "@app/components/SidebarNav";
|
||||||
import { OrgSelector } from "@app/components/OrgSelector";
|
import { OrgSelector } from "@app/components/OrgSelector";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { ListUserOrgsResponse } from "@server/routers/org";
|
import { ListUserOrgsResponse } from "@server/routers/org";
|
||||||
import SupporterStatus from "@app/components/SupporterStatus";
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { ExternalLink, Menu, X, Server } from "lucide-react";
|
import { ExternalLink, Menu, X, Server } from "lucide-react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
@ -22,7 +21,6 @@ import { Breadcrumbs } from "@app/components/Breadcrumbs";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
@ -59,7 +57,6 @@ export function Layout({
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const isAdminPage = pathname?.startsWith("/admin");
|
const isAdminPage = pathname?.startsWith("/admin");
|
||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
const { isUnlocked } = useLicenseStatusContext();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen overflow-hidden">
|
<div className="flex flex-col h-screen overflow-hidden">
|
||||||
|
@ -120,7 +117,6 @@ export function Layout({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 space-y-4 border-t shrink-0">
|
<div className="p-4 space-y-4 border-t shrink-0">
|
||||||
<SupporterStatus />
|
|
||||||
<OrgSelector
|
<OrgSelector
|
||||||
orgId={orgId}
|
orgId={orgId}
|
||||||
orgs={orgs}
|
orgs={orgs}
|
||||||
|
@ -199,19 +195,16 @@ export function Layout({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 space-y-4 border-t shrink-0">
|
<div className="p-4 space-y-4 border-t shrink-0">
|
||||||
<SupporterStatus />
|
|
||||||
<OrgSelector orgId={orgId} orgs={orgs} />
|
<OrgSelector orgId={orgId} orgs={orgs} />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs text-muted-foreground text-center">
|
<div className="text-xs text-muted-foreground text-center">
|
||||||
<Link
|
<Link
|
||||||
href="https://github.com/fosrl/pangolin"
|
href="https://code.thetadev.de/ThetaDev/pangolin"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center justify-center gap-1"
|
className="flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
{!isUnlocked()
|
Open Source
|
||||||
? "Community Edition"
|
|
||||||
: "Commercial Edition"}
|
|
||||||
<ExternalLink size={12} />
|
<ExternalLink size={12} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,37 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -14,14 +14,13 @@ import {
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { Laptop, LogOut, Moon, Sun } from "lucide-react";
|
import { Laptop, Moon, Sun } from "lucide-react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import Disable2FaForm from "./Disable2FaForm";
|
import Disable2FaForm from "./Disable2FaForm";
|
||||||
import Enable2FaForm from "./Enable2FaForm";
|
import Enable2FaForm from "./Enable2FaForm";
|
||||||
import SupporterStatus from "./SupporterStatus";
|
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
|
|
||||||
export default function ProfileIcon() {
|
export default function ProfileIcon() {
|
||||||
|
|
|
@ -6,8 +6,6 @@ import { useParams, usePathname } from "next/navigation";
|
||||||
import { cn } from "@app/lib/cn";
|
import { cn } from "@app/lib/cn";
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
import { useUserContext } from "@app/hooks/useUserContext";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
|
||||||
|
|
||||||
export interface SidebarNavItem {
|
export interface SidebarNavItem {
|
||||||
href: string;
|
href: string;
|
||||||
|
@ -15,7 +13,6 @@ export interface SidebarNavItem {
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
children?: SidebarNavItem[];
|
children?: SidebarNavItem[];
|
||||||
autoExpand?: boolean;
|
autoExpand?: boolean;
|
||||||
showProfessional?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
export interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
|
||||||
|
@ -61,7 +58,6 @@ export function SidebarNav({
|
||||||
findAutoExpandedAndActivePath(items);
|
findAutoExpandedAndActivePath(items);
|
||||||
return autoExpanded;
|
return autoExpanded;
|
||||||
});
|
});
|
||||||
const { licenseStatus, isUnlocked } = useLicenseStatusContext();
|
|
||||||
|
|
||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
|
|
||||||
|
@ -92,8 +88,6 @@ export function SidebarNav({
|
||||||
const hasChildren = item.children && item.children.length > 0;
|
const hasChildren = item.children && item.children.length > 0;
|
||||||
const isExpanded = expandedItems.has(hydratedHref);
|
const isExpanded = expandedItems.has(hydratedHref);
|
||||||
const indent = level * 28; // Base indent for each level
|
const indent = level * 28; // Base indent for each level
|
||||||
const isProfessional = item.showProfessional && !isUnlocked();
|
|
||||||
const isDisabled = disabled || isProfessional;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={hydratedHref}>
|
<div key={hydratedHref}>
|
||||||
|
@ -108,28 +102,28 @@ export function SidebarNav({
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Link
|
<Link
|
||||||
href={isProfessional ? "#" : hydratedHref}
|
href={hydratedHref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center w-full px-3 py-2",
|
"flex items-center w-full px-3 py-2",
|
||||||
isActive
|
isActive
|
||||||
? "text-primary font-medium"
|
? "text-primary font-medium"
|
||||||
: "text-muted-foreground group-hover:text-foreground",
|
: "text-muted-foreground group-hover:text-foreground",
|
||||||
isDisabled && "cursor-not-allowed"
|
disabled && "cursor-not-allowed"
|
||||||
)}
|
)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (isDisabled) {
|
if (disabled) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (onItemClick) {
|
} else if (onItemClick) {
|
||||||
onItemClick();
|
onItemClick();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
tabIndex={isDisabled ? -1 : undefined}
|
tabIndex={disabled ? -1 : undefined}
|
||||||
aria-disabled={isDisabled}
|
aria-disabled={disabled}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex items-center",
|
"flex items-center",
|
||||||
isDisabled && "opacity-60"
|
disabled && "opacity-60"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon && (
|
{item.icon && (
|
||||||
|
@ -139,20 +133,12 @@ export function SidebarNav({
|
||||||
)}
|
)}
|
||||||
{item.title}
|
{item.title}
|
||||||
</div>
|
</div>
|
||||||
{isProfessional && (
|
|
||||||
<Badge
|
|
||||||
variant="outlinePrimary"
|
|
||||||
className="ml-2"
|
|
||||||
>
|
|
||||||
Professional
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
{hasChildren && (
|
{hasChildren && (
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleItem(hydratedHref)}
|
onClick={() => toggleItem(hydratedHref)}
|
||||||
className="p-2 rounded-md text-muted-foreground hover:text-foreground cursor-pointer"
|
className="p-2 rounded-md text-muted-foreground hover:text-foreground cursor-pointer"
|
||||||
disabled={isDisabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<ChevronDown className="h-5 w-5" />
|
<ChevronDown className="h-5 w-5" />
|
||||||
|
|
|
@ -1,434 +0,0 @@
|
||||||
"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}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
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;
|
|
|
@ -1,17 +0,0 @@
|
||||||
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;
|
|
|
@ -1,12 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
|
@ -1,12 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
|
@ -1,67 +0,0 @@
|
||||||
"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;
|
|
|
@ -1,46 +0,0 @@
|
||||||
"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;
|
|
Loading…
Add table
Reference in a new issue