mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-30 12:09:51 +00:00
Compare commits
71 Commits
exit-node-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0d1291cff | ||
|
|
1215aa8122 | ||
|
|
d318a756a8 | ||
|
|
b3c1e49c0c | ||
|
|
dc12b00502 | ||
|
|
1e27acbf88 | ||
|
|
4012cc658d | ||
|
|
84d7a87609 | ||
|
|
9a92be532a | ||
|
|
18ac542e30 | ||
|
|
c74b423bae | ||
|
|
6d17bb04c4 | ||
|
|
957e7ba127 | ||
|
|
def710cba8 | ||
|
|
44da854575 | ||
|
|
d7d37c6f6e | ||
|
|
3c80b9a229 | ||
|
|
a998a35482 | ||
|
|
20e0e5ebd0 | ||
|
|
4d831effe1 | ||
|
|
80f4dd0e60 | ||
|
|
eafa3076d8 | ||
|
|
fef3cd8354 | ||
|
|
36ada0705e | ||
|
|
8ae3c06df7 | ||
|
|
ba127a8536 | ||
|
|
5c024f3a3a | ||
|
|
4fdb8583f6 | ||
|
|
2946df3b8e | ||
|
|
c3b0c4e5e9 | ||
|
|
a79d0f1677 | ||
|
|
bfd7a7f561 | ||
|
|
cf12ab1ac3 | ||
|
|
ddabfb5ca1 | ||
|
|
ec0666a612 | ||
|
|
bbf42c5802 | ||
|
|
6aa1d3b094 | ||
|
|
f1ec1a2fb1 | ||
|
|
32fcf90467 | ||
|
|
5a53f88fd6 | ||
|
|
51971c7ef2 | ||
|
|
491096109a | ||
|
|
802a41b1bd | ||
|
|
f59fbabede | ||
|
|
5a7d54058e | ||
|
|
5ef4490692 | ||
|
|
817e848d08 | ||
|
|
166c8326c5 | ||
|
|
673f1e93f4 | ||
|
|
35ad235f49 | ||
|
|
834672c846 | ||
|
|
b8180d848a | ||
|
|
fef7563e14 | ||
|
|
6337cf4359 | ||
|
|
b3cfe82dff | ||
|
|
d65128671c | ||
|
|
41fdd5de74 | ||
|
|
2704202ba9 | ||
|
|
72ef0ae020 | ||
|
|
1442faa740 | ||
|
|
6aa589e612 | ||
|
|
4b1a8e14c4 | ||
|
|
1a0db10b1a | ||
|
|
b7634086db | ||
|
|
73e9e830c3 | ||
|
|
a6469e67a8 | ||
|
|
1ba75092f9 | ||
|
|
82745c701a | ||
|
|
e4fd2b656d | ||
|
|
4786fc3a31 | ||
|
|
f286d66cbc |
8
.github/workflows/cicd.yml
vendored
8
.github/workflows/cicd.yml
vendored
@@ -77,7 +77,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -149,7 +149,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -204,7 +204,7 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
- name: Log in to Docker Hub
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: docker.io
|
registry: docker.io
|
||||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
@@ -407,7 +407,7 @@ jobs:
|
|||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry (for cosign)
|
- name: Login to GitHub Container Registry (for cosign)
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
|
|||||||
2
.github/workflows/linting.yml
vendored
2
.github/workflows/linting.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
|
|||||||
2
.github/workflows/mirror.yaml
vendored
2
.github/workflows/mirror.yaml
vendored
@@ -23,7 +23,7 @@ jobs:
|
|||||||
skopeo --version
|
skopeo --version
|
||||||
|
|
||||||
- name: Install cosign
|
- name: Install cosign
|
||||||
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
|
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
|
||||||
|
|
||||||
- name: Input check
|
- name: Input check
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
2
.github/workflows/stale-bot.yml
vendored
2
.github/workflows/stale-bot.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
|||||||
stale:
|
stale:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||||
with:
|
with:
|
||||||
days-before-stale: 14
|
days-before-stale: 14
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
|
|||||||
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
|||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
|
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||||
with:
|
with:
|
||||||
node-version: '24'
|
node-version: '24'
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { APP_PATH } from "@server/lib/consts";
|
import { APP_PATH } from "./server/lib/consts";
|
||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ server:
|
|||||||
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||||
allowed_headers: ["X-CSRF-Token", "Content-Type"]
|
allowed_headers: ["X-CSRF-Token", "Content-Type"]
|
||||||
credentials: false
|
credentials: false
|
||||||
{{if .EnableGeoblocking}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
|
{{if .EnableMaxMind}}maxmind_db_path: "./config/GeoLite2-Country.mmdb"{{end}}
|
||||||
|
{{if .EnableMaxMind}}maxmind_asn_path: "./config/GeoLite2-ASN.mmdb"{{end}}
|
||||||
{{if .EnableEmail}}
|
{{if .EnableEmail}}
|
||||||
email:
|
email:
|
||||||
smtp_host: "{{.EmailSMTPHost}}"
|
smtp_host: "{{.EmailSMTPHost}}"
|
||||||
@@ -36,3 +37,8 @@ flags:
|
|||||||
disable_signup_without_invite: true
|
disable_signup_without_invite: true
|
||||||
disable_user_create_org: false
|
disable_user_create_org: false
|
||||||
allow_raw_resources: true
|
allow_raw_resources: true
|
||||||
|
|
||||||
|
{{if .IsPostgreSQL}}
|
||||||
|
postgres:
|
||||||
|
connection_string: postgresql://pangolin:{{.IsPostgreSQLPass}}@postgres:5432/pangolin
|
||||||
|
{{end}}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: pangolin
|
name: pangolin
|
||||||
services:
|
services:
|
||||||
pangolin:
|
pangolin:
|
||||||
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}}
|
image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{if .IsPostgreSQL}}postgresql-{{end}}{{.PangolinVersion}}
|
||||||
container_name: pangolin
|
container_name: pangolin
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
deploy:
|
deploy:
|
||||||
@@ -10,6 +10,20 @@ services:
|
|||||||
memory: 1g
|
memory: 1g
|
||||||
reservations:
|
reservations:
|
||||||
memory: 256m
|
memory: 256m
|
||||||
|
{{if or .IsPostgreSQL .IsRedis}}
|
||||||
|
depends_on:
|
||||||
|
{{if .IsPostgreSQL}}
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
{{end}}
|
||||||
|
{{if .IsRedis}}
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
{{end}}
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
- backend
|
||||||
|
{{end}}
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -60,8 +74,56 @@ services:
|
|||||||
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
- ./config/letsencrypt:/letsencrypt # Volume to store the Let's Encrypt certificates
|
||||||
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
- ./config/traefik/logs:/var/log/traefik # Volume to store Traefik logs
|
||||||
|
|
||||||
|
{{if .IsPostgreSQL}}
|
||||||
|
postgres:
|
||||||
|
image: postgres:18
|
||||||
|
container_name: postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: pangolin
|
||||||
|
POSTGRES_PASSWORD: {{.IsPostgreSQLPass}}
|
||||||
|
POSTGRES_DB: pangolin
|
||||||
|
volumes:
|
||||||
|
- ./postgres18:/var/lib/postgresql
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U pangolin"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .IsRedis}}
|
||||||
|
redis:
|
||||||
|
image: redis:8-trixie
|
||||||
|
container_name: redis
|
||||||
|
restart: unless-stopped
|
||||||
|
command: >
|
||||||
|
redis-server
|
||||||
|
--save 3600 1000
|
||||||
|
--appendonly yes
|
||||||
|
--requirepass {{.IsRedisPass}}
|
||||||
|
volumes:
|
||||||
|
- ./redis8:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "-a", "{{.IsRedisPass}}", "ping"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
{{end}}
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
name: pangolin
|
name: pangolin_frontend
|
||||||
{{if .EnableIPv6}} enable_ipv6: true{{end}}
|
{{if .EnableIPv6}} enable_ipv6: true{{end}}
|
||||||
|
{{if or .IsPostgreSQL .IsRedis}}
|
||||||
|
backend:
|
||||||
|
driver: bridge
|
||||||
|
name: pangolin_backend
|
||||||
|
internal: true
|
||||||
|
{{end}}
|
||||||
|
|||||||
6
install/config/privateConfig.yml
Normal file
6
install/config/privateConfig.yml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{{if .IsRedis}}
|
||||||
|
redis:
|
||||||
|
host: "redis"
|
||||||
|
port: 6379
|
||||||
|
password: "{{.IsRedisPass}}"
|
||||||
|
{{end}}
|
||||||
@@ -5,7 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/charmbracelet/huh v1.0.0
|
github.com/charmbracelet/huh v1.0.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
golang.org/x/term v0.42.0
|
golang.org/x/term v0.43.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,6 +33,6 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
golang.org/x/sync v0.15.0 // indirect
|
golang.org/x/sync v0.15.0 // indirect
|
||||||
golang.org/x/sys v0.43.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/text v0.23.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
|||||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
|
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||||
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
|
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
@@ -54,9 +54,13 @@ type Config struct {
|
|||||||
InstallGerbil bool
|
InstallGerbil bool
|
||||||
TraefikBouncerKey string
|
TraefikBouncerKey string
|
||||||
DoCrowdsecInstall bool
|
DoCrowdsecInstall bool
|
||||||
EnableGeoblocking bool
|
EnableMaxMind bool
|
||||||
Secret string
|
Secret string
|
||||||
IsEnterprise bool
|
IsEnterprise bool
|
||||||
|
IsPostgreSQL bool
|
||||||
|
IsPostgreSQLPass string
|
||||||
|
IsRedis bool
|
||||||
|
IsRedisPass string
|
||||||
}
|
}
|
||||||
|
|
||||||
type SupportedContainer string
|
type SupportedContainer string
|
||||||
@@ -123,11 +127,11 @@ func main() {
|
|||||||
|
|
||||||
fmt.Println("\nConfiguration files created successfully!")
|
fmt.Println("\nConfiguration files created successfully!")
|
||||||
|
|
||||||
// Download MaxMind database if requested
|
// Download MaxMind Country / ASN database if requested
|
||||||
if config.EnableGeoblocking {
|
if config.EnableMaxMind {
|
||||||
fmt.Println("\n=== Downloading MaxMind Database ===")
|
fmt.Println("\n=== Downloading MaxMind Country and ASN Databases ===")
|
||||||
if err := downloadMaxMindDatabase(); err != nil {
|
if err := downloadMaxMindDatabase(); err != nil {
|
||||||
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
fmt.Printf("Error downloading MaxMind databases: %v\n", err)
|
||||||
fmt.Println("You can download it manually later if needed.")
|
fmt.Println("You can download it manually later if needed.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -188,15 +192,15 @@ func main() {
|
|||||||
fmt.Println("\n=== MaxMind Database Update ===")
|
fmt.Println("\n=== MaxMind Database Update ===")
|
||||||
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
|
if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil {
|
||||||
fmt.Println("MaxMind GeoLite2 Country database found.")
|
fmt.Println("MaxMind GeoLite2 Country database found.")
|
||||||
if readBool("Would you like to update the MaxMind database to the latest version?", false) {
|
if readBool("Would you like to update the MaxMind databases (Country and ASN) to the latest version?", false) {
|
||||||
if err := downloadMaxMindDatabase(); err != nil {
|
if err := downloadMaxMindDatabase(); err != nil {
|
||||||
fmt.Printf("Error updating MaxMind database: %v\n", err)
|
fmt.Printf("Error updating MaxMind database: %v\n", err)
|
||||||
fmt.Println("You can try updating it manually later if needed.")
|
fmt.Println("You can try updating it manually later if needed.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
fmt.Println("MaxMind GeoLite2 Country database not found.")
|
fmt.Println("MaxMind GeoLite2 Country and ASN databases not found.")
|
||||||
if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) {
|
if readBool("Would you like to download the MaxMind GeoLite2 databases for blocking functionality?", false) {
|
||||||
if err := downloadMaxMindDatabase(); err != nil {
|
if err := downloadMaxMindDatabase(); err != nil {
|
||||||
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
fmt.Printf("Error downloading MaxMind database: %v\n", err)
|
||||||
fmt.Println("You can try downloading it manually later if needed.")
|
fmt.Println("You can try downloading it manually later if needed.")
|
||||||
@@ -204,8 +208,10 @@ func main() {
|
|||||||
// Now you need to update your config file accordingly to enable geoblocking
|
// Now you need to update your config file accordingly to enable geoblocking
|
||||||
fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n")
|
fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n")
|
||||||
// add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server
|
// add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server
|
||||||
fmt.Println("Add the following line under the 'server' section:")
|
// add maxmind_asn_path: "./config/GeoLite2-ASN.mmdb" under server
|
||||||
|
fmt.Println("Add the following lines under the 'server' section:")
|
||||||
fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"")
|
fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"")
|
||||||
|
fmt.Println(" maxmind_asn_path: \"./config/GeoLite2-ASN.mmdb\"")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -484,6 +490,17 @@ func collectUserInput() Config {
|
|||||||
fmt.Println("\n=== Basic Configuration ===")
|
fmt.Println("\n=== Basic Configuration ===")
|
||||||
|
|
||||||
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.")
|
||||||
|
if config.IsEnterprise {
|
||||||
|
config.IsRedis = readBool("Do you want to run the Redis containers locally? Required for HA.")
|
||||||
|
if config.IsRedis {
|
||||||
|
config.IsRedisPass = readPassword("Enter a unique password for the Redis service.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.IsPostgreSQL = readBool("Do you want to run the PostgreSQL containers locally? Otherwise, default to the local SQLite database only.", false)
|
||||||
|
if config.IsPostgreSQL {
|
||||||
|
config.IsPostgreSQLPass = readPassword("Enter a unique password for the PostgreSQL pangolin user.")
|
||||||
|
}
|
||||||
|
|
||||||
config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "")
|
config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "")
|
||||||
|
|
||||||
@@ -527,7 +544,7 @@ func collectUserInput() Config {
|
|||||||
fmt.Println("\n=== Advanced Configuration ===")
|
fmt.Println("\n=== Advanced Configuration ===")
|
||||||
|
|
||||||
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
config.EnableIPv6 = readBool("Is your server IPv6 capable?", true)
|
||||||
config.EnableGeoblocking = readBool("Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true)
|
config.EnableMaxMind = readBool("Do you want to download the MaxMind GeoLite2 Country and ADN databases for blocking functionality?", true)
|
||||||
|
|
||||||
if config.DashboardDomain == "" {
|
if config.DashboardDomain == "" {
|
||||||
fmt.Println("Error: Dashboard Domain name is required")
|
fmt.Println("Error: Dashboard Domain name is required")
|
||||||
@@ -780,29 +797,42 @@ func checkPortsAvailable(port int) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func downloadMaxMindDatabase() error {
|
func downloadMaxMindDatabase() error {
|
||||||
fmt.Println("Downloading MaxMind GeoLite2 Country database...")
|
fmt.Println("Downloading MaxMind GeoLite2 Country and ASN databases...")
|
||||||
|
|
||||||
// Download the GeoLite2 Country database
|
// Download the GeoLite2 Country databases
|
||||||
if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz",
|
if err := run("curl", "-L", "-o", "GeoLite2-Country.tar.gz",
|
||||||
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil {
|
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-Country.tar.gz"); err != nil {
|
||||||
return fmt.Errorf("failed to download GeoLite2 database: %v", err)
|
return fmt.Errorf("failed to download GeoLite2 Country database: %v", err)
|
||||||
|
}
|
||||||
|
if err := run("curl", "-L", "-o", "GeoLite2-ASN.tar.gz",
|
||||||
|
"https://github.com/GitSquared/node-geolite2-redist/raw/refs/heads/master/redist/GeoLite2-ASN.tar.gz"); err != nil {
|
||||||
|
return fmt.Errorf("failed to download GeoLite2 ASN database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract the database
|
// Extract the Country database
|
||||||
if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil {
|
if err := run("tar", "-xzf", "GeoLite2-Country.tar.gz"); err != nil {
|
||||||
return fmt.Errorf("failed to extract GeoLite2 database: %v", err)
|
return fmt.Errorf("failed to extract GeoLite2 Country database: %v", err)
|
||||||
|
}
|
||||||
|
if err := run("tar", "-xzf", "GeoLite2-ASN.tar.gz"); err != nil {
|
||||||
|
return fmt.Errorf("failed to extract GeoLite2 ASN database: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the .mmdb file and move it to the config directory
|
// Find the .mmdb file and move it to the config directory
|
||||||
if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil {
|
if err := run("bash", "-c", "mv GeoLite2-Country_*/GeoLite2-Country.mmdb config/"); err != nil {
|
||||||
return fmt.Errorf("failed to move GeoLite2 database to config directory: %v", err)
|
return fmt.Errorf("failed to move GeoLite2 Country database to config directory: %v", err)
|
||||||
|
}
|
||||||
|
if err := run("bash", "-c", "mv GeoLite2-ASN_*/GeoLite2-ASN.mmdb config/"); err != nil {
|
||||||
|
return fmt.Errorf("failed to move GeoLite2 ASN database to config directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the downloaded files
|
// Clean up the downloaded files
|
||||||
if err := run("rm", "-rf", "GeoLite2-Country.tar.gz", "GeoLite2-Country_*"); err != nil {
|
if err := run("sh", "-c", "rm -rf GeoLite2-Country.tar.gz GeoLite2-Country_*"); err != nil {
|
||||||
fmt.Printf("Warning: failed to clean up temporary files: %v\n", err)
|
fmt.Printf("Warning: failed to clean up temporary country files: %v\n", err)
|
||||||
|
}
|
||||||
|
if err := run("sh", "-c", "rm -rf GeoLite2-ASN.tar.gz GeoLite2-ASN_*"); err != nil {
|
||||||
|
fmt.Printf("Warning: failed to clean up temporary ASN files: %v\n", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("MaxMind GeoLite2 Country database downloaded successfully!")
|
fmt.Println("MaxMind GeoLite2 Country and ASN database downloaded successfully!")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,23 +255,6 @@
|
|||||||
"resourceGoTo": "Go to Resource",
|
"resourceGoTo": "Go to Resource",
|
||||||
"resourceDelete": "Delete Resource",
|
"resourceDelete": "Delete Resource",
|
||||||
"resourceDeleteConfirm": "Confirm Delete Resource",
|
"resourceDeleteConfirm": "Confirm Delete Resource",
|
||||||
"labelDelete": "Delete Label",
|
|
||||||
"labelAdd": "Add Label",
|
|
||||||
"labelCreateSuccessMessage": "Label Created Successfully",
|
|
||||||
"labelEditSuccessMessage": "Label Modified Successfully",
|
|
||||||
"labelNameField": "Label Name",
|
|
||||||
"labelColorField": "Label Color",
|
|
||||||
"labelPlaceholder": "Ex: homelab",
|
|
||||||
"labelCreate": "Create Label",
|
|
||||||
"createLabelDialogTitle": "Create Label",
|
|
||||||
"createLabelDialogDescription": "Create a new label that can be attached to this organization",
|
|
||||||
"labelEdit": "Edit Label",
|
|
||||||
"editLabelDialogTitle": "Update Label",
|
|
||||||
"editLabelDialogDescription": "Edit a new label that can be attached to this organization",
|
|
||||||
"labelDeleteConfirm": "Confirm Delete Label",
|
|
||||||
"labelErrorDelete": "Failed to delete label",
|
|
||||||
"labelMessageRemove": "This action is permanent. All sites, resources, and clients tagged with this label will be untagged.",
|
|
||||||
"labelQuestionRemove": "Are you sure you want to remove the label from the organization?",
|
|
||||||
"visibility": "Visibility",
|
"visibility": "Visibility",
|
||||||
"enabled": "Enabled",
|
"enabled": "Enabled",
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
@@ -1157,15 +1140,6 @@
|
|||||||
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
"idpErrorConnectingTo": "There was a problem connecting to {name}. Please contact your administrator.",
|
||||||
"idpErrorNotFound": "IdP not found",
|
"idpErrorNotFound": "IdP not found",
|
||||||
"inviteInvalid": "Invalid Invite",
|
"inviteInvalid": "Invalid Invite",
|
||||||
"labels": "Labels",
|
|
||||||
"orgLabelsDescription": "Manage labels in this organization.",
|
|
||||||
"addLabels": "Add labels",
|
|
||||||
"siteLabelsTab": "Labels",
|
|
||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
|
||||||
"labelsNotFound": "Labels not found",
|
|
||||||
"labelSearch": "Search labels",
|
|
||||||
"selectColor": "Select color",
|
|
||||||
"createNewLabel": "Create new org label \"{label}\"",
|
|
||||||
"inviteInvalidDescription": "The invite link is invalid.",
|
"inviteInvalidDescription": "The invite link is invalid.",
|
||||||
"inviteErrorWrongUser": "Invite is not for this user",
|
"inviteErrorWrongUser": "Invite is not for this user",
|
||||||
"inviteErrorUserNotExists": "User does not exist. Please create an account first.",
|
"inviteErrorUserNotExists": "User does not exist. Please create an account first.",
|
||||||
@@ -1983,7 +1957,7 @@
|
|||||||
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
|
"sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.",
|
||||||
"sshSudo": "Allow sudo",
|
"sshSudo": "Allow sudo",
|
||||||
"sshSudoCommands": "Sudo Commands",
|
"sshSudoCommands": "Sudo Commands",
|
||||||
"sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo.",
|
"sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo. Absolute paths must be used.",
|
||||||
"sshCreateHomeDir": "Create Home Directory",
|
"sshCreateHomeDir": "Create Home Directory",
|
||||||
"sshUnixGroups": "Unix Groups",
|
"sshUnixGroups": "Unix Groups",
|
||||||
"sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.",
|
"sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.",
|
||||||
|
|||||||
@@ -5,12 +5,7 @@ const withNextIntl = createNextIntlPlugin();
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
reactStrictMode: false,
|
reactStrictMode: false,
|
||||||
eslint: {
|
reactCompiler: true,
|
||||||
ignoreDuringBuilds: true
|
|
||||||
},
|
|
||||||
experimental: {
|
|
||||||
reactCompiler: true
|
|
||||||
},
|
|
||||||
output: "standalone"
|
output: "standalone"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
5819
package-lock.json
generated
5819
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
115
package.json
115
package.json
@@ -32,11 +32,11 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@asteasolutions/zod-to-openapi": "8.4.1",
|
"@asteasolutions/zod-to-openapi": "8.5.0",
|
||||||
"@aws-sdk/client-s3": "3.1011.0",
|
"@aws-sdk/client-s3": "3.1056.0",
|
||||||
"@faker-js/faker": "10.3.0",
|
"@faker-js/faker": "10.4.0",
|
||||||
"@headlessui/react": "2.2.9",
|
"@headlessui/react": "2.2.10",
|
||||||
"@hookform/resolvers": "5.2.2",
|
"@hookform/resolvers": "5.4.0",
|
||||||
"@monaco-editor/react": "4.7.0",
|
"@monaco-editor/react": "4.7.0",
|
||||||
"@node-rs/argon2": "2.0.2",
|
"@node-rs/argon2": "2.0.2",
|
||||||
"@oslojs/crypto": "1.0.1",
|
"@oslojs/crypto": "1.0.1",
|
||||||
@@ -59,16 +59,17 @@
|
|||||||
"@radix-ui/react-tabs": "1.1.13",
|
"@radix-ui/react-tabs": "1.1.13",
|
||||||
"@radix-ui/react-toast": "1.2.15",
|
"@radix-ui/react-toast": "1.2.15",
|
||||||
"@radix-ui/react-tooltip": "1.2.8",
|
"@radix-ui/react-tooltip": "1.2.8",
|
||||||
"@react-email/components": "1.0.8",
|
"@react-email/body": "0.3.0",
|
||||||
"@react-email/render": "2.0.4",
|
"@react-email/components": "1.0.12",
|
||||||
"@react-email/tailwind": "2.0.5",
|
"@react-email/render": "2.0.8",
|
||||||
|
"@react-email/tailwind": "2.0.7",
|
||||||
"@simplewebauthn/browser": "13.3.0",
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
"@simplewebauthn/server": "13.3.0",
|
"@simplewebauthn/server": "13.3.1",
|
||||||
"@tailwindcss/forms": "0.5.11",
|
"@tailwindcss/forms": "0.5.11",
|
||||||
"@tanstack/react-query": "5.90.21",
|
"@tanstack/react-query": "5.100.14",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.15.0",
|
"axios": "1.16.1",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
"canvas-confetti": "1.9.4",
|
"canvas-confetti": "1.9.4",
|
||||||
"class-variance-authority": "0.7.1",
|
"class-variance-authority": "0.7.1",
|
||||||
@@ -80,77 +81,76 @@
|
|||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.3.0",
|
"express-rate-limit": "8.5.2",
|
||||||
"glob": "13.0.6",
|
"glob": "13.0.6",
|
||||||
"helmet": "8.1.0",
|
"helmet": "8.2.0",
|
||||||
"http-errors": "2.0.1",
|
"http-errors": "2.0.1",
|
||||||
"input-otp": "1.4.2",
|
"input-otp": "1.4.2",
|
||||||
"ioredis": "5.10.0",
|
"ioredis": "5.11.0",
|
||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "4.1.1",
|
"js-yaml": "4.1.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
"lucide-react": "0.577.0",
|
"lucide-react": "1.17.0",
|
||||||
"maxmind": "5.0.5",
|
"maxmind": "5.0.6",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
"next": "15.5.15",
|
"next": "16.2.6",
|
||||||
"next-intl": "4.8.3",
|
"next-intl": "4.13.0",
|
||||||
"next-themes": "0.4.6",
|
"next-themes": "0.4.6",
|
||||||
"nextjs-toploader": "3.9.17",
|
"nextjs-toploader": "3.9.17",
|
||||||
"node-cache": "5.1.2",
|
"node-cache": "5.1.2",
|
||||||
"nodemailer": "8.0.5",
|
"nodemailer": "8.0.9",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.20.0",
|
"pg": "8.21.0",
|
||||||
"posthog-node": "5.28.0",
|
"posthog-node": "5.35.6",
|
||||||
"qrcode.react": "4.2.0",
|
"qrcode.react": "4.2.0",
|
||||||
"react": "19.2.4",
|
"react": "19.2.6",
|
||||||
"react-day-picker": "9.14.0",
|
"react-day-picker": "9.14.0",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.6",
|
||||||
"react-easy-sort": "1.8.0",
|
"react-easy-sort": "1.8.0",
|
||||||
"react-hook-form": "7.71.2",
|
"react-hook-form": "7.76.1",
|
||||||
"react-icons": "5.6.0",
|
"react-icons": "5.6.0",
|
||||||
"recharts": "2.15.4",
|
"recharts": "3.8.1",
|
||||||
"reodotdev": "1.1.0",
|
"reodotdev": "1.1.0",
|
||||||
"resend": "6.9.2",
|
"semver": "7.8.1",
|
||||||
"semver": "7.7.4",
|
|
||||||
"sshpk": "1.18.0",
|
"sshpk": "1.18.0",
|
||||||
"stripe": "20.4.1",
|
"stripe": "22.2.0",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tailwind-merge": "3.5.0",
|
"tailwind-merge": "3.6.0",
|
||||||
"topojson-client": "3.1.0",
|
"topojson-client": "3.1.0",
|
||||||
"tw-animate-css": "1.4.0",
|
"tw-animate-css": "1.4.0",
|
||||||
"use-debounce": "10.1.0",
|
"use-debounce": "10.1.1",
|
||||||
"uuid": "13.0.0",
|
"uuid": "14.0.0",
|
||||||
"vaul": "1.1.2",
|
"vaul": "1.1.2",
|
||||||
"visionscarto-world-atlas": "1.0.0",
|
"visionscarto-world-atlas": "1.0.0",
|
||||||
"winston": "3.19.0",
|
"winston": "3.19.0",
|
||||||
"winston-daily-rotate-file": "5.0.0",
|
"winston-daily-rotate-file": "5.0.0",
|
||||||
"ws": "8.19.0",
|
"ws": "8.21.0",
|
||||||
"yaml": "2.8.3",
|
"yaml": "2.9.0",
|
||||||
"yargs": "18.0.0",
|
"yargs": "18.0.0",
|
||||||
"zod": "4.3.6",
|
"zod": "4.4.3",
|
||||||
"zod-validation-error": "5.0.0"
|
"zod-validation-error": "5.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@dotenvx/dotenvx": "1.54.1",
|
"@dotenvx/dotenvx": "1.69.1",
|
||||||
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
"@esbuild-plugins/tsconfig-paths": "0.1.2",
|
||||||
"@react-email/preview-server": "5.2.10",
|
"@react-email/ui": "^6.5.0",
|
||||||
"@tailwindcss/postcss": "4.2.2",
|
"@tailwindcss/postcss": "4.3.0",
|
||||||
"@tanstack/react-query-devtools": "5.91.3",
|
"@tanstack/react-query-devtools": "5.100.14",
|
||||||
"@types/better-sqlite3": "7.6.13",
|
"@types/better-sqlite3": "7.6.13",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/cors": "2.8.19",
|
"@types/cors": "2.8.19",
|
||||||
"@types/crypto-js": "4.2.2",
|
"@types/crypto-js": "4.2.2",
|
||||||
"@types/d3": "7.4.3",
|
"@types/d3": "7.4.3",
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/express-session": "1.18.2",
|
"@types/express-session": "1.19.0",
|
||||||
"@types/jmespath": "0.15.2",
|
"@types/jmespath": "0.15.2",
|
||||||
"@types/js-yaml": "4.0.9",
|
"@types/js-yaml": "4.0.9",
|
||||||
"@types/jsonwebtoken": "9.0.10",
|
"@types/jsonwebtoken": "9.0.10",
|
||||||
"@types/node": "25.3.5",
|
"@types/node": "25.9.1",
|
||||||
"@types/nodemailer": "7.0.11",
|
"@types/nodemailer": "8.0.0",
|
||||||
"@types/nprogress": "0.2.3",
|
"@types/nprogress": "0.2.3",
|
||||||
"@types/pg": "8.18.0",
|
"@types/pg": "8.20.0",
|
||||||
"@types/react": "19.2.14",
|
"@types/react": "19.2.15",
|
||||||
"@types/react-dom": "19.2.3",
|
"@types/react-dom": "19.2.3",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@types/sshpk": "1.17.4",
|
"@types/sshpk": "1.17.4",
|
||||||
@@ -160,21 +160,22 @@
|
|||||||
"@types/yargs": "17.0.35",
|
"@types/yargs": "17.0.35",
|
||||||
"babel-plugin-react-compiler": "1.0.0",
|
"babel-plugin-react-compiler": "1.0.0",
|
||||||
"drizzle-kit": "0.31.10",
|
"drizzle-kit": "0.31.10",
|
||||||
"esbuild": "0.27.4",
|
"esbuild": "0.28.0",
|
||||||
"esbuild-node-externals": "1.20.1",
|
"esbuild-node-externals": "1.22.0",
|
||||||
"eslint": "10.0.3",
|
"eslint": "10.4.0",
|
||||||
"eslint-config-next": "16.1.7",
|
"eslint-config-next": "16.2.6",
|
||||||
"postcss": "8.5.8",
|
"postcss": "8.5.15",
|
||||||
"prettier": "3.8.1",
|
"prettier": "3.8.3",
|
||||||
"react-email": "5.2.10",
|
"react-email": "6.5.0",
|
||||||
"tailwindcss": "4.2.2",
|
"tailwindcss": "4.3.0",
|
||||||
"tsc-alias": "1.8.16",
|
"tsc-alias": "1.8.17",
|
||||||
"tsx": "4.21.0",
|
"tsx": "4.22.3",
|
||||||
"typescript": "5.9.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.56.1"
|
"typescript-eslint": "8.60.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"esbuild": "0.27.4",
|
"esbuild": "0.28.0",
|
||||||
"dompurify": "3.3.2"
|
"dompurify": "3.4.0",
|
||||||
|
"postcss": "8.5.15"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,12 +148,6 @@ export enum ActionsEnum {
|
|||||||
updateAlertRule = "updateAlertRule",
|
updateAlertRule = "updateAlertRule",
|
||||||
deleteAlertRule = "deleteAlertRule",
|
deleteAlertRule = "deleteAlertRule",
|
||||||
listAlertRules = "listAlertRules",
|
listAlertRules = "listAlertRules",
|
||||||
listOrgLabels = "listOrgLabels",
|
|
||||||
createOrgLabel = "createOrgLabel",
|
|
||||||
updateOrgLabel = "updateOrgLabel",
|
|
||||||
deleteOrgLabel = "deleteOrgLabel",
|
|
||||||
attachLabelToItem = "attachLabelToItem",
|
|
||||||
detachLabelFromItem = "detachLabelFromItem",
|
|
||||||
getAlertRule = "getAlertRule",
|
getAlertRule = "getAlertRule",
|
||||||
createHealthCheck = "createHealthCheck",
|
createHealthCheck = "createHealthCheck",
|
||||||
updateHealthCheck = "updateHealthCheck",
|
updateHealthCheck = "updateHealthCheck",
|
||||||
|
|||||||
@@ -162,89 +162,6 @@ export const resources = pgTable("resources", {
|
|||||||
wildcard: boolean("wildcard").notNull().default(false)
|
wildcard: boolean("wildcard").notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const labels = pgTable("labels", {
|
|
||||||
labelId: serial("labelId").primaryKey(),
|
|
||||||
name: varchar("name").notNull(),
|
|
||||||
color: varchar("color").notNull(),
|
|
||||||
orgId: varchar("orgId")
|
|
||||||
.references(() => orgs.orgId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const siteLabels = pgTable(
|
|
||||||
"siteLabels",
|
|
||||||
{
|
|
||||||
siteLabelId: serial("siteLabelId").primaryKey(),
|
|
||||||
siteId: integer("siteId")
|
|
||||||
.references(() => sites.siteId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const resourceLabels = pgTable(
|
|
||||||
"resourceLabels",
|
|
||||||
{
|
|
||||||
resourceLabelId: serial("resourceLabelId").primaryKey(),
|
|
||||||
resourceId: integer("resourceId")
|
|
||||||
.references(() => resources.resourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const siteResourceLabels = pgTable(
|
|
||||||
"siteResourceLabels",
|
|
||||||
{
|
|
||||||
siteResourceLabelId: serial("siteResourceLabelId").primaryKey(),
|
|
||||||
siteResourceId: integer("siteResourceId")
|
|
||||||
.references(() => siteResources.siteResourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const clientLabels = pgTable(
|
|
||||||
"clientLabels",
|
|
||||||
{
|
|
||||||
clientLabelId: serial("clientLabelId").primaryKey(),
|
|
||||||
clientId: integer("clientId")
|
|
||||||
.references(() => clients.clientId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const targets = pgTable("targets", {
|
export const targets = pgTable("targets", {
|
||||||
targetId: serial("targetId").primaryKey(),
|
targetId: serial("targetId").primaryKey(),
|
||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId")
|
||||||
@@ -279,11 +196,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", {
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
siteId: integer("siteId")
|
siteId: integer("siteId").references(() => sites.siteId, {
|
||||||
.references(() => sites.siteId, {
|
onDelete: "cascade"
|
||||||
onDelete: "cascade"
|
}).notNull(),
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
name: varchar("name"),
|
name: varchar("name"),
|
||||||
hcEnabled: boolean("hcEnabled").notNull().default(false),
|
hcEnabled: boolean("hcEnabled").notNull().default(false),
|
||||||
hcPath: varchar("hcPath"),
|
hcPath: varchar("hcPath"),
|
||||||
@@ -1182,30 +1097,19 @@ export const roundTripMessageTracker = pgTable("roundTripMessageTracker", {
|
|||||||
complete: boolean("complete").notNull().default(false)
|
complete: boolean("complete").notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const statusHistory = pgTable(
|
export const statusHistory = pgTable("statusHistory", {
|
||||||
"statusHistory",
|
id: serial("id").primaryKey(),
|
||||||
{
|
entityType: varchar("entityType").notNull(),
|
||||||
id: serial("id").primaryKey(),
|
entityId: integer("entityId").notNull(),
|
||||||
entityType: varchar("entityType").notNull(),
|
orgId: varchar("orgId")
|
||||||
entityId: integer("entityId").notNull(),
|
.notNull()
|
||||||
orgId: varchar("orgId")
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
.notNull()
|
status: varchar("status").notNull(),
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
timestamp: integer("timestamp").notNull(),
|
||||||
status: varchar("status").notNull(),
|
}, (table) => [
|
||||||
timestamp: integer("timestamp").notNull()
|
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
|
||||||
},
|
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
|
||||||
(table) => [
|
]);
|
||||||
index("idx_statusHistory_entity").on(
|
|
||||||
table.entityType,
|
|
||||||
table.entityId,
|
|
||||||
table.timestamp
|
|
||||||
),
|
|
||||||
index("idx_statusHistory_org_timestamp").on(
|
|
||||||
table.orgId,
|
|
||||||
table.timestamp
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
@@ -1275,4 +1179,3 @@ export type RoundTripMessageTracker = InferSelectModel<
|
|||||||
>;
|
>;
|
||||||
export type Network = InferSelectModel<typeof networks>;
|
export type Network = InferSelectModel<typeof networks>;
|
||||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||||
export type Label = InferSelectModel<typeof labels>;
|
|
||||||
|
|||||||
@@ -183,95 +183,6 @@ export const resources = sqliteTable("resources", {
|
|||||||
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
|
wildcard: integer("wildcard", { mode: "boolean" }).notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const labels = sqliteTable("labels", {
|
|
||||||
labelId: integer("labelId").primaryKey({ autoIncrement: true }),
|
|
||||||
name: text("name").notNull(),
|
|
||||||
color: text("color").notNull(),
|
|
||||||
orgId: text("orgId")
|
|
||||||
.references(() => orgs.orgId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
});
|
|
||||||
|
|
||||||
export const siteLabels = sqliteTable(
|
|
||||||
"siteLabels",
|
|
||||||
{
|
|
||||||
siteLabelId: integer("siteLabelId").primaryKey({ autoIncrement: true }),
|
|
||||||
siteId: integer("siteId")
|
|
||||||
.references(() => sites.siteId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("site_label_uniq").on(t.siteId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const resourceLabels = sqliteTable(
|
|
||||||
"resourceLabels",
|
|
||||||
{
|
|
||||||
resourceLabelId: integer("resourceLabelId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
resourceId: integer("resourceId")
|
|
||||||
.references(() => resources.resourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("resource_label_uniq").on(t.resourceId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const siteResourceLabels = sqliteTable(
|
|
||||||
"siteResourceLabels",
|
|
||||||
{
|
|
||||||
siteResourceLabelId: integer("siteResourceLabelId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
siteResourceId: integer("siteResourceId")
|
|
||||||
.references(() => siteResources.siteResourceId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("site_resource_label_uniq").on(t.siteResourceId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const clientLabels = sqliteTable(
|
|
||||||
"clientLabels",
|
|
||||||
{
|
|
||||||
clientLabelId: integer("clientLabelId").primaryKey({
|
|
||||||
autoIncrement: true
|
|
||||||
}),
|
|
||||||
clientId: integer("clientId")
|
|
||||||
.references(() => clients.clientId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
labelId: integer("labelId")
|
|
||||||
.references(() => labels.labelId, {
|
|
||||||
onDelete: "cascade"
|
|
||||||
})
|
|
||||||
.notNull()
|
|
||||||
},
|
|
||||||
(t) => [unique("client_label_uniq").on(t.clientId, t.labelId)]
|
|
||||||
);
|
|
||||||
|
|
||||||
export const targets = sqliteTable("targets", {
|
export const targets = sqliteTable("targets", {
|
||||||
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
targetId: integer("targetId").primaryKey({ autoIncrement: true }),
|
||||||
resourceId: integer("resourceId")
|
resourceId: integer("resourceId")
|
||||||
@@ -308,11 +219,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", {
|
|||||||
onDelete: "cascade"
|
onDelete: "cascade"
|
||||||
})
|
})
|
||||||
.notNull(),
|
.notNull(),
|
||||||
siteId: integer("siteId")
|
siteId: integer("siteId").references(() => sites.siteId, {
|
||||||
.references(() => sites.siteId, {
|
onDelete: "cascade"
|
||||||
onDelete: "cascade"
|
}).notNull(),
|
||||||
})
|
|
||||||
.notNull(),
|
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
hcEnabled: integer("hcEnabled", { mode: "boolean" })
|
hcEnabled: integer("hcEnabled", { mode: "boolean" })
|
||||||
.notNull()
|
.notNull()
|
||||||
@@ -1287,30 +1196,19 @@ export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", {
|
|||||||
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
complete: integer("complete", { mode: "boolean" }).notNull().default(false)
|
||||||
});
|
});
|
||||||
|
|
||||||
export const statusHistory = sqliteTable(
|
export const statusHistory = sqliteTable("statusHistory", {
|
||||||
"statusHistory",
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
{
|
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
||||||
entityType: text("entityType").notNull(), // "site" | "healthCheck"
|
orgId: text("orgId")
|
||||||
entityId: integer("entityId").notNull(), // siteId or targetHealthCheckId
|
.notNull()
|
||||||
orgId: text("orgId")
|
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
||||||
.notNull()
|
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
||||||
.references(() => orgs.orgId, { onDelete: "cascade" }),
|
timestamp: integer("timestamp").notNull(), // unix epoch seconds
|
||||||
status: text("status").notNull(), // "online"/"offline" for sites; "healthy"/"unhealthy"/"unknown" for healthChecks
|
}, (table) => [
|
||||||
timestamp: integer("timestamp").notNull() // unix epoch seconds
|
index("idx_statusHistory_entity").on(table.entityType, table.entityId, table.timestamp),
|
||||||
},
|
index("idx_statusHistory_org_timestamp").on(table.orgId, table.timestamp),
|
||||||
(table) => [
|
]);
|
||||||
index("idx_statusHistory_entity").on(
|
|
||||||
table.entityType,
|
|
||||||
table.entityId,
|
|
||||||
table.timestamp
|
|
||||||
),
|
|
||||||
index("idx_statusHistory_org_timestamp").on(
|
|
||||||
table.orgId,
|
|
||||||
table.timestamp
|
|
||||||
)
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
export type Org = InferSelectModel<typeof orgs>;
|
export type Org = InferSelectModel<typeof orgs>;
|
||||||
export type User = InferSelectModel<typeof users>;
|
export type User = InferSelectModel<typeof users>;
|
||||||
@@ -1380,4 +1278,3 @@ export type RoundTripMessageTracker = InferSelectModel<
|
|||||||
typeof roundTripMessageTracker
|
typeof roundTripMessageTracker
|
||||||
>;
|
>;
|
||||||
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
export type StatusHistory = InferSelectModel<typeof statusHistory>;
|
||||||
export type Label = InferSelectModel<typeof labels>;
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
#! /usr/bin/env node
|
#! /usr/bin/env node
|
||||||
import "./extendZod.ts";
|
import "./extendZod";
|
||||||
|
|
||||||
import { runSetupFunctions } from "./setup";
|
import { runSetupFunctions } from "./setup";
|
||||||
import { createApiServer } from "./apiServer";
|
import { createApiServer } from "./apiServer";
|
||||||
|
|||||||
@@ -221,10 +221,18 @@ async function handleResource(
|
|||||||
)
|
)
|
||||||
.where(eq(targets.resourceId, resource.resourceId));
|
.where(eq(targets.resourceId, resource.resourceId));
|
||||||
|
|
||||||
|
const monitoredTargets = otherTargets.filter(
|
||||||
|
(t) => t.hcHealth !== "unknown"
|
||||||
|
);
|
||||||
|
|
||||||
let health = "healthy";
|
let health = "healthy";
|
||||||
const allUnknown = otherTargets.every((t) => t.hcHealth === "unknown");
|
const allUnknown = monitoredTargets.length === 0;
|
||||||
const allHealthy = otherTargets.every((t) => t.hcHealth === "healthy");
|
const allHealthy = monitoredTargets.every(
|
||||||
const allUnhealthy = otherTargets.every((t) => t.hcHealth === "unhealthy");
|
(t) => t.hcHealth === "healthy"
|
||||||
|
);
|
||||||
|
const allUnhealthy = monitoredTargets.every(
|
||||||
|
(t) => t.hcHealth === "unhealthy"
|
||||||
|
);
|
||||||
|
|
||||||
if (allUnknown) {
|
if (allUnknown) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
|
|||||||
@@ -24,12 +24,10 @@ export enum TierFeature {
|
|||||||
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
DomainNamespaces = "domainNamespaces", // handle downgrade by removing custom domain namespaces
|
||||||
StandaloneHealthChecks = "standaloneHealthChecks",
|
StandaloneHealthChecks = "standaloneHealthChecks",
|
||||||
AlertingRules = "alertingRules",
|
AlertingRules = "alertingRules",
|
||||||
WildcardSubdomain = "wildcardSubdomain",
|
WildcardSubdomain = "wildcardSubdomain"
|
||||||
Labels = "labels"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
export const tierMatrix: Record<TierFeature, Tier[]> = {
|
||||||
[TierFeature.Labels]: ["tier2", "tier3", "enterprise"],
|
|
||||||
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
[TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"],
|
||||||
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
[TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
clientSiteResources,
|
clientSiteResources,
|
||||||
domains,
|
domains,
|
||||||
orgDomains,
|
orgDomains,
|
||||||
|
roleActions,
|
||||||
roles,
|
roles,
|
||||||
roleSiteResources,
|
roleSiteResources,
|
||||||
Site,
|
Site,
|
||||||
@@ -19,6 +20,7 @@ import { sites } from "@server/db";
|
|||||||
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
|
import { eq, and, ne, inArray, or, isNotNull } from "drizzle-orm";
|
||||||
import { Config } from "./types";
|
import { Config } from "./types";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
|
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||||
import { getNextAvailableAliasAddress } from "../ip";
|
import { getNextAvailableAliasAddress } from "../ip";
|
||||||
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
import { createCertificate } from "#dynamic/routers/certificates/createCertificate";
|
||||||
|
|
||||||
@@ -332,8 +334,7 @@ export async function updateClientResources(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (resourceData.roles.length > 0) {
|
if (resourceData.roles.length > 0) {
|
||||||
// Re-add specified roles but we need to get the roleIds from the role name in the array
|
const existingRoles = await trx
|
||||||
const rolesToUpdate = await trx
|
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(
|
.where(
|
||||||
@@ -343,7 +344,28 @@ export async function updateClientResources(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const roleIds = rolesToUpdate.map((role) => role.roleId);
|
const foundNames = new Set(existingRoles.map((r) => r.name));
|
||||||
|
const missingNames = resourceData.roles.filter(
|
||||||
|
(n) => !foundNames.has(n)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const name of missingNames) {
|
||||||
|
const [created] = await trx
|
||||||
|
.insert(roles)
|
||||||
|
.values({ name, orgId })
|
||||||
|
.returning();
|
||||||
|
await trx.insert(roleActions).values(
|
||||||
|
defaultRoleAllowedActions.map((action) => ({
|
||||||
|
roleId: created.roleId,
|
||||||
|
actionId: action,
|
||||||
|
orgId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
existingRoles.push(created);
|
||||||
|
logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleIds = existingRoles.map((role) => role.roleId);
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insert(roleSiteResources)
|
.insert(roleSiteResources)
|
||||||
@@ -444,8 +466,7 @@ export async function updateClientResources(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (resourceData.roles.length > 0) {
|
if (resourceData.roles.length > 0) {
|
||||||
// get roleIds from role names
|
const existingRoles = await trx
|
||||||
const rolesToUpdate = await trx
|
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(
|
.where(
|
||||||
@@ -455,7 +476,28 @@ export async function updateClientResources(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const roleIds = rolesToUpdate.map((role) => role.roleId);
|
const foundNames = new Set(existingRoles.map((r) => r.name));
|
||||||
|
const missingNames = resourceData.roles.filter(
|
||||||
|
(n) => !foundNames.has(n)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const name of missingNames) {
|
||||||
|
const [created] = await trx
|
||||||
|
.insert(roles)
|
||||||
|
.values({ name, orgId })
|
||||||
|
.returning();
|
||||||
|
await trx.insert(roleActions).values(
|
||||||
|
defaultRoleAllowedActions.map((action) => ({
|
||||||
|
roleId: created.roleId,
|
||||||
|
actionId: action,
|
||||||
|
orgId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
existingRoles.push(created);
|
||||||
|
logger.info(`Auto-created role "${name}" in org ${orgId} from blueprint`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const roleIds = existingRoles.map((role) => role.roleId);
|
||||||
|
|
||||||
await trx
|
await trx
|
||||||
.insert(roleSiteResources)
|
.insert(roleSiteResources)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
resourcePincode,
|
resourcePincode,
|
||||||
resourceRules,
|
resourceRules,
|
||||||
resourceWhitelist,
|
resourceWhitelist,
|
||||||
|
roleActions,
|
||||||
roleResources,
|
roleResources,
|
||||||
roles,
|
roles,
|
||||||
Target,
|
Target,
|
||||||
@@ -36,6 +37,7 @@ import { isValidRegionId } from "@server/db/regions";
|
|||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
||||||
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
|
import { fireHealthCheckUnknownAlert } from "@server/lib/alerts";
|
||||||
import { tierMatrix } from "../billing/tierMatrix";
|
import { tierMatrix } from "../billing/tierMatrix";
|
||||||
|
import { defaultRoleAllowedActions } from "@server/routers/role/createRole";
|
||||||
|
|
||||||
export type ProxyResourcesResults = {
|
export type ProxyResourcesResults = {
|
||||||
proxyResource: Resource;
|
proxyResource: Resource;
|
||||||
@@ -925,14 +927,26 @@ async function syncRoleResources(
|
|||||||
.where(eq(roleResources.resourceId, resourceId));
|
.where(eq(roleResources.resourceId, resourceId));
|
||||||
|
|
||||||
for (const roleName of ssoRoles) {
|
for (const roleName of ssoRoles) {
|
||||||
const [role] = await trx
|
let [role] = await trx
|
||||||
.select()
|
.select()
|
||||||
.from(roles)
|
.from(roles)
|
||||||
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
|
.where(and(eq(roles.name, roleName), eq(roles.orgId, orgId)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
if (!role) {
|
if (!role) {
|
||||||
throw new Error(`Role not found: ${roleName} in org ${orgId}`);
|
const [created] = await trx
|
||||||
|
.insert(roles)
|
||||||
|
.values({ name: roleName, orgId })
|
||||||
|
.returning();
|
||||||
|
await trx.insert(roleActions).values(
|
||||||
|
defaultRoleAllowedActions.map((action) => ({
|
||||||
|
roleId: created.roleId,
|
||||||
|
actionId: action,
|
||||||
|
orgId
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
role = created;
|
||||||
|
logger.info(`Auto-created role "${roleName}" in org ${orgId} from blueprint`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (role.isAdmin) {
|
if (role.isAdmin) {
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export const RuleSchema = z
|
|||||||
.object({
|
.object({
|
||||||
action: z.enum(["allow", "deny", "pass"]),
|
action: z.enum(["allow", "deny", "pass"]),
|
||||||
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
match: z.enum(["cidr", "path", "ip", "country", "asn", "region"]),
|
||||||
value: z.string(),
|
value: z.coerce.string(),
|
||||||
priority: z.int().optional()
|
priority: z.int().optional()
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
@@ -340,7 +340,8 @@ export const ResourceSchema = z
|
|||||||
if (parts.includes("*", 1)) return false; // no further wildcards
|
if (parts.includes("*", 1)) return false; // no further wildcards
|
||||||
if (parts.length < 3) return false; // need at least *.label.tld
|
if (parts.length < 3) return false; // need at least *.label.tld
|
||||||
|
|
||||||
const labelRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
const labelRegex =
|
||||||
|
/^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$|^[a-zA-Z0-9]$/;
|
||||||
return parts.slice(1).every((label) => labelRegex.test(label));
|
return parts.slice(1).every((label) => labelRegex.test(label));
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
userOrgRoles,
|
userOrgRoles,
|
||||||
userSiteResources
|
userSiteResources
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { and, eq, inArray, ne } from "drizzle-orm";
|
import { and, count, eq, inArray, ne } from "drizzle-orm";
|
||||||
|
|
||||||
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
|
import { deletePeer as newtDeletePeer } from "@server/routers/newt/peers";
|
||||||
import {
|
import {
|
||||||
@@ -39,6 +39,11 @@ import {
|
|||||||
removePeerData,
|
removePeerData,
|
||||||
removeTargets as removeSubnetProxyTargets
|
removeTargets as removeSubnetProxyTargets
|
||||||
} from "@server/routers/client/targets";
|
} from "@server/routers/client/targets";
|
||||||
|
import { lockManager } from "#dynamic/lib/lock";
|
||||||
|
|
||||||
|
// TTL for rebuild-association locks. These functions can fan out into many
|
||||||
|
// peer/proxy updates, so give them a generous window.
|
||||||
|
const REBUILD_ASSOCIATIONS_LOCK_TTL_MS = 120000;
|
||||||
|
|
||||||
export async function getClientSiteResourceAccess(
|
export async function getClientSiteResourceAccess(
|
||||||
siteResource: SiteResource,
|
siteResource: SiteResource,
|
||||||
@@ -161,6 +166,23 @@ export async function rebuildClientAssociationsFromSiteResource(
|
|||||||
pubKey: string | null;
|
pubKey: string | null;
|
||||||
subnet: string | null;
|
subnet: string | null;
|
||||||
}[];
|
}[];
|
||||||
|
}> {
|
||||||
|
return await lockManager.withLock(
|
||||||
|
`rebuild-client-associations:site-resource:${siteResource.siteResourceId}`,
|
||||||
|
() => rebuildClientAssociationsFromSiteResourceImpl(siteResource, trx),
|
||||||
|
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebuildClientAssociationsFromSiteResourceImpl(
|
||||||
|
siteResource: SiteResource,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<{
|
||||||
|
mergedAllClients: {
|
||||||
|
clientId: number;
|
||||||
|
pubKey: string | null;
|
||||||
|
subnet: string | null;
|
||||||
|
}[];
|
||||||
}> {
|
}> {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
|
`rebuildClientAssociations: [rebuildClientAssociationsFromSiteResource] START siteResourceId=${siteResource.siteResourceId} networkId=${siteResource.networkId} orgId=${siteResource.orgId}`
|
||||||
@@ -539,6 +561,29 @@ async function handleMessagesForSiteClients(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// get the number of sites on each of these clients so we can log it and make decisions about whether to send messages based on it
|
||||||
|
const clientSiteCounts: Record<number, number> = {};
|
||||||
|
if (clientsToProcess.size > 0) {
|
||||||
|
const clientIdsToProcess = Array.from(clientsToProcess.keys());
|
||||||
|
const siteCounts = await trx
|
||||||
|
.select({
|
||||||
|
clientId: clientSitesAssociationsCache.clientId,
|
||||||
|
siteCount: count(clientSitesAssociationsCache.siteId)
|
||||||
|
})
|
||||||
|
.from(clientSitesAssociationsCache)
|
||||||
|
.where(
|
||||||
|
inArray(
|
||||||
|
clientSitesAssociationsCache.clientId,
|
||||||
|
clientIdsToProcess
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.groupBy(clientSitesAssociationsCache.clientId);
|
||||||
|
|
||||||
|
for (const row of siteCounts) {
|
||||||
|
clientSiteCounts[row.clientId] = Number(row.siteCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const client of clientsToProcess.values()) {
|
for (const client of clientsToProcess.values()) {
|
||||||
// UPDATE THE NEWT
|
// UPDATE THE NEWT
|
||||||
if (!client.subnet || !client.pubKey) {
|
if (!client.subnet || !client.pubKey) {
|
||||||
@@ -582,7 +627,14 @@ async function handleMessagesForSiteClients(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isAdd) {
|
if (isAdd) {
|
||||||
// TODO: if we are in jit mode here should we really be sending this?
|
if (clientSiteCounts[client.clientId] > 250) {
|
||||||
|
// skip adding the peer if we have more than 250 sites because we are in jit mode anyway
|
||||||
|
logger.info(
|
||||||
|
`rebuildClientAssociations: Client ${client.clientId} has ${clientSiteCounts[client.clientId]} sites so skipping adding peer to newt and olm because it is likely in jit mode`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -600,9 +652,24 @@ async function handleMessagesForSiteClients(
|
|||||||
exitNodeJobs.push(updateClientSiteDestinations(client, trx));
|
exitNodeJobs.push(updateClientSiteDestinations(client, trx));
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(exitNodeJobs);
|
Promise.all(exitNodeJobs).catch((error) => {
|
||||||
await Promise.all(newtJobs); // do the servers first to make sure they are ready?
|
logger.error(
|
||||||
await Promise.all(olmJobs);
|
`rebuildClientAssociations: Error updating client site destinations for site ${site.siteId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Promise.all(newtJobs).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`rebuildClientAssociations: Error updating Newt peers for site ${site.siteId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Promise.all(olmJobs).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`rebuildClientAssociations: Error updating Olm peers for site ${site.siteId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PeerDestination {
|
interface PeerDestination {
|
||||||
@@ -885,6 +952,17 @@ async function handleSubnetProxyTargetUpdates(
|
|||||||
export async function rebuildClientAssociationsFromClient(
|
export async function rebuildClientAssociationsFromClient(
|
||||||
client: Client,
|
client: Client,
|
||||||
trx: Transaction | typeof db = db
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<void> {
|
||||||
|
return await lockManager.withLock(
|
||||||
|
`rebuild-client-associations:client:${client.clientId}`,
|
||||||
|
() => rebuildClientAssociationsFromClientImpl(client, trx),
|
||||||
|
REBUILD_ASSOCIATIONS_LOCK_TTL_MS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rebuildClientAssociationsFromClientImpl(
|
||||||
|
client: Client,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
let newSiteResourceIds: number[] = [];
|
let newSiteResourceIds: number[] = [];
|
||||||
|
|
||||||
@@ -1157,6 +1235,12 @@ async function handleMessagesForClientSites(
|
|||||||
const olmJobs: Promise<any>[] = [];
|
const olmJobs: Promise<any>[] = [];
|
||||||
const exitNodeJobs: Promise<any>[] = [];
|
const exitNodeJobs: Promise<any>[] = [];
|
||||||
|
|
||||||
|
const totalSitesOnClient = await trx
|
||||||
|
.select({ count: count(clientSitesAssociationsCache.siteId) })
|
||||||
|
.from(clientSitesAssociationsCache)
|
||||||
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId))
|
||||||
|
.then((rows) => Number(rows[0].count));
|
||||||
|
|
||||||
for (const siteData of sitesData) {
|
for (const siteData of sitesData) {
|
||||||
const site = siteData.sites;
|
const site = siteData.sites;
|
||||||
const exitNode = siteData.exitNodes;
|
const exitNode = siteData.exitNodes;
|
||||||
@@ -1217,7 +1301,14 @@ async function handleMessagesForClientSites(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: if we are in jit mode here should we really be sending this?
|
if (totalSitesOnClient > 250) {
|
||||||
|
// skip adding the site if we have more than 250 because we are in jit mode anyway
|
||||||
|
logger.info(
|
||||||
|
`rebuildClientAssociations: Client ${client.clientId} has ${totalSitesOnClient} sites so skipping adding peer to newt and olm because it is likely in jit mode`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await initPeerAddHandshake(
|
await initPeerAddHandshake(
|
||||||
// this will kick off the add peer process for the client
|
// this will kick off the add peer process for the client
|
||||||
client.clientId,
|
client.clientId,
|
||||||
@@ -1245,9 +1336,24 @@ async function handleMessagesForClientSites(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(exitNodeJobs);
|
Promise.all(exitNodeJobs).catch((error) => {
|
||||||
await Promise.all(newtJobs);
|
logger.error(
|
||||||
await Promise.all(olmJobs);
|
`rebuildClientAssociations: Error updating client site destinations for client ${client.clientId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Promise.all(newtJobs).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`rebuildClientAssociations: Error updating Newt peers for client ${client.clientId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Promise.all(olmJobs).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`rebuildClientAssociations: Error updating Olm peers for client ${client.clientId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleMessagesForClientResources(
|
async function handleMessagesForClientResources(
|
||||||
@@ -1528,3 +1634,269 @@ async function handleMessagesForClientResources(
|
|||||||
|
|
||||||
await Promise.all([...proxyJobs, ...olmJobs]);
|
await Promise.all([...proxyJobs, ...olmJobs]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ClientAssociationsCacheVerification = {
|
||||||
|
clientId: number;
|
||||||
|
consistent: boolean;
|
||||||
|
// What permissions say the cache should contain
|
||||||
|
expectedSiteResourceIds: number[];
|
||||||
|
expectedSiteIds: number[];
|
||||||
|
// What the cache currently contains
|
||||||
|
actualSiteResourceIds: number[];
|
||||||
|
actualSiteIds: number[];
|
||||||
|
// Diff
|
||||||
|
missingSiteResourceIds: number[]; // present in expected, missing from cache
|
||||||
|
extraSiteResourceIds: number[]; // present in cache, not in expected
|
||||||
|
missingSiteIds: number[];
|
||||||
|
extraSiteIds: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// verifyClientAssociationsCache walks the same permission-derivation logic as
|
||||||
|
// rebuildClientAssociationsFromClient but does NOT modify the database. It
|
||||||
|
// returns the expected vs actual cache contents and a boolean indicating
|
||||||
|
// whether the cache is in sync with what permissions imply.
|
||||||
|
export async function verifyClientAssociationsCache(
|
||||||
|
client: Client,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<ClientAssociationsCacheVerification> {
|
||||||
|
let newSiteResourceIds: number[] = [];
|
||||||
|
|
||||||
|
// 1. Direct client associations
|
||||||
|
const directSiteResources = await trx
|
||||||
|
.select({ siteResourceId: clientSiteResources.siteResourceId })
|
||||||
|
.from(clientSiteResources)
|
||||||
|
.innerJoin(
|
||||||
|
siteResources,
|
||||||
|
eq(siteResources.siteResourceId, clientSiteResources.siteResourceId)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(clientSiteResources.clientId, client.clientId),
|
||||||
|
eq(siteResources.orgId, client.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
newSiteResourceIds.push(
|
||||||
|
...directSiteResources.map((r) => r.siteResourceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. User-based and role-based access (if client has a userId)
|
||||||
|
if (client.userId) {
|
||||||
|
const userSiteResourceIds = await trx
|
||||||
|
.select({ siteResourceId: userSiteResources.siteResourceId })
|
||||||
|
.from(userSiteResources)
|
||||||
|
.innerJoin(
|
||||||
|
siteResources,
|
||||||
|
eq(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
userSiteResources.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userSiteResources.userId, client.userId),
|
||||||
|
eq(siteResources.orgId, client.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
newSiteResourceIds.push(
|
||||||
|
...userSiteResourceIds.map((r) => r.siteResourceId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const roleIds = await trx
|
||||||
|
.select({ roleId: userOrgRoles.roleId })
|
||||||
|
.from(userOrgRoles)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(userOrgRoles.userId, client.userId),
|
||||||
|
eq(userOrgRoles.orgId, client.orgId)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then((rows) => rows.map((row) => row.roleId));
|
||||||
|
|
||||||
|
if (roleIds.length > 0) {
|
||||||
|
const roleSiteResourceIds = await trx
|
||||||
|
.select({ siteResourceId: roleSiteResources.siteResourceId })
|
||||||
|
.from(roleSiteResources)
|
||||||
|
.innerJoin(
|
||||||
|
siteResources,
|
||||||
|
eq(
|
||||||
|
siteResources.siteResourceId,
|
||||||
|
roleSiteResources.siteResourceId
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
inArray(roleSiteResources.roleId, roleIds),
|
||||||
|
eq(siteResources.orgId, client.orgId)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
newSiteResourceIds.push(
|
||||||
|
...roleSiteResourceIds.map((r) => r.siteResourceId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newSiteResourceIds = Array.from(new Set(newSiteResourceIds));
|
||||||
|
|
||||||
|
const newSiteResources =
|
||||||
|
newSiteResourceIds.length > 0
|
||||||
|
? await trx
|
||||||
|
.select()
|
||||||
|
.from(siteResources)
|
||||||
|
.where(
|
||||||
|
inArray(siteResources.siteResourceId, newSiteResourceIds)
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const networkIds = Array.from(
|
||||||
|
new Set(
|
||||||
|
newSiteResources
|
||||||
|
.map((sr) => sr.networkId)
|
||||||
|
.filter((id): id is number => id !== null)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
const newSiteIds =
|
||||||
|
networkIds.length > 0
|
||||||
|
? await trx
|
||||||
|
.select({ siteId: siteNetworks.siteId })
|
||||||
|
.from(siteNetworks)
|
||||||
|
.where(inArray(siteNetworks.networkId, networkIds))
|
||||||
|
.then((rows) =>
|
||||||
|
Array.from(new Set(rows.map((r) => r.siteId)))
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Read the existing cache state
|
||||||
|
const existingResourceAssociations = await trx
|
||||||
|
.select({
|
||||||
|
siteResourceId: clientSiteResourcesAssociationsCache.siteResourceId
|
||||||
|
})
|
||||||
|
.from(clientSiteResourcesAssociationsCache)
|
||||||
|
.where(
|
||||||
|
eq(clientSiteResourcesAssociationsCache.clientId, client.clientId)
|
||||||
|
);
|
||||||
|
const existingSiteResourceIds = existingResourceAssociations.map(
|
||||||
|
(r) => r.siteResourceId
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingSiteAssociations = await trx
|
||||||
|
.select({ siteId: clientSitesAssociationsCache.siteId })
|
||||||
|
.from(clientSitesAssociationsCache)
|
||||||
|
.where(eq(clientSitesAssociationsCache.clientId, client.clientId));
|
||||||
|
const existingSiteIds = existingSiteAssociations.map((s) => s.siteId);
|
||||||
|
|
||||||
|
const expectedSiteResourceSet = new Set(newSiteResourceIds);
|
||||||
|
const actualSiteResourceSet = new Set(existingSiteResourceIds);
|
||||||
|
const expectedSiteSet = new Set(newSiteIds);
|
||||||
|
const actualSiteSet = new Set(existingSiteIds);
|
||||||
|
|
||||||
|
const missingSiteResourceIds = newSiteResourceIds.filter(
|
||||||
|
(id) => !actualSiteResourceSet.has(id)
|
||||||
|
);
|
||||||
|
const extraSiteResourceIds = existingSiteResourceIds.filter(
|
||||||
|
(id) => !expectedSiteResourceSet.has(id)
|
||||||
|
);
|
||||||
|
const missingSiteIds = newSiteIds.filter((id) => !actualSiteSet.has(id));
|
||||||
|
const extraSiteIds = existingSiteIds.filter(
|
||||||
|
(id) => !expectedSiteSet.has(id)
|
||||||
|
);
|
||||||
|
|
||||||
|
const consistent =
|
||||||
|
missingSiteResourceIds.length === 0 &&
|
||||||
|
extraSiteResourceIds.length === 0 &&
|
||||||
|
missingSiteIds.length === 0 &&
|
||||||
|
extraSiteIds.length === 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId: client.clientId,
|
||||||
|
consistent,
|
||||||
|
expectedSiteResourceIds: Array.from(expectedSiteResourceSet).sort(
|
||||||
|
(a, b) => a - b
|
||||||
|
),
|
||||||
|
expectedSiteIds: Array.from(expectedSiteSet).sort((a, b) => a - b),
|
||||||
|
actualSiteResourceIds: Array.from(actualSiteResourceSet).sort(
|
||||||
|
(a, b) => a - b
|
||||||
|
),
|
||||||
|
actualSiteIds: Array.from(actualSiteSet).sort((a, b) => a - b),
|
||||||
|
missingSiteResourceIds: missingSiteResourceIds.sort((a, b) => a - b),
|
||||||
|
extraSiteResourceIds: extraSiteResourceIds.sort((a, b) => a - b),
|
||||||
|
missingSiteIds: missingSiteIds.sort((a, b) => a - b),
|
||||||
|
extraSiteIds: extraSiteIds.sort((a, b) => a - b)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupSiteAssociations efficiently removes all client associations for a
|
||||||
|
// site that is being deleted. Instead of calling
|
||||||
|
// rebuildClientAssociationsFromSiteResource once per site resource (which is
|
||||||
|
// O(resources) in DB round-trips and message fan-out), this function performs
|
||||||
|
// a single bulk lookup of affected clients and site resources, deletes all
|
||||||
|
// cache rows at once, and fires all peer/proxy removal messages in parallel.
|
||||||
|
//
|
||||||
|
// The caller is responsible for deleting the site row itself (and for sending
|
||||||
|
// the newt/wg/terminate signal to the newt process).
|
||||||
|
export async function cleanupSiteAssociations(
|
||||||
|
site: Site,
|
||||||
|
trx: Transaction | typeof db = db
|
||||||
|
): Promise<void> {
|
||||||
|
const siteId = site.siteId;
|
||||||
|
|
||||||
|
logger.debug(`cleanupSiteAssociations: START siteId=${siteId}`);
|
||||||
|
|
||||||
|
// 1. Find every client currently cached against this site.
|
||||||
|
const cachedSiteClientRows = await trx
|
||||||
|
.select({ clientId: clientSitesAssociationsCache.clientId })
|
||||||
|
.from(clientSitesAssociationsCache)
|
||||||
|
.where(eq(clientSitesAssociationsCache.siteId, siteId));
|
||||||
|
|
||||||
|
const cachedClientIds = cachedSiteClientRows.map((r) => r.clientId);
|
||||||
|
|
||||||
|
// 2. Load full client details (needed for WireGuard public-key references).
|
||||||
|
const allClients =
|
||||||
|
cachedClientIds.length > 0
|
||||||
|
? await trx
|
||||||
|
.select({
|
||||||
|
clientId: clients.clientId,
|
||||||
|
pubKey: clients.pubKey,
|
||||||
|
subnet: clients.subnet
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.where(inArray(clients.clientId, cachedClientIds))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// 6. Bulk-delete all cache entries for this site. Do this before sending
|
||||||
|
// destination-update messages so updateClientSiteDestinations computes
|
||||||
|
// the correct (post-deletion) set of destinations.
|
||||||
|
await trx
|
||||||
|
.delete(clientSitesAssociationsCache)
|
||||||
|
.where(eq(clientSitesAssociationsCache.siteId, siteId));
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`cleanupSiteAssociations: siteId=${siteId} cache cleared. clients=${allClients.length}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 7. Fire all removal messages in parallel.
|
||||||
|
const jobs: Promise<any>[] = [];
|
||||||
|
|
||||||
|
for (const client of allClients) {
|
||||||
|
// Tell each olm to drop the site's WireGuard peer.
|
||||||
|
if (site.publicKey) {
|
||||||
|
jobs.push(olmDeletePeer(client.clientId, siteId, site.publicKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute and push updated relay destinations (now excluding this site).
|
||||||
|
if (client.pubKey && client.subnet) {
|
||||||
|
jobs.push(updateClientSiteDestinations(client, trx));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(jobs).catch((error) => {
|
||||||
|
logger.error(
|
||||||
|
`cleanupSiteAssociations: error sending cleanup messages for siteId=${siteId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.debug(`cleanupSiteAssociations: DONE siteId=${siteId}`);
|
||||||
|
}
|
||||||
|
|||||||
11
server/lib/requestParams.ts
Normal file
11
server/lib/requestParams.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export function getFirstString(value: unknown): string | undefined {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value) && typeof value[0] === "string") {
|
||||||
|
return value[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { resourceAccessToken, resources, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } 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 { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyAccessTokenAccess(
|
export async function verifyApiKeyAccessTokenAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,7 +13,7 @@ export async function verifyApiKeyAccessTokenAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const accessTokenId = req.params.accessTokenId;
|
const accessTokenId = getFirstString(req.params.accessTokenId);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -20,6 +21,12 @@ export async function verifyApiKeyAccessTokenAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!accessTokenId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [accessToken] = await db
|
const [accessToken] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resourceAccessToken)
|
.from(resourceAccessToken)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { apiKeys, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq, or } from "drizzle-orm";
|
import { and, eq, or } 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 { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyApiKeyAccess(
|
export async function verifyApiKeyApiKeyAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,8 +15,10 @@ export async function verifyApiKeyApiKeyAccess(
|
|||||||
const { apiKey: callerApiKey } = req;
|
const { apiKey: callerApiKey } = req;
|
||||||
|
|
||||||
const apiKeyId =
|
const apiKeyId =
|
||||||
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
|
getFirstString(req.params.apiKeyId) ||
|
||||||
const orgId = req.params.orgId;
|
getFirstString(req.body.apiKeyId) ||
|
||||||
|
getFirstString(req.query.apiKeyId);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!callerApiKey) {
|
if (!callerApiKey) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { db, domains, orgDomains, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } 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 { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyDomainAccess(
|
export async function verifyApiKeyDomainAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,8 +13,10 @@ export async function verifyApiKeyDomainAccess(
|
|||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const domainId =
|
const domainId =
|
||||||
req.params.domainId || req.body.domainId || req.query.domainId;
|
getFirstString(req.params.domainId) ||
|
||||||
const orgId = req.params.orgId;
|
getFirstString(req.body.domainId) ||
|
||||||
|
getFirstString(req.query.domainId);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -27,6 +30,12 @@ export async function verifyApiKeyDomainAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (apiKey.isRoot) {
|
if (apiKey.isRoot) {
|
||||||
// Root keys can access any domain in any org
|
// Root keys can access any domain in any org
|
||||||
return next();
|
return next();
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { idp, idpOrg, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } 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 { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyIdpAccess(
|
export async function verifyApiKeyIdpAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,8 +13,12 @@ export async function verifyApiKeyIdpAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const idpId = req.params.idpId || req.body.idpId || req.query.idpId;
|
const idpIdRaw =
|
||||||
const orgId = req.params.orgId;
|
getFirstString(req.params.idpId) ||
|
||||||
|
getFirstString(req.body.idpId) ||
|
||||||
|
getFirstString(req.query.idpId);
|
||||||
|
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -27,7 +32,7 @@ export async function verifyApiKeyIdpAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!idpId) {
|
if (Number.isNaN(idpId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID")
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } 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 { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyOrgAccess(
|
export async function verifyApiKeyOrgAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,7 +13,7 @@ export async function verifyApiKeyOrgAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKeyId = req.apiKey?.apiKeyId;
|
const apiKeyId = req.apiKey?.apiKeyId;
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!apiKeyId) {
|
if (!apiKeyId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -45,7 +46,7 @@ export async function verifyApiKeyOrgAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!req.apiKeyOrg) {
|
if (!req.apiKeyOrg) {
|
||||||
next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.FORBIDDEN,
|
HttpCode.FORBIDDEN,
|
||||||
"Key does not have access to this organization"
|
"Key does not have access to this organization"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { siteResources, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } 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 { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeySiteResourceAccess(
|
export async function verifyApiKeySiteResourceAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,7 +13,8 @@ export async function verifyApiKeySiteResourceAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const siteResourceId = parseInt(req.params.siteResourceId);
|
const siteResourceIdRaw = getFirstString(req.params.siteResourceId);
|
||||||
|
const siteResourceId = Number.parseInt(siteResourceIdRaw ?? "", 10);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -20,7 +22,7 @@ export async function verifyApiKeySiteResourceAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!siteResourceId) {
|
if (Number.isNaN(siteResourceId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.BAD_REQUEST,
|
HttpCode.BAD_REQUEST,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { resources, targets, apiKeyOrg } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } 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 { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyTargetAccess(
|
export async function verifyApiKeyTargetAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -12,7 +13,8 @@ export async function verifyApiKeyTargetAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const apiKey = req.apiKey;
|
const apiKey = req.apiKey;
|
||||||
const targetId = parseInt(req.params.targetId);
|
const targetIdRaw = getFirstString(req.params.targetId);
|
||||||
|
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
|
||||||
|
|
||||||
if (!apiKey) {
|
if (!apiKey) {
|
||||||
return next(
|
return next(
|
||||||
@@ -20,7 +22,7 @@ export async function verifyApiKeyTargetAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNaN(targetId)) {
|
if (Number.isNaN(targetId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid target ID")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
import { canUserAccessResource } from "@server/auth/canUserAccessResource";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyAccessTokenAccess(
|
export async function verifyAccessTokenAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,7 +15,7 @@ export async function verifyAccessTokenAccess(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const accessTokenId = req.params.accessTokenId;
|
const accessTokenId = getFirstString(req.params.accessTokenId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -22,6 +23,12 @@ export async function verifyAccessTokenAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!accessTokenId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid access token ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [accessToken] = await db
|
const [accessToken] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(resourceAccessToken)
|
.from(resourceAccessToken)
|
||||||
@@ -87,7 +94,7 @@ export async function verifyAccessTokenAccess(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyApiKeyAccess(
|
export async function verifyApiKeyAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,9 +15,24 @@ export async function verifyApiKeyAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const apiKeyId =
|
const apiKeyIdFromParams = getFirstString(req.params?.apiKeyId);
|
||||||
req.params.apiKeyId || req.body.apiKeyId || req.query.apiKeyId;
|
const apiKeyIdFromBody = getFirstString(req.body?.apiKeyId);
|
||||||
const orgId = req.params.orgId;
|
|
||||||
|
if (
|
||||||
|
apiKeyIdFromParams &&
|
||||||
|
apiKeyIdFromBody &&
|
||||||
|
apiKeyIdFromParams !== apiKeyIdFromBody
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"API key ID provided in both URL and body with different values"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const apiKeyId = apiKeyIdFromParams || apiKeyIdFromBody;
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -104,10 +120,7 @@ export async function verifyApiKeyAccess(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.userOrgRoleIds = await getUserOrgRoleIds(
|
req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId);
|
||||||
req.userOrg.userId,
|
|
||||||
orgId
|
|
||||||
);
|
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyDomainAccess(
|
export async function verifyDomainAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,9 +15,8 @@ export async function verifyDomainAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const domainId =
|
const domainId = getFirstString(req.params.domainId);
|
||||||
req.params.domainId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
const orgId = req.params.orgId;
|
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -62,10 +62,7 @@ export async function verifyDomainAccess(
|
|||||||
.select()
|
.select()
|
||||||
.from(userOrgs)
|
.from(userOrgs)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))
|
||||||
eq(userOrgs.userId, userId),
|
|
||||||
eq(userOrgs.orgId, orgId)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
req.userOrg = userOrgRole[0];
|
req.userOrg = userOrgRole[0];
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyLimits(
|
export async function verifyLimits(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -13,7 +14,10 @@ export async function verifyLimits(
|
|||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
const orgId = req.userOrgId || req.apiKeyOrg?.orgId || req.params.orgId;
|
const orgId =
|
||||||
|
req.userOrgId ||
|
||||||
|
req.apiKeyOrg?.orgId ||
|
||||||
|
getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return next(); // its fine if we silently fail here because this is not critical to operation or security and its better user experience if we dont fail
|
return next(); // its fine if we silently fail here because this is not critical to operation or security and its better user experience if we dont fail
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import createHttpError from "http-errors";
|
|||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyOrgAccess(
|
export async function verifyOrgAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -13,7 +14,7 @@ export async function verifyOrgAccess(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { db, userOrgs, siteProvisioningKeys, siteProvisioningKeyOrg } from "@server/db";
|
import {
|
||||||
|
db,
|
||||||
|
userOrgs,
|
||||||
|
siteProvisioningKeys,
|
||||||
|
siteProvisioningKeyOrg
|
||||||
|
} from "@server/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } 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 { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifySiteProvisioningKeyAccess(
|
export async function verifySiteProvisioningKeyAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -13,8 +19,10 @@ export async function verifySiteProvisioningKeyAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const siteProvisioningKeyId = req.params.siteProvisioningKeyId;
|
const siteProvisioningKeyId = getFirstString(
|
||||||
const orgId = req.params.orgId;
|
req.params.siteProvisioningKeyId
|
||||||
|
);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -80,10 +88,7 @@ export async function verifySiteProvisioningKeyAccess(
|
|||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(userOrgs.userId, userId),
|
eq(userOrgs.userId, userId),
|
||||||
eq(
|
eq(userOrgs.orgId, row.siteProvisioningKeyOrg.orgId)
|
||||||
userOrgs.orgId,
|
|
||||||
row.siteProvisioningKeyOrg.orgId
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import HttpCode from "@server/types/HttpCode";
|
|||||||
import { canUserAccessResource } from "../auth/canUserAccessResource";
|
import { canUserAccessResource } from "../auth/canUserAccessResource";
|
||||||
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy";
|
||||||
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyTargetAccess(
|
export async function verifyTargetAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -14,7 +15,8 @@ export async function verifyTargetAccess(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const targetId = parseInt(req.params.targetId);
|
const targetIdRaw = getFirstString(req.params.targetId);
|
||||||
|
const targetId = Number.parseInt(targetIdRaw ?? "", 10);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { userOrgs } from "@server/db";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } 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 { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyUserIsOrgOwner(
|
export async function verifyUserIsOrgOwner(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -11,7 +12,7 @@ export async function verifyUserIsOrgOwner(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ import { LogStreamingManager } from "./LogStreamingManager";
|
|||||||
*/
|
*/
|
||||||
export const logStreamingManager = new LogStreamingManager();
|
export const logStreamingManager = new LogStreamingManager();
|
||||||
|
|
||||||
if (build !== "saas") {
|
if (build != "saas") { // this is handled separately in the saas build, so we don't want to start it here
|
||||||
// this is handled separately in the saas build, so we don't want to start it here
|
|
||||||
logStreamingManager.start();
|
logStreamingManager.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { eq, and } 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";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyCertificateAccess(
|
export async function verifyCertificateAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -27,11 +28,43 @@ export async function verifyCertificateAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
// Assume user/org access is already verified
|
// Assume user/org access is already verified
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
const certId =
|
|
||||||
req.params.certId || req.body?.certId || req.query?.certId;
|
const certIdFromParams = getFirstString(req.params?.certId);
|
||||||
let domainId =
|
const certIdFromBody = getFirstString(req.body?.certId);
|
||||||
req.params.domainId || req.body?.domainId || req.query?.domainId;
|
|
||||||
|
if (
|
||||||
|
certIdFromParams &&
|
||||||
|
certIdFromBody &&
|
||||||
|
certIdFromParams !== certIdFromBody
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Certificate ID provided in both URL and body with different values"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const certId = certIdFromParams || certIdFromBody;
|
||||||
|
|
||||||
|
const domainIdFromParams = getFirstString(req.params?.domainId);
|
||||||
|
const domainIdFromBody = getFirstString(req.body?.domainId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
domainIdFromParams &&
|
||||||
|
domainIdFromBody &&
|
||||||
|
domainIdFromParams !== domainIdFromBody
|
||||||
|
) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Domain ID provided in both URL and body with different values"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let domainId = domainIdFromParams || domainIdFromBody;
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -65,7 +98,7 @@ export async function verifyCertificateAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
domainId = cert.domainId;
|
domainId = cert.domainId ?? undefined;
|
||||||
if (!domainId) {
|
if (!domainId) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { and, eq } 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 { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyIdpAccess(
|
export async function verifyIdpAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -25,8 +26,12 @@ export async function verifyIdpAccess(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const userId = req.user!.userId;
|
const userId = req.user!.userId;
|
||||||
const idpId = req.params.idpId || req.body.idpId || req.query.idpId;
|
const idpIdRaw =
|
||||||
const orgId = req.params.orgId;
|
getFirstString(req.params.idpId) ||
|
||||||
|
getFirstString(req.body?.idpId) ||
|
||||||
|
getFirstString(req.query?.idpId);
|
||||||
|
const idpId = Number.parseInt(idpIdRaw ?? "", 10);
|
||||||
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -40,7 +45,7 @@ export async function verifyIdpAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!idpId) {
|
if (Number.isNaN(idpId)) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID")
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { and, eq } 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 { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
import { getUserOrgRoleIds } from "@server/lib/userOrgRoles";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function verifyRemoteExitNodeAccess(
|
export async function verifyRemoteExitNodeAccess(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -25,11 +26,11 @@ export async function verifyRemoteExitNodeAccess(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
) {
|
) {
|
||||||
const userId = req.user!.userId; // Assuming you have user information in the request
|
const userId = req.user!.userId; // Assuming you have user information in the request
|
||||||
const orgId = req.params.orgId;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
const remoteExitNodeId =
|
const remoteExitNodeId =
|
||||||
req.params.remoteExitNodeId ||
|
getFirstString(req.params.remoteExitNodeId) ||
|
||||||
req.body.remoteExitNodeId ||
|
getFirstString(req.body?.remoteExitNodeId) ||
|
||||||
req.query.remoteExitNodeId;
|
getFirstString(req.query?.remoteExitNodeId);
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return next(
|
return next(
|
||||||
@@ -37,6 +38,15 @@ export async function verifyRemoteExitNodeAccess(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!orgId || !remoteExitNodeId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.BAD_REQUEST,
|
||||||
|
"Invalid organization or remote exit node ID"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [remoteExitNode] = await db
|
const [remoteExitNode] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export function verifyValidSubscription(tiers: Tier[]) {
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
if (build !== "saas") {
|
if (build != "saas") {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,20 @@ export const queryAccessAuditLogsCombined = queryAccessAuditLogsQuery.merge(
|
|||||||
);
|
);
|
||||||
type Q = z.infer<typeof queryAccessAuditLogsCombined>;
|
type Q = z.infer<typeof queryAccessAuditLogsCombined>;
|
||||||
|
|
||||||
|
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
|
||||||
|
items: T[]
|
||||||
|
): T[] {
|
||||||
|
return [...items].sort((a, b) => {
|
||||||
|
const nameA = a.name ?? "";
|
||||||
|
const nameB = b.name ?? "";
|
||||||
|
|
||||||
|
if (nameA < nameB) return -1;
|
||||||
|
if (nameA > nameB) return 1;
|
||||||
|
|
||||||
|
return a.id - b.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getWhere(data: Q) {
|
function getWhere(data: Q) {
|
||||||
return and(
|
return and(
|
||||||
gt(accessAuditLog.timestamp, data.timeStart),
|
gt(accessAuditLog.timestamp, data.timeStart),
|
||||||
@@ -308,7 +322,7 @@ async function queryUniqueFilterAttributes(
|
|||||||
actors: uniqueActors
|
actors: uniqueActors
|
||||||
.map((row) => row.actor)
|
.map((row) => row.actor)
|
||||||
.filter((actor): actor is string => actor !== null),
|
.filter((actor): actor is string => actor !== null),
|
||||||
resources: resourcesWithNames,
|
resources: sortNamedFilterOptions(resourcesWithNames),
|
||||||
locations: uniqueLocations
|
locations: uniqueLocations
|
||||||
.map((row) => row.locations)
|
.map((row) => row.locations)
|
||||||
.filter((location): location is string => location !== null)
|
.filter((location): location is string => location !== null)
|
||||||
|
|||||||
@@ -107,6 +107,20 @@ export const queryConnectionAuditLogsCombined =
|
|||||||
queryConnectionAuditLogsQuery.merge(queryConnectionAuditLogsParams);
|
queryConnectionAuditLogsQuery.merge(queryConnectionAuditLogsParams);
|
||||||
type Q = z.infer<typeof queryConnectionAuditLogsCombined>;
|
type Q = z.infer<typeof queryConnectionAuditLogsCombined>;
|
||||||
|
|
||||||
|
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
|
||||||
|
items: T[]
|
||||||
|
): T[] {
|
||||||
|
return [...items].sort((a, b) => {
|
||||||
|
const nameA = a.name ?? "";
|
||||||
|
const nameB = b.name ?? "";
|
||||||
|
|
||||||
|
if (nameA < nameB) return -1;
|
||||||
|
if (nameA > nameB) return 1;
|
||||||
|
|
||||||
|
return a.id - b.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getWhere(data: Q) {
|
function getWhere(data: Q) {
|
||||||
return and(
|
return and(
|
||||||
gt(connectionAuditLog.startedAt, data.timeStart),
|
gt(connectionAuditLog.startedAt, data.timeStart),
|
||||||
@@ -425,7 +439,7 @@ async function queryUniqueFilterAttributes(
|
|||||||
.map((row) => row.destAddr)
|
.map((row) => row.destAddr)
|
||||||
.filter((addr): addr is string => addr !== null),
|
.filter((addr): addr is string => addr !== null),
|
||||||
clients: clientsWithNames,
|
clients: clientsWithNames,
|
||||||
resources: resourcesWithNames,
|
resources: sortNamedFilterOptions(resourcesWithNames),
|
||||||
users: usersWithEmails
|
users: usersWithEmails
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import * as siteProvisioning from "#private/routers/siteProvisioning";
|
|||||||
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
import * as eventStreamingDestination from "#private/routers/eventStreamingDestination";
|
||||||
import * as alertRule from "#private/routers/alertRule";
|
import * as alertRule from "#private/routers/alertRule";
|
||||||
import * as healthChecks from "#private/routers/healthChecks";
|
import * as healthChecks from "#private/routers/healthChecks";
|
||||||
import * as labels from "#private/routers/labels";
|
import * as client from "@server/routers/client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
verifyOrgAccess,
|
verifyOrgAccess,
|
||||||
@@ -733,59 +733,6 @@ authenticated.get(
|
|||||||
alertRule.getAlertRule
|
alertRule.getAlertRule
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.get(
|
|
||||||
"/org/:orgId/labels",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyValidSubscription(tierMatrix.labels),
|
|
||||||
verifyUserHasAction(ActionsEnum.listOrgLabels),
|
|
||||||
labels.listOrgLabels
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/org/:orgId/labels",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyValidSubscription(tierMatrix.labels),
|
|
||||||
verifyUserHasAction(ActionsEnum.createOrgLabel),
|
|
||||||
labels.createOrgLabel
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.patch(
|
|
||||||
"/org/:orgId/label/:labelId",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyValidSubscription(tierMatrix.labels),
|
|
||||||
verifyUserHasAction(ActionsEnum.updateOrgLabel),
|
|
||||||
labels.updateOrgLabel
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
|
||||||
"/org/:orgId/label/:labelId",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyUserHasAction(ActionsEnum.deleteOrgLabel),
|
|
||||||
labels.deleteOrgLabel
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/label/:labelId/attach",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyValidSubscription(tierMatrix.labels),
|
|
||||||
verifyUserHasAction(ActionsEnum.attachLabelToItem),
|
|
||||||
labels.attachLabelToItem
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.put(
|
|
||||||
"/org/:orgId/label/:labelId/detach",
|
|
||||||
verifyValidLicense,
|
|
||||||
verifyOrgAccess,
|
|
||||||
verifyValidSubscription(tierMatrix.labels),
|
|
||||||
verifyUserHasAction(ActionsEnum.detachLabelFromItem),
|
|
||||||
labels.detachLabelFromItem
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
"/org/:orgId/health-checks",
|
"/org/:orgId/health-checks",
|
||||||
verifyValidLicense,
|
verifyValidLicense,
|
||||||
@@ -829,3 +776,15 @@ authenticated.get(
|
|||||||
verifyUserHasAction(ActionsEnum.getTarget),
|
verifyUserHasAction(ActionsEnum.getTarget),
|
||||||
healthChecks.getHealthCheckStatusHistory
|
healthChecks.getHealthCheckStatusHistory
|
||||||
);
|
);
|
||||||
|
|
||||||
|
authenticated.get(
|
||||||
|
"/client/:clientId/verify-associations-cache",
|
||||||
|
verifyClientAccess,
|
||||||
|
client.verifyClientAssociationsCache
|
||||||
|
);
|
||||||
|
|
||||||
|
authenticated.post(
|
||||||
|
"/client/:clientId/rebuild-associations-cache",
|
||||||
|
verifyClientAccess,
|
||||||
|
client.rebuildClientAssociationsCacheRoute
|
||||||
|
);
|
||||||
|
|||||||
@@ -16,40 +16,44 @@ 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 { response as sendResponse } from "@server/lib/response";
|
import { response as sendResponse } from "@server/lib/response";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
|
import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types";
|
||||||
|
|
||||||
export interface CreateNewLicenseResponse {
|
export interface CreateNewLicenseResponse {
|
||||||
data: Data
|
data: Data;
|
||||||
success: boolean
|
success: boolean;
|
||||||
error: boolean
|
error: boolean;
|
||||||
message: string
|
message: string;
|
||||||
status: number
|
status: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Data {
|
export interface Data {
|
||||||
licenseKey: LicenseKey
|
licenseKey: LicenseKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LicenseKey {
|
export interface LicenseKey {
|
||||||
id: number
|
id: number;
|
||||||
instanceName: any
|
instanceName: any;
|
||||||
instanceId: string
|
instanceId: string;
|
||||||
licenseKey: string
|
licenseKey: string;
|
||||||
tier: string
|
tier: string;
|
||||||
type: string
|
type: string;
|
||||||
quantity: number
|
quantity: number;
|
||||||
quantity_2: number
|
quantity_2: number;
|
||||||
isValid: boolean
|
isValid: boolean;
|
||||||
updatedAt: string
|
updatedAt: string;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
expiresAt: string
|
expiresAt: string;
|
||||||
paidFor: boolean
|
paidFor: boolean;
|
||||||
orgId: string
|
orgId: string;
|
||||||
metadata: string
|
metadata: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createNewLicense(orgId: string, licenseData: any): Promise<CreateNewLicenseResponse> {
|
export async function createNewLicense(
|
||||||
|
orgId: string,
|
||||||
|
licenseData: any
|
||||||
|
): Promise<CreateNewLicenseResponse> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both
|
`${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both
|
||||||
@@ -80,7 +84,7 @@ export async function generateNewLicense(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const { orgId } = req.params;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -16,6 +16,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 { response as sendResponse } from "@server/lib/response";
|
import { response as sendResponse } from "@server/lib/response";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import {
|
import {
|
||||||
GeneratedLicenseKey,
|
GeneratedLicenseKey,
|
||||||
@@ -55,7 +56,7 @@ export async function listSaasLicenseKeys(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const { orgId } = req.params;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
return next(
|
return next(
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
clients,
|
|
||||||
clientLabels,
|
|
||||||
db,
|
|
||||||
labels,
|
|
||||||
resourceLabels,
|
|
||||||
resources,
|
|
||||||
siteLabels,
|
|
||||||
siteResourceLabels,
|
|
||||||
siteResources,
|
|
||||||
sites
|
|
||||||
} from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty(),
|
|
||||||
labelId: z.string().transform(Number).pipe(z.int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
const attachLabelBodySchema = z.strictObject({
|
|
||||||
siteId: z.number().int().optional(),
|
|
||||||
resourceId: z.number().int().optional(),
|
|
||||||
siteResourceId: z.number().int().optional(),
|
|
||||||
clientId: z.number().int().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function attachLabelToItem(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId, labelId } = parsedParams.data;
|
|
||||||
|
|
||||||
const parsedBody = attachLabelBodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { siteId, resourceId, siteResourceId, clientId } =
|
|
||||||
parsedBody.data;
|
|
||||||
|
|
||||||
if (!siteId && !resourceId && !siteResourceId && !clientId) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(labels)
|
|
||||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Label with Id ${labelId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (siteId) {
|
|
||||||
const siteCount = await db.$count(
|
|
||||||
sites,
|
|
||||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (siteCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Site with Id ${siteId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// idempotent, calling this endpoint multiple times should attach the label only once
|
|
||||||
await db
|
|
||||||
.insert(siteLabels)
|
|
||||||
.values({
|
|
||||||
labelId,
|
|
||||||
siteId
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resourceId) {
|
|
||||||
const resourceCount = await db.$count(
|
|
||||||
resources,
|
|
||||||
and(
|
|
||||||
eq(resources.resourceId, resourceId),
|
|
||||||
eq(resources.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (resourceCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Resource with Id ${resourceId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// idempotent, calling this endpoint multiple times should attach the label only once
|
|
||||||
await db
|
|
||||||
.insert(resourceLabels)
|
|
||||||
.values({
|
|
||||||
labelId,
|
|
||||||
resourceId
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (siteResourceId) {
|
|
||||||
const resourceCount = await db.$count(
|
|
||||||
siteResources,
|
|
||||||
and(
|
|
||||||
eq(siteResources.siteResourceId, siteResourceId),
|
|
||||||
eq(siteResources.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (resourceCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`SiteResource with Id ${siteResourceId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// idempotent, calling this endpoint multiple times should attach the label only once
|
|
||||||
await db
|
|
||||||
.insert(siteResourceLabels)
|
|
||||||
.values({
|
|
||||||
labelId,
|
|
||||||
siteResourceId
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clientId) {
|
|
||||||
const clientCount = await db.$count(
|
|
||||||
clients,
|
|
||||||
and(
|
|
||||||
eq(clients.clientId, clientId),
|
|
||||||
eq(clients.orgId, orgId),
|
|
||||||
isNull(clients.userId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (clientCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Client with Id ${clientId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// idempotent, calling this endpoint multiple times should attach the label only once
|
|
||||||
await db
|
|
||||||
.insert(clientLabels)
|
|
||||||
.values({
|
|
||||||
labelId,
|
|
||||||
clientId
|
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
|
||||||
}
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: {},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Label attached successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
import {
|
|
||||||
db,
|
|
||||||
labels,
|
|
||||||
resourceLabels,
|
|
||||||
resources,
|
|
||||||
siteLabels,
|
|
||||||
sites
|
|
||||||
} from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty()
|
|
||||||
});
|
|
||||||
|
|
||||||
const bodySchema = z.strictObject({
|
|
||||||
name: z.string().nonempty(),
|
|
||||||
color: z
|
|
||||||
.string()
|
|
||||||
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
|
|
||||||
.nonempty(),
|
|
||||||
siteId: z.number().int().optional(),
|
|
||||||
resourceId: z.number().int().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function createOrgLabel(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId } = parsedParams.data;
|
|
||||||
|
|
||||||
const parsedBody = bodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { name, color, siteId, resourceId } = parsedBody.data;
|
|
||||||
|
|
||||||
if (siteId) {
|
|
||||||
const siteCount = await db.$count(
|
|
||||||
sites,
|
|
||||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (siteCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
`Site with Id ${siteId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resourceId) {
|
|
||||||
const resourceCount = await db.$count(
|
|
||||||
resources,
|
|
||||||
and(
|
|
||||||
eq(resources.resourceId, resourceId),
|
|
||||||
eq(resources.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (resourceCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
`Resource with Id ${resourceId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const label = await db.transaction(async (tx) => {
|
|
||||||
const [label] = await tx
|
|
||||||
.insert(labels)
|
|
||||||
.values({
|
|
||||||
name,
|
|
||||||
color,
|
|
||||||
orgId
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
if (siteId) {
|
|
||||||
await tx.insert(siteLabels).values({
|
|
||||||
siteId,
|
|
||||||
labelId: label.labelId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resourceId) {
|
|
||||||
await tx.insert(resourceLabels).values({
|
|
||||||
resourceId,
|
|
||||||
labelId: label.labelId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return label;
|
|
||||||
});
|
|
||||||
|
|
||||||
return response<CreateOrEditLabelResponse>(res, {
|
|
||||||
data: { label },
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Org Label created successfully",
|
|
||||||
status: HttpCode.CREATED
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
import { db, labels } from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty(),
|
|
||||||
labelId: z.string().transform(Number).pipe(z.int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function deleteOrgLabel(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId, labelId } = parsedParams.data;
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(labels)
|
|
||||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(labels)
|
|
||||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: null,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Label deleted successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import {
|
|
||||||
clients,
|
|
||||||
clientLabels,
|
|
||||||
db,
|
|
||||||
labels,
|
|
||||||
resourceLabels,
|
|
||||||
resources,
|
|
||||||
siteLabels,
|
|
||||||
siteResourceLabels,
|
|
||||||
siteResources,
|
|
||||||
sites
|
|
||||||
} from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty(),
|
|
||||||
labelId: z.string().transform(Number).pipe(z.int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
const detachLabelBodySchema = z.strictObject({
|
|
||||||
siteId: z.number().int().optional(),
|
|
||||||
resourceId: z.number().int().optional(),
|
|
||||||
siteResourceId: z.number().int().optional(),
|
|
||||||
clientId: z.number().int().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function detachLabelFromItem(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId, labelId } = parsedParams.data;
|
|
||||||
|
|
||||||
const parsedBody = detachLabelBodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { siteId, resourceId, siteResourceId, clientId } =
|
|
||||||
parsedBody.data;
|
|
||||||
|
|
||||||
if (!siteId && !resourceId && !siteResourceId && !clientId) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"At least one of `siteId`, `resourceId`, `siteResourceId` or `clientId` should be provided."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(labels)
|
|
||||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Label with Id ${labelId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (siteId) {
|
|
||||||
const siteCount = await db.$count(
|
|
||||||
sites,
|
|
||||||
and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))
|
|
||||||
);
|
|
||||||
|
|
||||||
if (siteCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Site with Id ${siteId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(siteLabels)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(siteLabels.labelId, labelId),
|
|
||||||
eq(siteLabels.siteId, siteId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (resourceId) {
|
|
||||||
const resourceCount = await db.$count(
|
|
||||||
resources,
|
|
||||||
and(
|
|
||||||
eq(resources.resourceId, resourceId),
|
|
||||||
eq(resources.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (resourceCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Resource with Id ${resourceId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(resourceLabels)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(resourceLabels.labelId, labelId),
|
|
||||||
eq(resourceLabels.resourceId, resourceId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (siteResourceId) {
|
|
||||||
const resourceCount = await db.$count(
|
|
||||||
siteResources,
|
|
||||||
and(
|
|
||||||
eq(siteResources.siteResourceId, siteResourceId),
|
|
||||||
eq(siteResources.orgId, orgId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (resourceCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`SiteResource with Id ${siteResourceId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(siteResourceLabels)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(siteResourceLabels.labelId, labelId),
|
|
||||||
eq(siteResourceLabels.siteResourceId, siteResourceId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (clientId) {
|
|
||||||
const clientCount = await db.$count(
|
|
||||||
clients,
|
|
||||||
and(
|
|
||||||
eq(clients.clientId, clientId),
|
|
||||||
eq(clients.orgId, orgId),
|
|
||||||
isNull(clients.userId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (clientCount === 0) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Client with Id ${clientId} doesn't exist.`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await db
|
|
||||||
.delete(clientLabels)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(clientLabels.labelId, labelId),
|
|
||||||
eq(clientLabels.clientId, clientId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response(res, {
|
|
||||||
data: {},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Label detached successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export * from "./listOrgLabels";
|
|
||||||
export * from "./createOrgLabel";
|
|
||||||
export * from "./updateOrgLabel";
|
|
||||||
export * from "./attachLabelToItem";
|
|
||||||
export * from "./detachLabelFromItem";
|
|
||||||
export * from "./deleteOrgLabel";
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { db, labels } from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import type { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { and, asc, eq, like, sql } from "drizzle-orm";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty()
|
|
||||||
});
|
|
||||||
|
|
||||||
const listLabelsSchema = z.object({
|
|
||||||
pageSize: z.coerce
|
|
||||||
.number<string>() // for prettier formatting
|
|
||||||
.int()
|
|
||||||
.positive()
|
|
||||||
.optional()
|
|
||||||
.catch(20)
|
|
||||||
.default(20)
|
|
||||||
.openapi({
|
|
||||||
type: "integer",
|
|
||||||
default: 20,
|
|
||||||
description: "Number of items per page"
|
|
||||||
}),
|
|
||||||
page: z.coerce
|
|
||||||
.number<string>() // for prettier formatting
|
|
||||||
.int()
|
|
||||||
.min(0)
|
|
||||||
.optional()
|
|
||||||
.catch(1)
|
|
||||||
.default(1)
|
|
||||||
.openapi({
|
|
||||||
type: "integer",
|
|
||||||
default: 1,
|
|
||||||
description: "Page number to retrieve"
|
|
||||||
}),
|
|
||||||
query: z.string().optional()
|
|
||||||
});
|
|
||||||
|
|
||||||
function queryLabelsBase() {
|
|
||||||
return db
|
|
||||||
.select({
|
|
||||||
labelId: labels.labelId,
|
|
||||||
name: labels.name,
|
|
||||||
color: labels.color
|
|
||||||
})
|
|
||||||
.from(labels);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function listOrgLabels(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedQuery = listLabelsSchema.safeParse(req.query);
|
|
||||||
if (!parsedQuery.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedQuery.error)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const { orgId } = parsedParams.data;
|
|
||||||
|
|
||||||
if (req.user && orgId && orgId !== req.userOrgId) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.FORBIDDEN,
|
|
||||||
"User does not have access to this organization"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { pageSize, page, query } = parsedQuery.data;
|
|
||||||
|
|
||||||
const conditions = [and(eq(labels.orgId, orgId))];
|
|
||||||
|
|
||||||
if (query) {
|
|
||||||
conditions.push(
|
|
||||||
like(
|
|
||||||
sql`LOWER(${labels.name})`,
|
|
||||||
"%" + query.toLowerCase() + "%"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseQuery = queryLabelsBase().where(and(...conditions));
|
|
||||||
|
|
||||||
// we need to add `as` so that drizzle filters the result as a subquery
|
|
||||||
const countQuery = db.$count(
|
|
||||||
queryLabelsBase()
|
|
||||||
.where(and(...conditions))
|
|
||||||
.as("filtered_labels")
|
|
||||||
);
|
|
||||||
|
|
||||||
const labelListQuery = baseQuery
|
|
||||||
.limit(pageSize)
|
|
||||||
.offset(pageSize * (page - 1))
|
|
||||||
.orderBy(asc(labels.name));
|
|
||||||
|
|
||||||
const [totalCount, rows] = await Promise.all([
|
|
||||||
countQuery,
|
|
||||||
labelListQuery
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response<ListOrgLabelsResponse>(res, {
|
|
||||||
data: {
|
|
||||||
labels: rows,
|
|
||||||
pagination: {
|
|
||||||
total: totalCount,
|
|
||||||
pageSize,
|
|
||||||
page
|
|
||||||
}
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Labels retrieved successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { db, labels } from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
|
||||||
import { NextFunction, Request, Response } from "express";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
|
||||||
orgId: z.string().nonempty(),
|
|
||||||
labelId: z.string().transform(Number).pipe(z.int().positive())
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateLabelBodySchema = z.strictObject({
|
|
||||||
name: z.string().min(1).max(255).optional(),
|
|
||||||
color: z
|
|
||||||
.string()
|
|
||||||
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
|
|
||||||
.nonempty()
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function updateOrgLabel(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
) {
|
|
||||||
try {
|
|
||||||
const parsedParams = paramsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { orgId, labelId } = parsedParams.data;
|
|
||||||
|
|
||||||
const parsedBody = updateLabelBodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select()
|
|
||||||
.from(labels)
|
|
||||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)));
|
|
||||||
|
|
||||||
if (!existing) {
|
|
||||||
return next(createHttpError(HttpCode.NOT_FOUND, "Label not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
const { name, color } = parsedBody.data;
|
|
||||||
|
|
||||||
const [label] = await db
|
|
||||||
.update(labels)
|
|
||||||
.set({
|
|
||||||
name,
|
|
||||||
color
|
|
||||||
})
|
|
||||||
.where(and(eq(labels.labelId, labelId), eq(labels.orgId, orgId)))
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return response<CreateOrEditLabelResponse>(res, {
|
|
||||||
data: {
|
|
||||||
label
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Label updated successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,7 +26,6 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq, InferInsertModel } from "drizzle-orm";
|
import { eq, InferInsertModel } from "drizzle-orm";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { validateLocalPath } from "@app/lib/validateLocalPath";
|
|
||||||
import config from "#private/lib/config";
|
import config from "#private/lib/config";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
@@ -35,78 +34,9 @@ const paramsSchema = z.strictObject({
|
|||||||
|
|
||||||
const bodySchema = z.strictObject({
|
const bodySchema = z.strictObject({
|
||||||
logoUrl: z
|
logoUrl: z
|
||||||
.union([
|
.string()
|
||||||
z.literal(""),
|
.optional()
|
||||||
z
|
.transform((val) => (val === "" ? null : val)),
|
||||||
.string()
|
|
||||||
.superRefine(async (urlOrPath, ctx) => {
|
|
||||||
const parseResult = z.url().safeParse(urlOrPath);
|
|
||||||
if (!parseResult.success) {
|
|
||||||
if (build !== "enterprise") {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: "Must be a valid URL"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
validateLocalPath(urlOrPath);
|
|
||||||
} catch (error) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(urlOrPath, {
|
|
||||||
method: "HEAD"
|
|
||||||
}).catch(() => {
|
|
||||||
// If HEAD fails (CORS or method not allowed), try GET
|
|
||||||
return fetch(urlOrPath, { method: "GET" });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `Failed to load image. Please check that the URL is accessible.`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType =
|
|
||||||
response.headers.get("content-type") ?? "";
|
|
||||||
if (!contentType.startsWith("image/")) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
let errorMessage =
|
|
||||||
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
|
|
||||||
|
|
||||||
if (error instanceof TypeError && error.message.includes("fetch")) {
|
|
||||||
errorMessage =
|
|
||||||
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
errorMessage = `Error verifying URL: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: errorMessage
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
])
|
|
||||||
.transform((val) => (val === "" ? null : val))
|
|
||||||
.nullish(),
|
|
||||||
logoWidth: z.coerce.number<number>().min(1),
|
logoWidth: z.coerce.number<number>().min(1),
|
||||||
logoHeight: z.coerce.number<number>().min(1),
|
logoHeight: z.coerce.number<number>().min(1),
|
||||||
resourceTitle: z.string(),
|
resourceTitle: z.string(),
|
||||||
|
|||||||
@@ -1,202 +0,0 @@
|
|||||||
/*
|
|
||||||
* This file is part of a proprietary work.
|
|
||||||
*
|
|
||||||
* Copyright (c) 2025-2026 Fossorial, Inc.
|
|
||||||
* All rights reserved.
|
|
||||||
*
|
|
||||||
* This file is licensed under the Fossorial Commercial License.
|
|
||||||
* You may not use this file except in compliance with the License.
|
|
||||||
* Unauthorized use, copying, modification, or distribution is strictly prohibited.
|
|
||||||
*
|
|
||||||
* This file is not licensed under the AGPLv3.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import axios from "axios";
|
|
||||||
import { db, exitNodes, newts, sites } from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import redisManager from "#private/lib/redis";
|
|
||||||
import { sendToClient } from "#private/routers/ws";
|
|
||||||
|
|
||||||
const INITIAL_DELAY_MS = 15 * 1000; // 15 seconds before first check
|
|
||||||
const CHECK_INTERVAL_MS = 10 * 1000; // Check every 10 seconds
|
|
||||||
const MAX_DURATION_MS = 5 * 60 * 1000; // Give up after 5 minutes
|
|
||||||
const REDIS_PENDING_SET = "exit-node-reconnect-pending";
|
|
||||||
const REDIS_HASH_PREFIX = "exit-node-reconnect:";
|
|
||||||
|
|
||||||
interface PendingReconnect {
|
|
||||||
startTime: number;
|
|
||||||
reachableAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// In-memory tracking for this node
|
|
||||||
const pendingReconnects = new Map<number, PendingReconnect>();
|
|
||||||
|
|
||||||
let schedulerInterval: NodeJS.Timeout | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Schedules a reconnect check for newts connected to the given exit node.
|
|
||||||
* Called when an exit node transitions from offline to online.
|
|
||||||
*/
|
|
||||||
export async function scheduleExitNodeReconnect(
|
|
||||||
exitNodeId: number,
|
|
||||||
reachableAt: string
|
|
||||||
): Promise<void> {
|
|
||||||
logger.info(
|
|
||||||
`Scheduling newt reconnect for exit node ${exitNodeId} (reachableAt: ${reachableAt})`
|
|
||||||
);
|
|
||||||
|
|
||||||
const entry: PendingReconnect = {
|
|
||||||
startTime: Date.now(),
|
|
||||||
reachableAt
|
|
||||||
};
|
|
||||||
|
|
||||||
pendingReconnects.set(exitNodeId, entry);
|
|
||||||
|
|
||||||
// Store in Redis if available for cross-node coordination
|
|
||||||
if (redisManager.isRedisEnabled()) {
|
|
||||||
await redisManager.sadd(REDIS_PENDING_SET, exitNodeId.toString());
|
|
||||||
await redisManager.hset(
|
|
||||||
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
|
||||||
"startTime",
|
|
||||||
entry.startTime.toString()
|
|
||||||
);
|
|
||||||
await redisManager.hset(
|
|
||||||
`${REDIS_HASH_PREFIX}${exitNodeId}`,
|
|
||||||
"reachableAt",
|
|
||||||
reachableAt
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Starts the background interval that checks pending exit node reconnects.
|
|
||||||
*/
|
|
||||||
export function startExitNodeReconnectScheduler(): void {
|
|
||||||
if (schedulerInterval) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
schedulerInterval = setInterval(async () => {
|
|
||||||
try {
|
|
||||||
await processPendingReconnects();
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error in exit node reconnect scheduler", { error });
|
|
||||||
}
|
|
||||||
}, CHECK_INTERVAL_MS);
|
|
||||||
|
|
||||||
logger.debug("Started exit node reconnect scheduler");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function processPendingReconnects(): Promise<void> {
|
|
||||||
// Merge in-memory and Redis-tracked pending reconnects
|
|
||||||
const toProcess = new Map(pendingReconnects);
|
|
||||||
|
|
||||||
if (redisManager.isRedisEnabled()) {
|
|
||||||
const redisIds = await redisManager.smembers(REDIS_PENDING_SET);
|
|
||||||
for (const idStr of redisIds) {
|
|
||||||
const id = parseInt(idStr, 10);
|
|
||||||
if (!toProcess.has(id)) {
|
|
||||||
const startTimeStr = await redisManager.hget(
|
|
||||||
`${REDIS_HASH_PREFIX}${id}`,
|
|
||||||
"startTime"
|
|
||||||
);
|
|
||||||
const reachableAt = await redisManager.hget(
|
|
||||||
`${REDIS_HASH_PREFIX}${id}`,
|
|
||||||
"reachableAt"
|
|
||||||
);
|
|
||||||
if (startTimeStr && reachableAt) {
|
|
||||||
toProcess.set(id, {
|
|
||||||
startTime: parseInt(startTimeStr, 10),
|
|
||||||
reachableAt
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
|
|
||||||
for (const [exitNodeId, entry] of toProcess) {
|
|
||||||
const elapsed = now - entry.startTime;
|
|
||||||
|
|
||||||
// Give up after max duration
|
|
||||||
if (elapsed >= MAX_DURATION_MS) {
|
|
||||||
logger.warn(
|
|
||||||
`Exit node reconnect check timed out for exit node ${exitNodeId} after 5 minutes`
|
|
||||||
);
|
|
||||||
await removePending(exitNodeId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Respect initial delay
|
|
||||||
if (elapsed < INITIAL_DELAY_MS) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the exit node HTTP endpoint is reachable
|
|
||||||
const pingUrl = `${entry.reachableAt}/ping`;
|
|
||||||
try {
|
|
||||||
await axios.get(pingUrl, { timeout: 5000 });
|
|
||||||
} catch {
|
|
||||||
logger.debug(
|
|
||||||
`Exit node ${exitNodeId} not yet reachable at ${pingUrl}`
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Node is reachable — send reconnect to all connected newts
|
|
||||||
logger.info(
|
|
||||||
`Exit node ${exitNodeId} is reachable. Sending newt/wg/reconnect to connected newts.`
|
|
||||||
);
|
|
||||||
|
|
||||||
await sendReconnectToNewts(exitNodeId);
|
|
||||||
await removePending(exitNodeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendReconnectToNewts(exitNodeId: number): Promise<void> {
|
|
||||||
try {
|
|
||||||
const connectedNewts = await db
|
|
||||||
.select({ newtId: newts.newtId })
|
|
||||||
.from(newts)
|
|
||||||
.innerJoin(sites, eq(newts.siteId, sites.siteId))
|
|
||||||
.where(eq(sites.exitNodeId, exitNodeId));
|
|
||||||
|
|
||||||
if (connectedNewts.length === 0) {
|
|
||||||
logger.debug(
|
|
||||||
`No newts found for exit node ${exitNodeId}, nothing to reconnect`
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Sending newt/wg/reconnect to ${connectedNewts.length} newt(s) for exit node ${exitNodeId}`
|
|
||||||
);
|
|
||||||
|
|
||||||
const reconnectMessage = {
|
|
||||||
type: "newt/wg/reconnect",
|
|
||||||
data: {}
|
|
||||||
};
|
|
||||||
|
|
||||||
await Promise.allSettled(
|
|
||||||
connectedNewts.map(({ newtId }) =>
|
|
||||||
sendToClient(newtId, reconnectMessage)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(
|
|
||||||
`Failed to send reconnect messages for exit node ${exitNodeId}`,
|
|
||||||
{ error }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removePending(exitNodeId: number): Promise<void> {
|
|
||||||
pendingReconnects.delete(exitNodeId);
|
|
||||||
|
|
||||||
if (redisManager.isRedisEnabled()) {
|
|
||||||
await redisManager.srem(REDIS_PENDING_SET, exitNodeId.toString());
|
|
||||||
await redisManager.del(`${REDIS_HASH_PREFIX}${exitNodeId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ import { MessageHandler } from "@server/routers/ws";
|
|||||||
import { RemoteExitNode } from "@server/db";
|
import { RemoteExitNode } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { scheduleExitNodeReconnect } from "./exitNodeReconnectScheduler";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles ping messages from clients and responds with pong
|
* Handles ping messages from clients and responds with pong
|
||||||
@@ -38,13 +37,6 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch the current state before updating so we can detect the offline→online transition
|
|
||||||
const [currentExitNode] = await db
|
|
||||||
.select({ online: exitNodes.online, reachableAt: exitNodes.reachableAt })
|
|
||||||
.from(exitNodes)
|
|
||||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
// Update the exit node's last ping timestamp
|
// Update the exit node's last ping timestamp
|
||||||
await db
|
await db
|
||||||
.update(exitNodes)
|
.update(exitNodes)
|
||||||
@@ -53,16 +45,6 @@ export const handleRemoteExitNodePingMessage: MessageHandler = async (
|
|||||||
online: true
|
online: true
|
||||||
})
|
})
|
||||||
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
.where(eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId));
|
||||||
|
|
||||||
// If the exit node was offline and is now coming online, schedule newt reconnects
|
|
||||||
if (currentExitNode && !currentExitNode.online && currentExitNode.reachableAt) {
|
|
||||||
scheduleExitNodeReconnect(
|
|
||||||
remoteExitNode.exitNodeId,
|
|
||||||
currentExitNode.reachableAt
|
|
||||||
).catch((error) => {
|
|
||||||
logger.error("Failed to schedule exit node reconnect", { error });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error handling ping message", { error });
|
logger.error("Error handling ping message", { error });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,3 @@ export * from "./listRemoteExitNodes";
|
|||||||
export * from "./pickRemoteExitNodeDefaults";
|
export * from "./pickRemoteExitNodeDefaults";
|
||||||
export * from "./quickStartRemoteExitNode";
|
export * from "./quickStartRemoteExitNode";
|
||||||
export * from "./offlineChecker";
|
export * from "./offlineChecker";
|
||||||
export * from "./exitNodeReconnectScheduler";
|
|
||||||
|
|||||||
@@ -14,8 +14,7 @@
|
|||||||
import {
|
import {
|
||||||
handleRemoteExitNodeRegisterMessage,
|
handleRemoteExitNodeRegisterMessage,
|
||||||
handleRemoteExitNodePingMessage,
|
handleRemoteExitNodePingMessage,
|
||||||
startRemoteExitNodeOfflineChecker,
|
startRemoteExitNodeOfflineChecker
|
||||||
startExitNodeReconnectScheduler
|
|
||||||
} from "#private/routers/remoteExitNode";
|
} from "#private/routers/remoteExitNode";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
@@ -30,5 +29,4 @@ export const messageHandlers: Record<string, MessageHandler> = {
|
|||||||
|
|
||||||
if (build != "saas") {
|
if (build != "saas") {
|
||||||
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
|
startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes
|
||||||
startExitNodeReconnectScheduler(); // check pending exit node reconnects and notify newts
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { logsDb, requestAuditLog, driver, primaryLogsDb } from "@server/db";
|
import { logsDb, requestAuditLog, driver } from "@server/db";
|
||||||
import { registry } from "@server/openApi";
|
import { registry } from "@server/openApi";
|
||||||
import { NextFunction } from "express";
|
import { NextFunction } from "express";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
@@ -74,12 +74,12 @@ async function query(query: Q) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const [all] = await primaryLogsDb
|
const [all] = await logsDb
|
||||||
.select({ total: count() })
|
.select({ total: count() })
|
||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(baseConditions);
|
.where(baseConditions);
|
||||||
|
|
||||||
const [blocked] = await primaryLogsDb
|
const [blocked] = await logsDb
|
||||||
.select({ total: count() })
|
.select({ total: count() })
|
||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(and(baseConditions, eq(requestAuditLog.action, false)));
|
.where(and(baseConditions, eq(requestAuditLog.action, false)));
|
||||||
@@ -90,7 +90,7 @@ async function query(query: Q) {
|
|||||||
|
|
||||||
const DISTINCT_LIMIT = 500;
|
const DISTINCT_LIMIT = 500;
|
||||||
|
|
||||||
const requestsPerCountry = await primaryLogsDb
|
const requestsPerCountry = await logsDb
|
||||||
.selectDistinct({
|
.selectDistinct({
|
||||||
code: requestAuditLog.location,
|
code: requestAuditLog.location,
|
||||||
count: totalQ
|
count: totalQ
|
||||||
@@ -118,7 +118,7 @@ async function query(query: Q) {
|
|||||||
const booleanTrue = driver === "pg" ? sql`true` : sql`1`;
|
const booleanTrue = driver === "pg" ? sql`true` : sql`1`;
|
||||||
const booleanFalse = driver === "pg" ? sql`false` : sql`0`;
|
const booleanFalse = driver === "pg" ? sql`false` : sql`0`;
|
||||||
|
|
||||||
const requestsPerDay = await primaryLogsDb
|
const requestsPerDay = await logsDb
|
||||||
.select({
|
.select({
|
||||||
day: groupByDayFunction.as("day"),
|
day: groupByDayFunction.as("day"),
|
||||||
allowedCount:
|
allowedCount:
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { logsDb, primaryLogsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db";
|
import { logsDb, requestAuditLog, resources, siteResources, db, primaryDb } from "@server/db";
|
||||||
import { registry } from "@server/openApi";
|
import { registry } from "@server/openApi";
|
||||||
import { NextFunction } from "express";
|
import { NextFunction } from "express";
|
||||||
import { Request, Response } from "express";
|
import { Request, Response } from "express";
|
||||||
@@ -86,6 +86,20 @@ export const queryRequestAuditLogsCombined = queryAccessAuditLogsQuery.merge(
|
|||||||
);
|
);
|
||||||
type Q = z.infer<typeof queryRequestAuditLogsCombined>;
|
type Q = z.infer<typeof queryRequestAuditLogsCombined>;
|
||||||
|
|
||||||
|
function sortNamedFilterOptions<T extends { id: number; name: string | null }>(
|
||||||
|
items: T[]
|
||||||
|
): T[] {
|
||||||
|
return [...items].sort((a, b) => {
|
||||||
|
const nameA = a.name ?? "";
|
||||||
|
const nameB = b.name ?? "";
|
||||||
|
|
||||||
|
if (nameA < nameB) return -1;
|
||||||
|
if (nameA > nameB) return 1;
|
||||||
|
|
||||||
|
return a.id - b.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getWhere(data: Q) {
|
function getWhere(data: Q) {
|
||||||
return and(
|
return and(
|
||||||
gt(requestAuditLog.timestamp, data.timeStart),
|
gt(requestAuditLog.timestamp, data.timeStart),
|
||||||
@@ -110,7 +124,7 @@ function getWhere(data: Q) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function queryRequest(data: Q) {
|
export function queryRequest(data: Q) {
|
||||||
return primaryLogsDb
|
return logsDb
|
||||||
.select({
|
.select({
|
||||||
id: requestAuditLog.id,
|
id: requestAuditLog.id,
|
||||||
timestamp: requestAuditLog.timestamp,
|
timestamp: requestAuditLog.timestamp,
|
||||||
@@ -211,7 +225,7 @@ async function enrichWithResourceDetails(logs: Awaited<ReturnType<typeof queryRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function countRequestQuery(data: Q) {
|
export function countRequestQuery(data: Q) {
|
||||||
const countQuery = primaryLogsDb
|
const countQuery = logsDb
|
||||||
.select({ count: count() })
|
.select({ count: count() })
|
||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(getWhere(data));
|
.where(getWhere(data));
|
||||||
@@ -254,34 +268,34 @@ async function queryUniqueFilterAttributes(
|
|||||||
uniqueResources,
|
uniqueResources,
|
||||||
uniqueSiteResources
|
uniqueSiteResources
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
primaryLogsDb
|
logsDb
|
||||||
.selectDistinct({ actor: requestAuditLog.actor })
|
.selectDistinct({ actor: requestAuditLog.actor })
|
||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(baseConditions)
|
.where(baseConditions)
|
||||||
.limit(DISTINCT_LIMIT + 1),
|
.limit(DISTINCT_LIMIT + 1),
|
||||||
primaryLogsDb
|
logsDb
|
||||||
.selectDistinct({ locations: requestAuditLog.location })
|
.selectDistinct({ locations: requestAuditLog.location })
|
||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(baseConditions)
|
.where(baseConditions)
|
||||||
.limit(DISTINCT_LIMIT + 1),
|
.limit(DISTINCT_LIMIT + 1),
|
||||||
primaryLogsDb
|
logsDb
|
||||||
.selectDistinct({ hosts: requestAuditLog.host })
|
.selectDistinct({ hosts: requestAuditLog.host })
|
||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(baseConditions)
|
.where(baseConditions)
|
||||||
.limit(DISTINCT_LIMIT + 1),
|
.limit(DISTINCT_LIMIT + 1),
|
||||||
primaryLogsDb
|
logsDb
|
||||||
.selectDistinct({ paths: requestAuditLog.path })
|
.selectDistinct({ paths: requestAuditLog.path })
|
||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(baseConditions)
|
.where(baseConditions)
|
||||||
.limit(DISTINCT_LIMIT + 1),
|
.limit(DISTINCT_LIMIT + 1),
|
||||||
primaryLogsDb
|
logsDb
|
||||||
.selectDistinct({
|
.selectDistinct({
|
||||||
id: requestAuditLog.resourceId
|
id: requestAuditLog.resourceId
|
||||||
})
|
})
|
||||||
.from(requestAuditLog)
|
.from(requestAuditLog)
|
||||||
.where(baseConditions)
|
.where(baseConditions)
|
||||||
.limit(DISTINCT_LIMIT + 1),
|
.limit(DISTINCT_LIMIT + 1),
|
||||||
primaryLogsDb
|
logsDb
|
||||||
.selectDistinct({
|
.selectDistinct({
|
||||||
id: requestAuditLog.siteResourceId
|
id: requestAuditLog.siteResourceId
|
||||||
})
|
})
|
||||||
@@ -353,7 +367,7 @@ async function queryUniqueFilterAttributes(
|
|||||||
actors: uniqueActors
|
actors: uniqueActors
|
||||||
.map((row) => row.actor)
|
.map((row) => row.actor)
|
||||||
.filter((actor): actor is string => actor !== null),
|
.filter((actor): actor is string => actor !== null),
|
||||||
resources: resourcesWithNames,
|
resources: sortNamedFilterOptions(resourcesWithNames),
|
||||||
locations: uniqueLocations
|
locations: uniqueLocations
|
||||||
.map((row) => row.locations)
|
.map((row) => row.locations)
|
||||||
.filter((location): location is string => location !== null),
|
.filter((location): location is string => location !== null),
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { UserType } from "@server/types/UserTypes";
|
|||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
import { unauthorized } from "@server/auth/unauthorizedResponse";
|
||||||
import { verifyTotpCode } from "@server/auth/totp";
|
import { verifyTotpCode } from "@server/auth/totp";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
// The RP ID is the domain name of your application
|
// The RP ID is the domain name of your application
|
||||||
const rpID = (() => {
|
const rpID = (() => {
|
||||||
@@ -406,7 +407,12 @@ export async function deleteSecurityKey(
|
|||||||
res: Response,
|
res: Response,
|
||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
const { credentialId: encodedCredentialId } = req.params;
|
const encodedCredentialId = getFirstString(req.params.credentialId);
|
||||||
|
if (!encodedCredentialId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid credential ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
const credentialId = decodeURIComponent(encodedCredentialId);
|
const credentialId = decodeURIComponent(encodedCredentialId);
|
||||||
const user = req.user as User;
|
const user = req.user as User;
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ export * from "./listUserDevices";
|
|||||||
export * from "./updateClient";
|
export * from "./updateClient";
|
||||||
export * from "./getClient";
|
export * from "./getClient";
|
||||||
export * from "./createUserClient";
|
export * from "./createUserClient";
|
||||||
|
export * from "./verifyClientAssociationsCache";
|
||||||
|
export * from "./rebuildClientAssociationsCacheRoute";
|
||||||
|
|||||||
@@ -1,20 +1,15 @@
|
|||||||
import {
|
import {
|
||||||
clientLabels,
|
|
||||||
clients,
|
clients,
|
||||||
clientSitesAssociationsCache,
|
clientSitesAssociationsCache,
|
||||||
currentFingerprint,
|
currentFingerprint,
|
||||||
db,
|
db,
|
||||||
labels,
|
|
||||||
olms,
|
olms,
|
||||||
orgs,
|
orgs,
|
||||||
roleClients,
|
roleClients,
|
||||||
sites,
|
sites,
|
||||||
userClients,
|
userClients,
|
||||||
users,
|
users
|
||||||
type Label
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -174,7 +169,6 @@ type ClientWithSites = Awaited<ReturnType<typeof queryClientsBase>>[0] & {
|
|||||||
siteNiceId: string | null;
|
siteNiceId: string | null;
|
||||||
}>;
|
}>;
|
||||||
olmUpdateAvailable?: boolean;
|
olmUpdateAvailable?: boolean;
|
||||||
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type OlmWithUpdateAvailable = ClientWithSites;
|
type OlmWithUpdateAvailable = ClientWithSites;
|
||||||
@@ -261,11 +255,6 @@ export async function listClients(
|
|||||||
(client) => client.clientId
|
(client) => client.clientId
|
||||||
);
|
);
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get client count with filter
|
// Get client count with filter
|
||||||
const conditions = [
|
const conditions = [
|
||||||
and(
|
and(
|
||||||
@@ -299,29 +288,18 @@ export async function listClients(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
conditions.push(
|
||||||
const queryList = [
|
or(
|
||||||
like(sql`LOWER(${clients.name})`, q),
|
like(
|
||||||
like(sql`LOWER(${clients.niceId})`, q)
|
sql`LOWER(${clients.name})`,
|
||||||
];
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
if (isLabelFeatureEnabled) {
|
like(
|
||||||
queryList.push(
|
sql`LOWER(${clients.niceId})`,
|
||||||
inArray(
|
"%" + query.toLowerCase() + "%"
|
||||||
clients.clientId,
|
|
||||||
db
|
|
||||||
.select({ id: clientLabels.clientId })
|
|
||||||
.from(clientLabels)
|
|
||||||
.innerJoin(
|
|
||||||
labels,
|
|
||||||
eq(labels.labelId, clientLabels.labelId)
|
|
||||||
)
|
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
|
||||||
)
|
)
|
||||||
);
|
)
|
||||||
}
|
);
|
||||||
|
|
||||||
conditions.push(or(...queryList));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseQuery = queryClientsBase().where(and(...conditions));
|
const baseQuery = queryClientsBase().where(and(...conditions));
|
||||||
@@ -348,30 +326,6 @@ export async function listClients(
|
|||||||
const clientIds = clientsList.map((client) => client.clientId);
|
const clientIds = clientsList.map((client) => client.clientId);
|
||||||
const siteAssociations = await getSiteAssociations(clientIds);
|
const siteAssociations = await getSiteAssociations(clientIds);
|
||||||
|
|
||||||
let labelsForClients: Array<{
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
clientId: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && clientIds.length > 0) {
|
|
||||||
labelsForClients = await db
|
|
||||||
.select({
|
|
||||||
labelId: labels.labelId,
|
|
||||||
name: labels.name,
|
|
||||||
color: labels.color,
|
|
||||||
clientId: clientLabels.clientId
|
|
||||||
})
|
|
||||||
.from(labels)
|
|
||||||
.innerJoin(
|
|
||||||
clientLabels,
|
|
||||||
eq(clientLabels.labelId, labels.labelId)
|
|
||||||
)
|
|
||||||
.where(inArray(clientLabels.clientId, clientIds))
|
|
||||||
.orderBy(asc(clientLabels.clientLabelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group site associations by client ID
|
// Group site associations by client ID
|
||||||
const sitesByClient = siteAssociations.reduce(
|
const sitesByClient = siteAssociations.reduce(
|
||||||
(acc, association) => {
|
(acc, association) => {
|
||||||
@@ -399,10 +353,7 @@ export async function listClients(
|
|||||||
const clientsWithSites = clientsList.map((client) => {
|
const clientsWithSites = clientsList.map((client) => {
|
||||||
return {
|
return {
|
||||||
...client,
|
...client,
|
||||||
sites: sitesByClient[client.clientId] || [],
|
sites: sitesByClient[client.clientId] || []
|
||||||
labels: labelsForClients.filter(
|
|
||||||
(l) => l.clientId === client.clientId
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
81
server/routers/client/rebuildClientAssociationsCacheRoute.ts
Normal file
81
server/routers/client/rebuildClientAssociationsCacheRoute.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { clients } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
clientId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "post",
|
||||||
|
path: "/client/{clientId}/rebuild-associations-cache",
|
||||||
|
description:
|
||||||
|
"Rebuild the client's site/site-resource association cache based on current permissions.",
|
||||||
|
tags: [OpenAPITags.Client],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function rebuildClientAssociationsCacheRoute(
|
||||||
|
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 { clientId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.clientId, clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Client with ID ${clientId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await rebuildClientAssociationsFromClient(client);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: null,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: "Client association cache rebuilt successfully",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to rebuild client association cache"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
83
server/routers/client/verifyClientAssociationsCache.ts
Normal file
83
server/routers/client/verifyClientAssociationsCache.ts
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { Request, Response, NextFunction } from "express";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { db } from "@server/db";
|
||||||
|
import { clients } from "@server/db";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import response from "@server/lib/response";
|
||||||
|
import HttpCode from "@server/types/HttpCode";
|
||||||
|
import createHttpError from "http-errors";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { fromError } from "zod-validation-error";
|
||||||
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
|
import { verifyClientAssociationsCache as verifyClientAssociationsCacheLib } from "@server/lib/rebuildClientAssociations";
|
||||||
|
|
||||||
|
const paramsSchema = z.strictObject({
|
||||||
|
clientId: z.string().transform(Number).pipe(z.int().positive())
|
||||||
|
});
|
||||||
|
|
||||||
|
registry.registerPath({
|
||||||
|
method: "get",
|
||||||
|
path: "/client/{clientId}/verify-associations-cache",
|
||||||
|
description:
|
||||||
|
"Read-only check of whether the client's site/site-resource association cache matches what the current permissions imply.",
|
||||||
|
tags: [OpenAPITags.Client],
|
||||||
|
request: {
|
||||||
|
params: paramsSchema
|
||||||
|
},
|
||||||
|
responses: {}
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function verifyClientAssociationsCache(
|
||||||
|
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 { clientId } = parsedParams.data;
|
||||||
|
|
||||||
|
const [client] = await db
|
||||||
|
.select()
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.clientId, clientId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.NOT_FOUND,
|
||||||
|
`Client with ID ${clientId} not found`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = await verifyClientAssociationsCacheLib(client);
|
||||||
|
|
||||||
|
return response(res, {
|
||||||
|
data: report,
|
||||||
|
success: true,
|
||||||
|
error: false,
|
||||||
|
message: report.consistent
|
||||||
|
? "Client association cache is consistent"
|
||||||
|
: "Client association cache is INCONSISTENT",
|
||||||
|
status: HttpCode.OK
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return next(
|
||||||
|
createHttpError(
|
||||||
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
|
"Failed to verify client association cache"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,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 { domain } from "zod/v4/core/regexes";
|
|
||||||
|
|
||||||
const getDomainSchema = z.strictObject({
|
const getDomainSchema = z.strictObject({
|
||||||
domainId: z.string().optional(),
|
domainId: z.string().optional(),
|
||||||
|
|||||||
@@ -1156,7 +1156,7 @@ export const authRouter = Router();
|
|||||||
unauthenticated.use("/auth", authRouter);
|
unauthenticated.use("/auth", authRouter);
|
||||||
authRouter.use(
|
authRouter.use(
|
||||||
rateLimit({
|
rateLimit({
|
||||||
windowMs: config.getRawConfig().rate_limits.auth.window_minutes,
|
windowMs: config.getRawConfig().rate_limits.auth.window_minutes * 60 * 1000,
|
||||||
max: config.getRawConfig().rate_limits.auth.max_requests,
|
max: config.getRawConfig().rate_limits.auth.max_requests,
|
||||||
keyGenerator: (req) =>
|
keyGenerator: (req) =>
|
||||||
`authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`,
|
`authRouterGlobal:${ipKeyGenerator(req.ip || "")}:${req.path}`,
|
||||||
@@ -1252,7 +1252,7 @@ authRouter.post(
|
|||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 900,
|
max: 900,
|
||||||
keyGenerator: (req) =>
|
keyGenerator: (req) =>
|
||||||
`olmGetToken:${req.body.newtId || ipKeyGenerator(req.ip || "")}`,
|
`olmGetToken:${req.body.olmId || ipKeyGenerator(req.ip || "")}`,
|
||||||
handler: (req, res, next) => {
|
handler: (req, res, next) => {
|
||||||
const message = `You can only request an Olm token ${900} times every ${15} minutes. Please try again later.`;
|
const message = `You can only request an Olm token ${900} times every ${15} minutes. Please try again later.`;
|
||||||
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message));
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import type { Label } from "@server/db";
|
|
||||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
|
||||||
|
|
||||||
export type ListOrgLabelsResponse = PaginatedResponse<{
|
|
||||||
labels: Omit<Label, "orgId">[];
|
|
||||||
}>;
|
|
||||||
|
|
||||||
export type CreateOrEditLabelResponse = {
|
|
||||||
label: Label;
|
|
||||||
};
|
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { response } from "@server/lib/response";
|
import { response } from "@server/lib/response";
|
||||||
|
import { getFirstString } from "@server/lib/requestParams";
|
||||||
|
|
||||||
export async function getUserResources(
|
export async function getUserResources(
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -26,7 +27,7 @@ export async function getUserResources(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const { orgId } = req.params;
|
const orgId = getFirstString(req.params.orgId);
|
||||||
const userId = req.user?.userId;
|
const userId = req.user?.userId;
|
||||||
|
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
@@ -35,6 +36,12 @@ export async function getUserResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!orgId) {
|
||||||
|
return next(
|
||||||
|
createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check user is in organization and get their role IDs
|
// Check user is in organization and get their role IDs
|
||||||
const [userOrg] = await db
|
const [userOrg] = await db
|
||||||
.select()
|
.select()
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
db,
|
db,
|
||||||
labels,
|
|
||||||
resourceHeaderAuth,
|
resourceHeaderAuth,
|
||||||
resourceHeaderAuthExtendedCompatibility,
|
resourceHeaderAuthExtendedCompatibility,
|
||||||
resourceLabels,
|
|
||||||
resourcePassword,
|
resourcePassword,
|
||||||
resourcePincode,
|
resourcePincode,
|
||||||
resources,
|
resources,
|
||||||
@@ -11,11 +9,8 @@ import {
|
|||||||
sites,
|
sites,
|
||||||
targetHealthCheck,
|
targetHealthCheck,
|
||||||
targets,
|
targets,
|
||||||
userResources,
|
userResources
|
||||||
type Label
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -159,7 +154,6 @@ export type ResourceWithTargets = {
|
|||||||
siteNiceId: string;
|
siteNiceId: string;
|
||||||
online?: boolean; // undefined for local sites
|
online?: boolean; // undefined for local sites
|
||||||
}>;
|
}>;
|
||||||
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function queryResourcesBase() {
|
function queryResourcesBase() {
|
||||||
@@ -294,11 +288,6 @@ export async function listResources(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
let accessibleResources: Array<{ resourceId: number }>;
|
let accessibleResources: Array<{ resourceId: number }>;
|
||||||
if (req.user) {
|
if (req.user) {
|
||||||
accessibleResources = await db
|
accessibleResources = await db
|
||||||
@@ -336,6 +325,24 @@ export async function listResources(
|
|||||||
)
|
)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(
|
||||||
|
sql`LOWER(${resources.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${resources.niceId})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${resources.fullDomain})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
if (typeof enabled !== "undefined") {
|
if (typeof enabled !== "undefined") {
|
||||||
conditions.push(eq(resources.enabled, enabled));
|
conditions.push(eq(resources.enabled, enabled));
|
||||||
}
|
}
|
||||||
@@ -379,32 +386,6 @@ export async function listResources(
|
|||||||
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
.where(and(eq(sites.orgId, orgId), eq(sites.siteId, siteId)));
|
||||||
conditions.push(inArray(resources.resourceId, resourcesWithSite));
|
conditions.push(inArray(resources.resourceId, resourcesWithSite));
|
||||||
}
|
}
|
||||||
if (query) {
|
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
|
||||||
const queryList = [
|
|
||||||
like(sql`LOWER(${resources.name})`, q),
|
|
||||||
like(sql`LOWER(${resources.niceId})`, q),
|
|
||||||
like(sql`LOWER(${resources.fullDomain})`, q)
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
queryList.push(
|
|
||||||
inArray(
|
|
||||||
resources.resourceId,
|
|
||||||
db
|
|
||||||
.select({ id: resourceLabels.resourceId })
|
|
||||||
.from(resourceLabels)
|
|
||||||
.innerJoin(
|
|
||||||
labels,
|
|
||||||
eq(labels.labelId, resourceLabels.labelId)
|
|
||||||
)
|
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
conditions.push(or(...queryList));
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseQuery = queryResourcesBase().where(and(...conditions));
|
const baseQuery = queryResourcesBase().where(and(...conditions));
|
||||||
|
|
||||||
@@ -426,36 +407,6 @@ export async function listResources(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const resourceIdList = rows.map((row) => row.resourceId);
|
const resourceIdList = rows.map((row) => row.resourceId);
|
||||||
|
|
||||||
let labelsForResources: Array<{
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
resourceId: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
labelsForResources =
|
|
||||||
resourceIdList.length === 0
|
|
||||||
? []
|
|
||||||
: await db
|
|
||||||
.select({
|
|
||||||
labelId: labels.labelId,
|
|
||||||
name: labels.name,
|
|
||||||
color: labels.color,
|
|
||||||
resourceId: resourceLabels.resourceId
|
|
||||||
})
|
|
||||||
.from(labels)
|
|
||||||
.innerJoin(
|
|
||||||
resourceLabels,
|
|
||||||
eq(resourceLabels.labelId, labels.labelId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
inArray(resourceLabels.resourceId, resourceIdList)
|
|
||||||
)
|
|
||||||
.orderBy(asc(resourceLabels.resourceLabelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const allResourceTargets =
|
const allResourceTargets =
|
||||||
resourceIdList.length === 0
|
resourceIdList.length === 0
|
||||||
? []
|
? []
|
||||||
@@ -507,10 +458,7 @@ export async function listResources(
|
|||||||
headerAuthId: row.headerAuthId,
|
headerAuthId: row.headerAuthId,
|
||||||
health: row.health ?? null,
|
health: row.health ?? null,
|
||||||
targets: [],
|
targets: [],
|
||||||
sites: [],
|
sites: []
|
||||||
labels: labelsForResources.filter(
|
|
||||||
(l) => l.resourceId === row.resourceId
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
map.set(row.resourceId, entry);
|
map.set(row.resourceId, entry);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
import { Request, Response, NextFunction } from "express";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db, Site, siteNetworks, siteResources } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { newts, newtSessions, sites } from "@server/db";
|
import { newts, sites } from "@server/db";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq } 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";
|
||||||
@@ -11,7 +11,7 @@ import { deletePeer } from "../gerbil/peers";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { sendToClient } from "#dynamic/routers/ws";
|
import { sendToClient } from "#dynamic/routers/ws";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
|
import { cleanupSiteAssociations } from "@server/lib/rebuildClientAssociations";
|
||||||
import { usageService } from "@server/lib/billing/usageService";
|
import { usageService } from "@server/lib/billing/usageService";
|
||||||
import { FeatureId } from "@server/lib/billing";
|
import { FeatureId } from "@server/lib/billing";
|
||||||
|
|
||||||
@@ -63,7 +63,11 @@ export async function deleteSite(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let deletedNewtId: string | null = null;
|
const [deletedNewt] = await db
|
||||||
|
.select()
|
||||||
|
.from(newts)
|
||||||
|
.where(eq(newts.siteId, siteId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
if (site.type == "wireguard") {
|
if (site.type == "wireguard") {
|
||||||
@@ -71,56 +75,24 @@ export async function deleteSite(
|
|||||||
await deletePeer(site.exitNodeId!, site.pubKey);
|
await deletePeer(site.exitNodeId!, site.pubKey);
|
||||||
}
|
}
|
||||||
} else if (site.type == "newt") {
|
} else if (site.type == "newt") {
|
||||||
const networks = await trx
|
// Clean up all client associations and send peer/proxy removal
|
||||||
.select({ networkId: siteNetworks.networkId })
|
// messages in a single efficient pass before deleting the row.
|
||||||
.from(siteNetworks)
|
await cleanupSiteAssociations(site, trx);
|
||||||
.where(eq(siteNetworks.siteId, siteId));
|
|
||||||
|
|
||||||
// loop through them
|
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
||||||
const updatedSiteResources = await trx
|
|
||||||
.select()
|
|
||||||
.from(siteResources)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
siteResources.networkId,
|
|
||||||
networks.map((n) => n.networkId)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
for (const siteResource of updatedSiteResources) {
|
|
||||||
await rebuildClientAssociationsFromSiteResource(
|
|
||||||
siteResource,
|
|
||||||
trx
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// get the newt on the site by querying the newt table for siteId
|
|
||||||
const [deletedNewt] = await trx
|
|
||||||
.delete(newts)
|
|
||||||
.where(eq(newts.siteId, siteId))
|
|
||||||
.returning();
|
|
||||||
if (deletedNewt) {
|
|
||||||
deletedNewtId = deletedNewt.newtId;
|
|
||||||
|
|
||||||
// delete all of the sessions for the newt
|
|
||||||
await trx
|
|
||||||
.delete(newtSessions)
|
|
||||||
.where(eq(newtSessions.newtId, deletedNewt.newtId));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await trx.delete(sites).where(eq(sites.siteId, siteId));
|
|
||||||
|
|
||||||
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
|
await usageService.add(site.orgId, FeatureId.SITES, -1, trx);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send termination message outside of transaction to prevent blocking
|
// Send termination message outside of transaction to prevent blocking
|
||||||
if (deletedNewtId) {
|
if (deletedNewt) {
|
||||||
const payload = {
|
const payload = {
|
||||||
type: `newt/wg/terminate`,
|
type: `newt/wg/terminate`,
|
||||||
data: {}
|
data: {}
|
||||||
};
|
};
|
||||||
// Don't await this to prevent blocking the response
|
// Don't await this to prevent blocking the response
|
||||||
sendToClient(deletedNewtId, payload).catch((error) => {
|
sendToClient(deletedNewt.newtId, payload).catch((error) => {
|
||||||
logger.error(
|
logger.error(
|
||||||
"Failed to send termination message to newt:",
|
"Failed to send termination message to newt:",
|
||||||
error
|
error
|
||||||
|
|||||||
@@ -9,10 +9,7 @@ import {
|
|||||||
siteResources,
|
siteResources,
|
||||||
targets,
|
targets,
|
||||||
sites,
|
sites,
|
||||||
userSites,
|
userSites
|
||||||
labels,
|
|
||||||
siteLabels,
|
|
||||||
type Label
|
|
||||||
} from "@server/db";
|
} from "@server/db";
|
||||||
import cache from "#dynamic/lib/cache";
|
import cache from "#dynamic/lib/cache";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
@@ -26,8 +23,6 @@ import createHttpError from "http-errors";
|
|||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
|
|
||||||
// Stale-while-revalidate: keeps the last successfully fetched version so that
|
// Stale-while-revalidate: keeps the last successfully fetched version so that
|
||||||
// a transient network failure / timeout does not flip every site back to
|
// a transient network failure / timeout does not flip every site back to
|
||||||
@@ -192,7 +187,7 @@ const listSitesSchema = z.object({
|
|||||||
|
|
||||||
function querySitesBase() {
|
function querySitesBase() {
|
||||||
return db
|
return db
|
||||||
.selectDistinct({
|
.select({
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
niceId: sites.niceId,
|
niceId: sites.niceId,
|
||||||
name: sites.name,
|
name: sites.name,
|
||||||
@@ -238,7 +233,6 @@ type SiteRowBase = Awaited<ReturnType<typeof querySitesBase>>[0];
|
|||||||
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
|
type SiteWithUpdateAvailable = Omit<SiteRowBase, "online"> & {
|
||||||
online?: SiteRowBase["online"]; // undefined for local sites
|
online?: SiteRowBase["online"]; // undefined for local sites
|
||||||
newtUpdateAvailable?: boolean;
|
newtUpdateAvailable?: boolean;
|
||||||
labels?: Array<Pick<Label, "color" | "labelId" | "name">>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ListSitesResponse = PaginatedResponse<{
|
export type ListSitesResponse = PaginatedResponse<{
|
||||||
@@ -314,11 +308,6 @@ export async function listSites(
|
|||||||
.where(eq(sites.orgId, orgId));
|
.where(eq(sites.orgId, orgId));
|
||||||
}
|
}
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
const { pageSize, page, query, sort_by, order, online, status } =
|
const { pageSize, page, query, sort_by, order, online, status } =
|
||||||
parsedQuery.data;
|
parsedQuery.data;
|
||||||
|
|
||||||
@@ -330,43 +319,33 @@ export async function listSites(
|
|||||||
eq(sites.orgId, orgId)
|
eq(sites.orgId, orgId)
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(
|
||||||
|
sql`LOWER(${sites.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${sites.niceId})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
if (typeof online !== "undefined") {
|
if (typeof online !== "undefined") {
|
||||||
conditions.push(eq(sites.online, online));
|
conditions.push(eq(sites.online, online));
|
||||||
}
|
}
|
||||||
if (typeof status !== "undefined") {
|
if (typeof status !== "undefined") {
|
||||||
conditions.push(eq(sites.status, status));
|
conditions.push(eq(sites.status, status));
|
||||||
}
|
}
|
||||||
if (query) {
|
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
|
||||||
const queryList = [
|
|
||||||
like(sql`LOWER(${sites.name})`, q),
|
|
||||||
like(sql`LOWER(${sites.niceId})`, q)
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
queryList.push(
|
|
||||||
inArray(
|
|
||||||
sites.siteId,
|
|
||||||
db
|
|
||||||
.select({ id: siteLabels.siteId })
|
|
||||||
.from(siteLabels)
|
|
||||||
.innerJoin(
|
|
||||||
labels,
|
|
||||||
eq(labels.labelId, siteLabels.labelId)
|
|
||||||
)
|
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
conditions.push(or(...queryList));
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseQuery = querySitesBase().where(and(...conditions));
|
const baseQuery = querySitesBase().where(and(...conditions));
|
||||||
|
|
||||||
// we need to add `as` so that drizzle filters the result as a subquery
|
// we need to add `as` so that drizzle filters the result as a subquery
|
||||||
const countQuery = db.$count(
|
const countQuery = db.$count(
|
||||||
querySitesBase().where(and(...conditions)).as("filtered_sites")
|
querySitesBase()
|
||||||
|
.where(and(...conditions))
|
||||||
|
.as("filtered_sites")
|
||||||
);
|
);
|
||||||
|
|
||||||
const siteListQuery = baseQuery
|
const siteListQuery = baseQuery
|
||||||
@@ -388,46 +367,11 @@ export async function listSites(
|
|||||||
// Get latest version asynchronously without blocking the response
|
// Get latest version asynchronously without blocking the response
|
||||||
const latestNewtVersionPromise = getLatestNewtVersion();
|
const latestNewtVersionPromise = getLatestNewtVersion();
|
||||||
|
|
||||||
const siteIds = rows.map((site) => site.siteId);
|
|
||||||
|
|
||||||
let labelsForSites: Array<{
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
siteId: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
labelsForSites =
|
|
||||||
siteIds.length === 0
|
|
||||||
? []
|
|
||||||
: await db
|
|
||||||
.select({
|
|
||||||
labelId: labels.labelId,
|
|
||||||
name: labels.name,
|
|
||||||
color: labels.color,
|
|
||||||
siteId: siteLabels.siteId
|
|
||||||
})
|
|
||||||
.from(labels)
|
|
||||||
.innerJoin(
|
|
||||||
siteLabels,
|
|
||||||
eq(siteLabels.labelId, labels.labelId)
|
|
||||||
)
|
|
||||||
.where(inArray(siteLabels.siteId, siteIds))
|
|
||||||
.orderBy(asc(siteLabels.siteLabelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
|
const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => {
|
||||||
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
const siteWithUpdate: SiteWithUpdateAvailable = { ...site };
|
||||||
// Initially set to false, will be updated if version check succeeds
|
// Initially set to false, will be updated if version check succeeds
|
||||||
siteWithUpdate.newtUpdateAvailable = false;
|
siteWithUpdate.newtUpdateAvailable = false;
|
||||||
|
return siteWithUpdate;
|
||||||
// associate labels
|
|
||||||
const labelsForSite = labelsForSites.filter(
|
|
||||||
(label) => label.siteId === site.siteId
|
|
||||||
);
|
|
||||||
|
|
||||||
return { ...siteWithUpdate, labels: labelsForSite };
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Try to get the latest version, but don't block if it fails
|
// Try to get the latest version, but don't block if it fails
|
||||||
|
|||||||
@@ -15,10 +15,7 @@ import logger from "@server/logger";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { eq, and, inArray } from "drizzle-orm";
|
import { eq, and, inArray } from "drizzle-orm";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
import {
|
import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations";
|
||||||
rebuildClientAssociationsFromClient,
|
|
||||||
rebuildClientAssociationsFromSiteResource
|
|
||||||
} from "@server/lib/rebuildClientAssociations";
|
|
||||||
|
|
||||||
const batchAddClientToSiteResourcesParamsSchema = z
|
const batchAddClientToSiteResourcesParamsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
import {
|
import { db, DB_TYPE, SiteResource, siteNetworks, siteResources, sites } from "@server/db";
|
||||||
db,
|
|
||||||
DB_TYPE,
|
|
||||||
Label,
|
|
||||||
SiteResource,
|
|
||||||
siteNetworks,
|
|
||||||
siteResourceLabels,
|
|
||||||
siteResources,
|
|
||||||
sites,
|
|
||||||
labels
|
|
||||||
} from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -19,8 +9,6 @@ import { NextFunction, Request, Response } from "express";
|
|||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
|
|
||||||
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
|
const listAllSiteResourcesByOrgParamsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -81,11 +69,16 @@ const listAllSiteResourcesByOrgQuerySchema = z.object({
|
|||||||
default: "asc",
|
default: "asc",
|
||||||
description: "Sort order"
|
description: "Sort order"
|
||||||
}),
|
}),
|
||||||
siteId: z.coerce.number<string>().int().positive().optional().openapi({
|
siteId: z.coerce
|
||||||
type: "integer",
|
.number<string>()
|
||||||
description:
|
.int()
|
||||||
"When set, only site resources associated with this site (via network) are returned"
|
.positive()
|
||||||
})
|
.optional()
|
||||||
|
.openapi({
|
||||||
|
type: "integer",
|
||||||
|
description:
|
||||||
|
"When set, only site resources associated with this site (via network) are returned"
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
||||||
@@ -95,7 +88,6 @@ export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{
|
|||||||
siteNames: string[];
|
siteNames: string[];
|
||||||
siteNiceIds: string[];
|
siteNiceIds: string[];
|
||||||
siteAddresses: (string | null)[];
|
siteAddresses: (string | null)[];
|
||||||
labels?: Array<Pick<Label, "labelId" | "name" | "color">>;
|
|
||||||
})[];
|
})[];
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -242,11 +234,6 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
const { page, pageSize, query, mode, sort_by, order, siteId } =
|
const { page, pageSize, query, mode, sort_by, order, siteId } =
|
||||||
parsedQuery.data;
|
parsedQuery.data;
|
||||||
|
|
||||||
const isLabelFeatureEnabled = await isLicensedOrSubscribed(
|
|
||||||
orgId,
|
|
||||||
tierMatrix.labels
|
|
||||||
);
|
|
||||||
|
|
||||||
const conditions = [and(eq(siteResources.orgId, orgId))];
|
const conditions = [and(eq(siteResources.orgId, orgId))];
|
||||||
|
|
||||||
if (siteId != null) {
|
if (siteId != null) {
|
||||||
@@ -271,41 +258,41 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
inArray(siteResources.siteResourceId, resourcesForSite)
|
inArray(siteResources.siteResourceId, resourcesForSite)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (query) {
|
||||||
|
conditions.push(
|
||||||
|
or(
|
||||||
|
like(
|
||||||
|
sql`LOWER(${siteResources.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${siteResources.niceId})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${siteResources.destination})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${siteResources.alias})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${siteResources.aliasAddress})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
),
|
||||||
|
like(
|
||||||
|
sql`LOWER(${sites.name})`,
|
||||||
|
"%" + query.toLowerCase() + "%"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (mode) {
|
if (mode) {
|
||||||
conditions.push(eq(siteResources.mode, mode));
|
conditions.push(eq(siteResources.mode, mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (query) {
|
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
|
||||||
const queryList = [
|
|
||||||
like(sql`LOWER(${siteResources.name})`, q),
|
|
||||||
like(sql`LOWER(${siteResources.niceId})`, q),
|
|
||||||
like(sql`LOWER(${siteResources.destination})`, q),
|
|
||||||
like(sql`LOWER(${siteResources.alias})`, q),
|
|
||||||
like(sql`LOWER(${siteResources.aliasAddress})`, q),
|
|
||||||
like(sql`LOWER(${sites.name})`, q)
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
queryList.push(
|
|
||||||
inArray(
|
|
||||||
siteResources.siteResourceId,
|
|
||||||
db
|
|
||||||
.select({ id: siteResourceLabels.siteResourceId })
|
|
||||||
.from(siteResourceLabels)
|
|
||||||
.innerJoin(
|
|
||||||
labels,
|
|
||||||
eq(labels.labelId, siteResourceLabels.labelId)
|
|
||||||
)
|
|
||||||
.where(like(sql`LOWER(${labels.name})`, q))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
conditions.push(or(...queryList));
|
|
||||||
}
|
|
||||||
|
|
||||||
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
const baseQuery = querySiteResourcesBase().where(and(...conditions));
|
||||||
|
|
||||||
const countQuery = db.$count(
|
const countQuery = db.$count(
|
||||||
@@ -328,51 +315,11 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
countQuery
|
countQuery
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const siteResourcesList = siteResourcesRaw.map(
|
const siteResourcesList = siteResourcesRaw.map(transformSiteResourceRow);
|
||||||
transformSiteResourceRow
|
|
||||||
);
|
|
||||||
|
|
||||||
const siteResourceIdList = siteResourcesList.map(
|
|
||||||
(r) => r.siteResourceId
|
|
||||||
);
|
|
||||||
|
|
||||||
let labelsForSiteResources: Array<{
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
siteResourceId: number;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
if (isLabelFeatureEnabled && siteResourceIdList.length > 0) {
|
|
||||||
labelsForSiteResources = await db
|
|
||||||
.select({
|
|
||||||
labelId: labels.labelId,
|
|
||||||
name: labels.name,
|
|
||||||
color: labels.color,
|
|
||||||
siteResourceId: siteResourceLabels.siteResourceId
|
|
||||||
})
|
|
||||||
.from(labels)
|
|
||||||
.innerJoin(
|
|
||||||
siteResourceLabels,
|
|
||||||
eq(siteResourceLabels.labelId, labels.labelId)
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
inArray(
|
|
||||||
siteResourceLabels.siteResourceId,
|
|
||||||
siteResourceIdList
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.orderBy(asc(siteResourceLabels.siteResourceLabelId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
return response<ListAllSiteResourcesByOrgResponse>(res, {
|
||||||
data: {
|
data: {
|
||||||
siteResources: siteResourcesList.map((r) => ({
|
siteResources: siteResourcesList,
|
||||||
...r,
|
|
||||||
labels: labelsForSiteResources.filter(
|
|
||||||
(l) => l.siteResourceId === r.siteResourceId
|
|
||||||
)
|
|
||||||
})),
|
|
||||||
pagination: {
|
pagination: {
|
||||||
total: totalCount,
|
total: totalCount,
|
||||||
pageSize,
|
pageSize,
|
||||||
@@ -393,4 +340,4 @@ export async function listAllSiteResourcesByOrg(
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import { internal } from "@app/lib/api";
|
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
|
||||||
import { ListOrgLabelsResponse } from "@server/routers/labels/types";
|
|
||||||
import { AxiosResponse } from "axios";
|
|
||||||
import OrgLabelsTable from "@app/components/OrgLabelsTable";
|
|
||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Labels"
|
|
||||||
};
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
params: Promise<{ orgId: string }>;
|
|
||||||
searchParams: Promise<Record<string, string>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default async function LabelsPage({ params, searchParams }: Props) {
|
|
||||||
const { orgId } = await params;
|
|
||||||
|
|
||||||
const searchParamsObj = new URLSearchParams(await searchParams);
|
|
||||||
|
|
||||||
let labels: ListOrgLabelsResponse["labels"] = [];
|
|
||||||
let pagination: ListOrgLabelsResponse["pagination"] = {
|
|
||||||
total: 0,
|
|
||||||
page: 1,
|
|
||||||
pageSize: 20
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await internal.get<AxiosResponse<ListOrgLabelsResponse>>(
|
|
||||||
`/org/${orgId}/labels?${searchParamsObj.toString()}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
);
|
|
||||||
const responseData = res.data.data;
|
|
||||||
labels = responseData.labels;
|
|
||||||
pagination = responseData.pagination;
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
const t = await getTranslations();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SettingsSectionTitle
|
|
||||||
title={t("labels")}
|
|
||||||
description={t("orgLabelsDescription")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OrgLabelsTable
|
|
||||||
labels={labels}
|
|
||||||
orgId={orgId}
|
|
||||||
rowCount={pagination.total}
|
|
||||||
pagination={{
|
|
||||||
pageIndex: pagination.page - 1,
|
|
||||||
pageSize: pagination.pageSize
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -76,8 +76,7 @@ export default async function ClientsPage(props: ClientsPageProps) {
|
|||||||
agent: client.agent,
|
agent: client.agent,
|
||||||
archived: client.archived || false,
|
archived: client.archived || false,
|
||||||
blocked: client.blocked || false,
|
blocked: client.blocked || false,
|
||||||
approvalState: client.approvalState ?? "approved",
|
approvalState: client.approvalState ?? "approved"
|
||||||
labels: client.labels ?? []
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -153,6 +153,65 @@ export default function GeneralPage() {
|
|||||||
const [approvalId, setApprovalId] = useState<number | null>(null);
|
const [approvalId, setApprovalId] = useState<number | null>(null);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [, startTransition] = useTransition();
|
const [, startTransition] = useTransition();
|
||||||
|
const [cacheCheck, setCacheCheck] = useState<null | {
|
||||||
|
consistent: boolean;
|
||||||
|
missingSiteResourceIds: number[];
|
||||||
|
extraSiteResourceIds: number[];
|
||||||
|
missingSiteIds: number[];
|
||||||
|
extraSiteIds: number[];
|
||||||
|
expectedSiteResourceIds: number[];
|
||||||
|
actualSiteResourceIds: number[];
|
||||||
|
expectedSiteIds: number[];
|
||||||
|
actualSiteIds: number[];
|
||||||
|
}>(null);
|
||||||
|
const [isCheckingCache, setIsCheckingCache] = useState(false);
|
||||||
|
const [isRebuildingCache, setIsRebuildingCache] = useState(false);
|
||||||
|
|
||||||
|
const handleRebuildCache = async () => {
|
||||||
|
if (!client.clientId) return;
|
||||||
|
setIsRebuildingCache(true);
|
||||||
|
try {
|
||||||
|
await api.post(
|
||||||
|
`/client/${client.clientId}/rebuild-associations-cache`
|
||||||
|
);
|
||||||
|
// Re-verify after rebuild so the result refreshes
|
||||||
|
const res = await api.get(
|
||||||
|
`/client/${client.clientId}/verify-associations-cache`
|
||||||
|
);
|
||||||
|
setCacheCheck(res.data.data);
|
||||||
|
toast({
|
||||||
|
title: "Cache rebuilt",
|
||||||
|
description: "Association cache rebuilt successfully."
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Rebuild failed",
|
||||||
|
description: formatAxiosError(e, "Failed to rebuild cache")
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsRebuildingCache(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerifyCache = async () => {
|
||||||
|
if (!client.clientId) return;
|
||||||
|
setIsCheckingCache(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get(
|
||||||
|
`/client/${client.clientId}/verify-associations-cache`
|
||||||
|
);
|
||||||
|
setCacheCheck(res.data.data);
|
||||||
|
} catch (e) {
|
||||||
|
toast({
|
||||||
|
variant: "destructive",
|
||||||
|
title: "Cache check failed",
|
||||||
|
description: formatAxiosError(e, "Failed to verify cache")
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsCheckingCache(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
|
|
||||||
const showApprovalFeatures =
|
const showApprovalFeatures =
|
||||||
@@ -844,6 +903,75 @@ export default function GeneralPage() {
|
|||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Hidden cache verification — subtle button, dev/admin diagnostic */}
|
||||||
|
<div className="mt-8 flex flex-col gap-2 items-start opacity-30 hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleVerifyCache}
|
||||||
|
disabled={isCheckingCache}
|
||||||
|
className="text-xs text-muted-foreground underline disabled:opacity-50"
|
||||||
|
title="Verify the client's site association cache against current permissions (read-only)"
|
||||||
|
>
|
||||||
|
{isCheckingCache
|
||||||
|
? "Checking cache…"
|
||||||
|
: "Verify association cache"}
|
||||||
|
</button>
|
||||||
|
{cacheCheck && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"text-xs rounded border px-2 py-1 " +
|
||||||
|
(cacheCheck.consistent
|
||||||
|
? "border-green-600 text-green-700"
|
||||||
|
: "border-red-600 text-red-700")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{cacheCheck.consistent ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="h-3 w-3" />
|
||||||
|
Cache is consistent
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-1 font-semibold">
|
||||||
|
<XCircle className="h-3 w-3" />
|
||||||
|
Cache is INCONSISTENT
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Missing site resources: [
|
||||||
|
{cacheCheck.missingSiteResourceIds.join(
|
||||||
|
", "
|
||||||
|
)}
|
||||||
|
]
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Extra site resources: [
|
||||||
|
{cacheCheck.extraSiteResourceIds.join(", ")}
|
||||||
|
]
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Missing sites: [
|
||||||
|
{cacheCheck.missingSiteIds.join(", ")}]
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Extra sites: [
|
||||||
|
{cacheCheck.extraSiteIds.join(", ")}]
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRebuildCache}
|
||||||
|
disabled={isRebuildingCache}
|
||||||
|
className="mt-1 text-xs underline font-semibold disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isRebuildingCache
|
||||||
|
? "Rebuilding…"
|
||||||
|
: "Rebuild cache now"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</SettingsContainer>
|
</SettingsContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,10 +280,14 @@ export default function GeneralPage() {
|
|||||||
console.log("Data refreshed");
|
console.log("Data refreshed");
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
try {
|
try {
|
||||||
|
const endDate = searchParams.get("end")
|
||||||
|
? dateRange.endDate
|
||||||
|
: { date: new Date() };
|
||||||
|
setDateRange((current) => ({ ...current, endDate }));
|
||||||
// Refresh data with current date range and pagination
|
// Refresh data with current date range and pagination
|
||||||
await queryDateTime(
|
await queryDateTime(
|
||||||
dateRange.startDate,
|
dateRange.startDate,
|
||||||
dateRange.endDate,
|
endDate,
|
||||||
currentPage,
|
currentPage,
|
||||||
pageSize
|
pageSize
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -266,10 +266,14 @@ export default function GeneralPage() {
|
|||||||
console.log("Data refreshed");
|
console.log("Data refreshed");
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
try {
|
try {
|
||||||
|
const endDate = searchParams.get("end")
|
||||||
|
? dateRange.endDate
|
||||||
|
: { date: new Date() };
|
||||||
|
setDateRange((current) => ({ ...current, endDate }));
|
||||||
// Refresh data with current date range and pagination
|
// Refresh data with current date range and pagination
|
||||||
await queryDateTime(
|
await queryDateTime(
|
||||||
dateRange.startDate,
|
dateRange.startDate,
|
||||||
dateRange.endDate,
|
endDate,
|
||||||
currentPage,
|
currentPage,
|
||||||
pageSize
|
pageSize
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useEnvContext } from "@app/hooks/useEnvContext";
|
|||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
@@ -294,7 +294,7 @@ export default function ConnectionLogsPage() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: t("error"),
|
title: t("error"),
|
||||||
description: formatAxiosError(error),
|
description: t("Failed to filter logs"),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -306,10 +306,14 @@ export default function ConnectionLogsPage() {
|
|||||||
console.log("Data refreshed");
|
console.log("Data refreshed");
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
try {
|
try {
|
||||||
|
const endDate = searchParams.get("end")
|
||||||
|
? dateRange.endDate
|
||||||
|
: { date: new Date() };
|
||||||
|
setDateRange((current) => ({ ...current, endDate }));
|
||||||
// Refresh data with current date range and pagination
|
// Refresh data with current date range and pagination
|
||||||
await queryDateTime(
|
await queryDateTime(
|
||||||
dateRange.startDate,
|
dateRange.startDate,
|
||||||
dateRange.endDate,
|
endDate,
|
||||||
currentPage,
|
currentPage,
|
||||||
pageSize
|
pageSize
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -281,10 +281,14 @@ export default function GeneralPage() {
|
|||||||
console.log("Data refreshed");
|
console.log("Data refreshed");
|
||||||
setIsRefreshing(true);
|
setIsRefreshing(true);
|
||||||
try {
|
try {
|
||||||
|
const endDate = searchParams.get("end")
|
||||||
|
? dateRange.endDate
|
||||||
|
: { date: new Date() };
|
||||||
|
setDateRange((current) => ({ ...current, endDate }));
|
||||||
// Refresh data with current date range and pagination
|
// Refresh data with current date range and pagination
|
||||||
await queryDateTime(
|
await queryDateTime(
|
||||||
dateRange.startDate,
|
dateRange.startDate,
|
||||||
dateRange.endDate,
|
endDate,
|
||||||
currentPage,
|
currentPage,
|
||||||
pageSize
|
pageSize
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -127,8 +127,7 @@ export default async function ClientResourcesPage(
|
|||||||
authDaemonPort: siteResource.authDaemonPort ?? null,
|
authDaemonPort: siteResource.authDaemonPort ?? null,
|
||||||
subdomain: siteResource.subdomain ?? null,
|
subdomain: siteResource.subdomain ?? null,
|
||||||
domainId: siteResource.domainId ?? null,
|
domainId: siteResource.domainId ?? null,
|
||||||
fullDomain: siteResource.fullDomain ?? null,
|
fullDomain: siteResource.fullDomain ?? null
|
||||||
labels: siteResource.labels ?? []
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ import { build } from "@server/build";
|
|||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import { UserType } from "@server/types/UserTypes";
|
import { UserType } from "@server/types/UserTypes";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import SetResourcePasswordForm from "components/SetResourcePasswordForm";
|
import SetResourcePasswordForm from "@app/components/SetResourcePasswordForm";
|
||||||
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
|
import { Binary, Bot, InfoIcon, Key } from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ export default async function ProxyResourcesPage(
|
|||||||
protocol: resource.protocol,
|
protocol: resource.protocol,
|
||||||
proxyPort: resource.proxyPort,
|
proxyPort: resource.proxyPort,
|
||||||
http: resource.http,
|
http: resource.http,
|
||||||
labels: resource.labels,
|
|
||||||
authState: !resource.http
|
authState: !resource.http
|
||||||
? "none"
|
? "none"
|
||||||
: resource.sso ||
|
: resource.sso ||
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ export default async function SitesPage(props: SitesPageProps) {
|
|||||||
return {
|
return {
|
||||||
name: site.name,
|
name: site.name,
|
||||||
id: site.siteId,
|
id: site.siteId,
|
||||||
labels: site.labels,
|
|
||||||
nice: site.niceId.toString(),
|
nice: site.niceId.toString(),
|
||||||
address: site.address?.split("/")[0],
|
address: site.address?.split("/")[0],
|
||||||
mbIn: formatSize(site.megabytesIn || 0, site.type),
|
mbIn: formatSize(site.megabytesIn || 0, site.type),
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
SquareMousePointer,
|
SquareMousePointer,
|
||||||
TagIcon,
|
|
||||||
TicketCheck,
|
TicketCheck,
|
||||||
Unplug,
|
Unplug,
|
||||||
User,
|
User,
|
||||||
@@ -100,7 +99,7 @@ export const orgNavSections = (
|
|||||||
href: "/{orgId}/settings/domains",
|
href: "/{orgId}/settings/domains",
|
||||||
icon: <Globe className="size-4 flex-none" />
|
icon: <Globe className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
...(build === "saas"
|
...(build == "saas"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarRemoteExitNodes",
|
title: "sidebarRemoteExitNodes",
|
||||||
@@ -238,19 +237,10 @@ export const orgNavSections = (
|
|||||||
title: "sidebarApiKeys",
|
title: "sidebarApiKeys",
|
||||||
href: "/{orgId}/settings/api-keys",
|
href: "/{orgId}/settings/api-keys",
|
||||||
icon: <KeyRound className="size-4 flex-none" />
|
icon: <KeyRound className="size-4 flex-none" />
|
||||||
},
|
}
|
||||||
...(build !== "oss"
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
title: "labels",
|
|
||||||
href: "/{orgId}/settings/labels",
|
|
||||||
icon: <TagIcon className="size-4 flex-none" />
|
|
||||||
}
|
|
||||||
]
|
|
||||||
: [])
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
...(build === "saas" && options?.isPrimaryOrg
|
...(build == "saas" && options?.isPrimaryOrg
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarBillingAndLicenses",
|
title: "sidebarBillingAndLicenses",
|
||||||
|
|||||||
@@ -44,77 +44,11 @@ export type AuthPageCustomizationProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AuthPageFormSchema = z.object({
|
const AuthPageFormSchema = z.object({
|
||||||
logoUrl: z.union([
|
logoUrl: z
|
||||||
z.literal(""),
|
.string()
|
||||||
z.string().superRefine(async (urlOrPath, ctx) => {
|
.optional()
|
||||||
const parseResult = z.url().safeParse(urlOrPath);
|
.transform((val) => (val === "" ? undefined : val)),
|
||||||
if (!parseResult.success) {
|
|
||||||
if (build !== "enterprise") {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: "Must be a valid URL"
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
validateLocalPath(urlOrPath);
|
|
||||||
} catch (error) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message:
|
|
||||||
"Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(urlOrPath, {
|
|
||||||
method: "HEAD"
|
|
||||||
}).catch(() => {
|
|
||||||
// If HEAD fails (CORS or method not allowed), try GET
|
|
||||||
return fetch(urlOrPath, { method: "GET" });
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.status !== 200) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `Failed to load image. Please check that the URL is accessible.`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const contentType = response.headers.get("content-type") ?? "";
|
|
||||||
if (!contentType.startsWith("image/")) {
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).`
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
let errorMessage =
|
|
||||||
"Unable to verify image URL. Please check that the URL is accessible and points to an image file.";
|
|
||||||
|
|
||||||
if (
|
|
||||||
error instanceof TypeError &&
|
|
||||||
error.message.includes("fetch")
|
|
||||||
) {
|
|
||||||
errorMessage =
|
|
||||||
"Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct.";
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
errorMessage = `Error verifying URL: ${error.message}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
message: errorMessage
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
]),
|
|
||||||
logoWidth: z.coerce.number<number>().min(1),
|
logoWidth: z.coerce.number<number>().min(1),
|
||||||
logoHeight: z.coerce.number<number>().min(1),
|
logoHeight: z.coerce.number<number>().min(1),
|
||||||
orgTitle: z.string().optional(),
|
orgTitle: z.string().optional(),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
|
import { DataTable } from "@app/components/ui/data-table";
|
||||||
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
import { ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||||
import { Badge } from "@app/components/ui/badge";
|
import { Badge } from "@app/components/ui/badge";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
@@ -29,21 +30,13 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
Funnel,
|
Funnel,
|
||||||
MoreHorizontal,
|
MoreHorizontal
|
||||||
PlusIcon
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
import { Selectedsite, SitesSelector } from "@app/components/site-selector";
|
||||||
import {
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
startTransition,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useOptimistic,
|
|
||||||
useState,
|
|
||||||
useTransition
|
|
||||||
} from "react";
|
|
||||||
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog";
|
||||||
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog";
|
||||||
import type { PaginationState } from "@tanstack/react-table";
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
@@ -60,10 +53,6 @@ import {
|
|||||||
} from "@app/components/ResourceSitesStatusCell";
|
} from "@app/components/ResourceSitesStatusCell";
|
||||||
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
import { ResourceAccessCertIndicator } from "@app/components/ResourceAccessCertIndicator";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import { LabelBadge } from "./label-badge";
|
|
||||||
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
|
||||||
|
|
||||||
export type InternalResourceSiteRow = ResourceSiteRow;
|
export type InternalResourceSiteRow = ResourceSiteRow;
|
||||||
|
|
||||||
@@ -95,11 +84,6 @@ export type InternalResourceRow = {
|
|||||||
subdomain?: string | null;
|
subdomain?: string | null;
|
||||||
domainId?: string | null;
|
domainId?: string | null;
|
||||||
fullDomain?: string | null;
|
fullDomain?: string | null;
|
||||||
labels?: Array<{
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDestinationDisplay(row: InternalResourceRow): string {
|
function formatDestinationDisplay(row: InternalResourceRow): string {
|
||||||
@@ -157,10 +141,7 @@ export default function ClientResourcesTable({
|
|||||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
const [siteFilterOpen, setSiteFilterOpen] = useState(false);
|
||||||
|
|
||||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
|
||||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
@@ -186,7 +167,7 @@ export default function ClientResourcesTable({
|
|||||||
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
|
}, [initialFilterSite, siteIdQ, siteIdNum, t]);
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
startRefreshTransition(() => {
|
startTransition(() => {
|
||||||
try {
|
try {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -204,8 +185,8 @@ export default function ClientResourcesTable({
|
|||||||
siteId: number
|
siteId: number
|
||||||
) => {
|
) => {
|
||||||
try {
|
try {
|
||||||
startTransition(async () => {
|
await api.delete(`/site-resource/${resourceId}`).then(() => {
|
||||||
await api.delete(`/site-resource/${resourceId}`).then(() => {
|
startTransition(() => {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
setIsDeleteModalOpen(false);
|
setIsDeleteModalOpen(false);
|
||||||
});
|
});
|
||||||
@@ -273,333 +254,296 @@ export default function ClientResourcesTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const internalColumns = useMemo<
|
const internalColumns: ExtendedColumnDef<InternalResourceRow>[] = [
|
||||||
ExtendedColumnDef<InternalResourceRow>[]
|
{
|
||||||
>(() => {
|
accessorKey: "name",
|
||||||
const cols: ExtendedColumnDef<InternalResourceRow>[] = [
|
enableHiding: false,
|
||||||
{
|
friendlyName: t("name"),
|
||||||
accessorKey: "name",
|
header: () => {
|
||||||
enableHiding: false,
|
const nameOrder = getSortDirection("name", searchParams);
|
||||||
friendlyName: t("name"),
|
const Icon =
|
||||||
header: () => {
|
nameOrder === "asc"
|
||||||
const nameOrder = getSortDirection("name", searchParams);
|
? ArrowDown01Icon
|
||||||
const Icon =
|
: nameOrder === "desc"
|
||||||
nameOrder === "asc"
|
? ArrowUp10Icon
|
||||||
? ArrowDown01Icon
|
: ChevronsUpDownIcon;
|
||||||
: nameOrder === "desc"
|
|
||||||
? ArrowUp10Icon
|
|
||||||
: ChevronsUpDownIcon;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="p-3"
|
className="p-3"
|
||||||
onClick={() => toggleSort("name")}
|
onClick={() => toggleSort("name")}
|
||||||
>
|
|
||||||
{t("name")}
|
|
||||||
<Icon className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "niceId",
|
|
||||||
accessorKey: "niceId",
|
|
||||||
friendlyName: t("identifier"),
|
|
||||||
enableHiding: true,
|
|
||||||
header: ({ column }) => {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() =>
|
|
||||||
column.toggleSorting(
|
|
||||||
column.getIsSorted() === "asc"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("identifier")}
|
|
||||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
cell: ({ row }) => {
|
|
||||||
return <span>{row.original.niceId || "-"}</span>;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "sites",
|
|
||||||
accessorFn: (row) =>
|
|
||||||
row.sites.map((s) => s.siteName).join(", "),
|
|
||||||
friendlyName: t("sites"),
|
|
||||||
header: () => (
|
|
||||||
<Popover
|
|
||||||
open={siteFilterOpen}
|
|
||||||
onOpenChange={setSiteFilterOpen}
|
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
{t("name")}
|
||||||
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "niceId",
|
||||||
|
accessorKey: "niceId",
|
||||||
|
friendlyName: t("identifier"),
|
||||||
|
enableHiding: true,
|
||||||
|
header: ({ column }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() =>
|
||||||
|
column.toggleSorting(column.getIsSorted() === "asc")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("identifier")}
|
||||||
|
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
cell: ({ row }) => {
|
||||||
|
return <span>{row.original.niceId || "-"}</span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sites",
|
||||||
|
accessorFn: (row) => row.sites.map((s) => s.siteName).join(", "),
|
||||||
|
friendlyName: t("sites"),
|
||||||
|
header: () => (
|
||||||
|
<Popover open={siteFilterOpen} onOpenChange={setSiteFilterOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
role="combobox"
|
||||||
|
className={cn(
|
||||||
|
"justify-between text-sm h-8 px-2 w-full p-3",
|
||||||
|
!selectedSite && "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{t("sites")}
|
||||||
|
<Funnel className="size-4 flex-none" />
|
||||||
|
{selectedSite && (
|
||||||
|
<Badge
|
||||||
|
className="truncate max-w-[10rem]"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{selectedSite.name}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent
|
||||||
|
className={dataTableFilterPopoverContentClassName}
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<div className="border-b p-1">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
role="combobox"
|
size="sm"
|
||||||
className={cn(
|
className="h-8 w-full justify-start font-normal"
|
||||||
"justify-between text-sm h-8 px-2 w-full p-3",
|
onClick={clearSiteFilter}
|
||||||
!selectedSite && "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
{t("standaloneHcFilterAnySite")}
|
||||||
{t("sites")}
|
|
||||||
<Funnel className="size-4 flex-none" />
|
|
||||||
{selectedSite && (
|
|
||||||
<Badge
|
|
||||||
className="truncate max-w-[10rem]"
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
{selectedSite.name}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Button>
|
</Button>
|
||||||
</PopoverTrigger>
|
</div>
|
||||||
<PopoverContent
|
<SitesSelector
|
||||||
className={dataTableFilterPopoverContentClassName}
|
orgId={orgId}
|
||||||
align="start"
|
selectedSite={selectedSite}
|
||||||
>
|
onSelectSite={onPickSite}
|
||||||
<div className="border-b p-1">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8 w-full justify-start font-normal"
|
|
||||||
onClick={clearSiteFilter}
|
|
||||||
>
|
|
||||||
{t("standaloneHcFilterAnySite")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<SitesSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedSite={selectedSite}
|
|
||||||
onSelectSite={onPickSite}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const resourceRow = row.original;
|
|
||||||
return (
|
|
||||||
<ResourceSitesStatusCell
|
|
||||||
orgId={resourceRow.orgId}
|
|
||||||
resourceSites={resourceRow.sites}
|
|
||||||
/>
|
/>
|
||||||
);
|
</PopoverContent>
|
||||||
}
|
</Popover>
|
||||||
},
|
),
|
||||||
{
|
cell: ({ row }) => {
|
||||||
accessorKey: "mode",
|
const resourceRow = row.original;
|
||||||
friendlyName: t("editInternalResourceDialogMode"),
|
return (
|
||||||
header: () => (
|
<ResourceSitesStatusCell
|
||||||
<ColumnFilterButton
|
orgId={resourceRow.orgId}
|
||||||
options={[
|
resourceSites={resourceRow.sites}
|
||||||
{
|
|
||||||
value: "host",
|
|
||||||
label: t("editInternalResourceDialogModeHost")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "cidr",
|
|
||||||
label: t("editInternalResourceDialogModeCidr")
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "http",
|
|
||||||
label: t("editInternalResourceDialogModeHttp")
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
selectedValue={searchParams.get("mode") ?? undefined}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
handleFilterChange("mode", value)
|
|
||||||
}
|
|
||||||
searchPlaceholder={t("searchPlaceholder")}
|
|
||||||
emptyMessage={t("emptySearchOptions")}
|
|
||||||
label={t("editInternalResourceDialogMode")}
|
|
||||||
className="p-3"
|
|
||||||
/>
|
/>
|
||||||
),
|
);
|
||||||
cell: ({ row }) => {
|
}
|
||||||
const resourceRow = row.original;
|
},
|
||||||
const modeLabels: Record<
|
{
|
||||||
"host" | "cidr" | "port" | "http",
|
accessorKey: "mode",
|
||||||
string
|
friendlyName: t("editInternalResourceDialogMode"),
|
||||||
> = {
|
header: () => (
|
||||||
host: t("editInternalResourceDialogModeHost"),
|
<ColumnFilterButton
|
||||||
cidr: t("editInternalResourceDialogModeCidr"),
|
options={[
|
||||||
port: t("editInternalResourceDialogModePort"),
|
{
|
||||||
http: t("editInternalResourceDialogModeHttp")
|
value: "host",
|
||||||
};
|
label: t("editInternalResourceDialogModeHost")
|
||||||
return <span>{modeLabels[resourceRow.mode]}</span>;
|
},
|
||||||
}
|
{
|
||||||
},
|
value: "cidr",
|
||||||
{
|
label: t("editInternalResourceDialogModeCidr")
|
||||||
accessorKey: "destination",
|
},
|
||||||
friendlyName: t("resourcesTableDestination"),
|
{
|
||||||
header: () => (
|
value: "http",
|
||||||
<span className="p-3">
|
label: t("editInternalResourceDialogModeHttp")
|
||||||
{t("resourcesTableDestination")}
|
}
|
||||||
</span>
|
]}
|
||||||
),
|
selectedValue={searchParams.get("mode") ?? undefined}
|
||||||
cell: ({ row }) => {
|
onValueChange={(value) => handleFilterChange("mode", value)}
|
||||||
const resourceRow = row.original;
|
searchPlaceholder={t("searchPlaceholder")}
|
||||||
const display = formatDestinationDisplay(resourceRow);
|
emptyMessage={t("emptySearchOptions")}
|
||||||
|
label={t("editInternalResourceDialogMode")}
|
||||||
|
className="p-3"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resourceRow = row.original;
|
||||||
|
const modeLabels: Record<
|
||||||
|
"host" | "cidr" | "port" | "http",
|
||||||
|
string
|
||||||
|
> = {
|
||||||
|
host: t("editInternalResourceDialogModeHost"),
|
||||||
|
cidr: t("editInternalResourceDialogModeCidr"),
|
||||||
|
port: t("editInternalResourceDialogModePort"),
|
||||||
|
http: t("editInternalResourceDialogModeHttp")
|
||||||
|
};
|
||||||
|
return <span>{modeLabels[resourceRow.mode]}</span>;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "destination",
|
||||||
|
friendlyName: t("resourcesTableDestination"),
|
||||||
|
header: () => (
|
||||||
|
<span className="p-3">{t("resourcesTableDestination")}</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resourceRow = row.original;
|
||||||
|
const display = formatDestinationDisplay(resourceRow);
|
||||||
|
return (
|
||||||
|
<CopyToClipboard
|
||||||
|
text={display}
|
||||||
|
isLink={false}
|
||||||
|
displayText={display}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "alias",
|
||||||
|
friendlyName: t("resourcesTableAlias"),
|
||||||
|
header: () => (
|
||||||
|
<span className="p-3">{t("resourcesTableAlias")}</span>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resourceRow = row.original;
|
||||||
|
if (resourceRow.mode === "host" && resourceRow.alias) {
|
||||||
return (
|
return (
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
text={display}
|
text={resourceRow.alias}
|
||||||
isLink={false}
|
isLink={false}
|
||||||
displayText={display}
|
displayText={resourceRow.alias}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
if (resourceRow.mode === "http") {
|
||||||
{
|
const domainId = resourceRow.domainId;
|
||||||
accessorKey: "alias",
|
const fullDomain = resourceRow.fullDomain;
|
||||||
friendlyName: t("resourcesTableAlias"),
|
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
|
||||||
header: () => (
|
const did =
|
||||||
<span className="p-3">{t("resourcesTableAlias")}</span>
|
build !== "oss" &&
|
||||||
),
|
resourceRow.ssl &&
|
||||||
cell: ({ row }) => {
|
domainId != null &&
|
||||||
const resourceRow = row.original;
|
domainId !== "" &&
|
||||||
if (resourceRow.mode === "host" && resourceRow.alias) {
|
fullDomain != null &&
|
||||||
return (
|
fullDomain !== "";
|
||||||
<CopyToClipboard
|
|
||||||
text={resourceRow.alias}
|
|
||||||
isLink={false}
|
|
||||||
displayText={resourceRow.alias}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (resourceRow.mode === "http") {
|
|
||||||
const domainId = resourceRow.domainId;
|
|
||||||
const fullDomain = resourceRow.fullDomain;
|
|
||||||
const url = `${resourceRow.ssl ? "https" : "http"}://${fullDomain}`;
|
|
||||||
const did =
|
|
||||||
build !== "oss" &&
|
|
||||||
resourceRow.ssl &&
|
|
||||||
domainId != null &&
|
|
||||||
domainId !== "" &&
|
|
||||||
fullDomain != null &&
|
|
||||||
fullDomain !== "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
{did ? (
|
|
||||||
<ResourceAccessCertIndicator
|
|
||||||
orgId={resourceRow.orgId}
|
|
||||||
domainId={domainId}
|
|
||||||
fullDomain={fullDomain}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<div className="">
|
|
||||||
<CopyToClipboard
|
|
||||||
text={url}
|
|
||||||
isLink={isSafeUrlForLink(url)}
|
|
||||||
displayText={url}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return <span>-</span>;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "aliasAddress",
|
|
||||||
friendlyName: t("resourcesTableAliasAddress"),
|
|
||||||
enableHiding: true,
|
|
||||||
header: () => (
|
|
||||||
<div className="flex items-center gap-2 p-3">
|
|
||||||
<span>{t("resourcesTableAliasAddress")}</span>
|
|
||||||
<InfoPopup info={t("resourcesTableAliasAddressInfo")} />
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const resourceRow = row.original;
|
|
||||||
return resourceRow.aliasAddress ? (
|
|
||||||
<CopyToClipboard
|
|
||||||
text={resourceRow.aliasAddress}
|
|
||||||
isLink={false}
|
|
||||||
displayText={resourceRow.aliasAddress}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<span>-</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
enableHiding: false,
|
|
||||||
header: () => <span className="p-3"></span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const resourceRow = row.original;
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 justify-end">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<DropdownMenu>
|
{did ? (
|
||||||
<DropdownMenuTrigger asChild>
|
<ResourceAccessCertIndicator
|
||||||
<Button
|
orgId={resourceRow.orgId}
|
||||||
variant="ghost"
|
domainId={domainId}
|
||||||
className="h-8 w-8 p-0"
|
fullDomain={fullDomain}
|
||||||
>
|
/>
|
||||||
<span className="sr-only">
|
) : null}
|
||||||
{t("openMenu")}
|
<div className="">
|
||||||
</span>
|
<CopyToClipboard
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
text={url}
|
||||||
</Button>
|
isLink={isSafeUrlForLink(url)}
|
||||||
</DropdownMenuTrigger>
|
displayText={url}
|
||||||
<DropdownMenuContent align="end">
|
/>
|
||||||
<DropdownMenuItem
|
</div>
|
||||||
onClick={() => {
|
|
||||||
setSelectedInternalResource(
|
|
||||||
resourceRow
|
|
||||||
);
|
|
||||||
setIsDeleteModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-red-500">
|
|
||||||
{t("delete")}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
<Button
|
|
||||||
variant={"outline"}
|
|
||||||
onClick={() => {
|
|
||||||
setEditingResource(resourceRow);
|
|
||||||
setIsEditDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("edit")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return <span>-</span>;
|
||||||
}
|
}
|
||||||
];
|
},
|
||||||
|
{
|
||||||
if (isLabelFeatureEnabled) {
|
accessorKey: "aliasAddress",
|
||||||
cols.splice(cols.length - 1, 0, {
|
friendlyName: t("resourcesTableAliasAddress"),
|
||||||
id: "labels",
|
enableHiding: true,
|
||||||
accessorKey: "labels",
|
header: () => (
|
||||||
header: () => (
|
<div className="flex items-center gap-2 p-3">
|
||||||
<span className="p-3 text-end w-full inline-block">
|
<span>{t("resourcesTableAliasAddress")}</span>
|
||||||
{t("labels")}
|
<InfoPopup info={t("resourcesTableAliasAddressInfo")} />
|
||||||
</span>
|
</div>
|
||||||
),
|
),
|
||||||
cell: ({ row }: { row: { original: InternalResourceRow } }) => (
|
cell: ({ row }) => {
|
||||||
<ClientResourceLabelCell
|
const resourceRow = row.original;
|
||||||
resource={row.original}
|
return resourceRow.aliasAddress ? (
|
||||||
orgId={orgId}
|
<CopyToClipboard
|
||||||
|
text={resourceRow.aliasAddress}
|
||||||
|
isLink={false}
|
||||||
|
displayText={resourceRow.aliasAddress}
|
||||||
/>
|
/>
|
||||||
)
|
) : (
|
||||||
});
|
<span>-</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
enableHiding: false,
|
||||||
|
header: () => <span className="p-3"></span>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const resourceRow = row.original;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">
|
||||||
|
{t("openMenu")}
|
||||||
|
</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedInternalResource(
|
||||||
|
resourceRow
|
||||||
|
);
|
||||||
|
setIsDeleteModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-red-500">
|
||||||
|
{t("delete")}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
<Button
|
||||||
|
variant={"outline"}
|
||||||
|
onClick={() => {
|
||||||
|
setEditingResource(resourceRow);
|
||||||
|
setIsEditDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("edit")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
];
|
||||||
return cols;
|
|
||||||
}, [isLabelFeatureEnabled, orgId, t, searchParams]);
|
|
||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
@@ -694,8 +638,7 @@ export default function ClientResourcesTable({
|
|||||||
enableColumnVisibility
|
enableColumnVisibility
|
||||||
columnVisibility={{
|
columnVisibility={{
|
||||||
niceId: false,
|
niceId: false,
|
||||||
aliasAddress: false,
|
aliasAddress: false
|
||||||
labels: false
|
|
||||||
}}
|
}}
|
||||||
stickyLeftColumn="name"
|
stickyLeftColumn="name"
|
||||||
stickyRightColumn="actions"
|
stickyRightColumn="actions"
|
||||||
@@ -731,101 +674,3 @@ export default function ClientResourcesTable({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type ClientResourceLabelCellProps = {
|
|
||||||
resource: InternalResourceRow;
|
|
||||||
orgId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function ClientResourceLabelCell({
|
|
||||||
resource,
|
|
||||||
orgId
|
|
||||||
}: ClientResourceLabelCellProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const labels = resource.labels ?? [];
|
|
||||||
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
|
|
||||||
|
|
||||||
function toggleResourceLabel(
|
|
||||||
label: SelectedLabel,
|
|
||||||
action: "attach" | "detach"
|
|
||||||
) {
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
if (action === "attach") {
|
|
||||||
setOptimisticLabels([...optimisticLabels, label]);
|
|
||||||
await api.put(
|
|
||||||
`/org/${orgId}/label/${label.labelId}/attach`,
|
|
||||||
{ siteResourceId: resource.id }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setOptimisticLabels(
|
|
||||||
optimisticLabels.filter(
|
|
||||||
(lb) => lb.labelId !== label.labelId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
await api.put(
|
|
||||||
`/org/${orgId}/label/${label.labelId}/detach`,
|
|
||||||
{ siteResourceId: resource.id }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
|
|
||||||
{optimisticLabels.slice(0, 3).map((label) => (
|
|
||||||
<LabelBadge
|
|
||||||
key={label.labelId}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
{...label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{optimisticLabels.length > 3 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex gap-1 items-center",
|
|
||||||
"rounded-full text-sm cursor-pointer",
|
|
||||||
"px-1.5 py-0 h-auto"
|
|
||||||
)}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
>
|
|
||||||
+{optimisticLabels.length - 3}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="p-1 size-auto rounded-full"
|
|
||||||
title={t("addLabels")}
|
|
||||||
>
|
|
||||||
<span className="sr-only">{t("addLabels")}</span>
|
|
||||||
<PlusIcon className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="center" className="p-0 w-full">
|
|
||||||
<LabelsSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedLabels={optimisticLabels}
|
|
||||||
toggleLabel={toggleResourceLabel}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { toast } from "@app/hooks/useToast";
|
|||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useState, useTransition } from "react";
|
import { useState } from "react";
|
||||||
import {
|
import {
|
||||||
cleanForFQDN,
|
cleanForFQDN,
|
||||||
InternalResourceForm,
|
InternalResourceForm,
|
||||||
@@ -39,30 +39,30 @@ export default function CreateInternalResourceDialog({
|
|||||||
}: CreateInternalResourceDialogProps) {
|
}: CreateInternalResourceDialogProps) {
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
const [isHttpModeDisabled, setIsHttpModeDisabled] = useState(false);
|
||||||
const [isSubmitting, startTransition] = useTransition();
|
|
||||||
|
|
||||||
function handleSubmit(values: InternalResourceFormValues) {
|
async function handleSubmit(values: InternalResourceFormValues) {
|
||||||
startTransition(async () => {
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
let data = { ...values };
|
let data = { ...values };
|
||||||
if (
|
if (
|
||||||
(data.mode === "host" || data.mode === "http") &&
|
(data.mode === "host" || data.mode === "http") &&
|
||||||
isHostname(data.destination)
|
isHostname(data.destination)
|
||||||
) {
|
) {
|
||||||
const currentAlias = data.alias?.trim() || "";
|
const currentAlias = data.alias?.trim() || "";
|
||||||
if (!currentAlias) {
|
if (!currentAlias) {
|
||||||
let aliasValue = data.destination;
|
let aliasValue = data.destination;
|
||||||
if (data.destination.toLowerCase() === "localhost") {
|
if (data.destination.toLowerCase() === "localhost") {
|
||||||
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
aliasValue = `${cleanForFQDN(data.name)}.internal`;
|
||||||
}
|
|
||||||
data = { ...data, alias: aliasValue };
|
|
||||||
}
|
}
|
||||||
|
data = { ...data, alias: aliasValue };
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await api.put<
|
await api.put<AxiosResponse<{ data: { siteResourceId: number } }>>(
|
||||||
AxiosResponse<{ data: { siteResourceId: number } }>
|
`/org/${orgId}/site-resource`,
|
||||||
>(`/org/${orgId}/site-resource`, {
|
{
|
||||||
name: data.name,
|
name: data.name,
|
||||||
siteIds: data.siteIds,
|
siteIds: data.siteIds,
|
||||||
mode: data.mode,
|
mode: data.mode,
|
||||||
@@ -106,30 +106,32 @@ export default function CreateInternalResourceDialog({
|
|||||||
clientIds: data.clients
|
clientIds: data.clients
|
||||||
? data.clients.map((c) => parseInt(c.id))
|
? data.clients.map((c) => parseInt(c.id))
|
||||||
: []
|
: []
|
||||||
});
|
}
|
||||||
|
);
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: t("createInternalResourceDialogSuccess"),
|
title: t("createInternalResourceDialogSuccess"),
|
||||||
description: t(
|
description: t(
|
||||||
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
"createInternalResourceDialogInternalResourceCreatedSuccessfully"
|
||||||
),
|
),
|
||||||
variant: "default"
|
variant: "default"
|
||||||
});
|
});
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: t("createInternalResourceDialogError"),
|
title: t("createInternalResourceDialogError"),
|
||||||
description: formatAxiosError(
|
description: formatAxiosError(
|
||||||
error,
|
error,
|
||||||
t(
|
t(
|
||||||
"createInternalResourceDialogFailedToCreateInternalResource"
|
"createInternalResourceDialogFailedToCreateInternalResource"
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
}
|
} finally {
|
||||||
});
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useTransition } from "react";
|
|
||||||
import {
|
|
||||||
Credenza,
|
|
||||||
CredenzaBody,
|
|
||||||
CredenzaClose,
|
|
||||||
CredenzaContent,
|
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
|
||||||
CredenzaHeader,
|
|
||||||
CredenzaTitle
|
|
||||||
} from "./Credenza";
|
|
||||||
import { OrgLabelForm } from "./OrgLabelForm";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
|
|
||||||
export type CreateOrgLabelDialogProps = {
|
|
||||||
open: boolean;
|
|
||||||
setOpen: (val: boolean) => void;
|
|
||||||
orgId: string;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function CreateOrgLabelDialog({
|
|
||||||
open,
|
|
||||||
setOpen,
|
|
||||||
orgId,
|
|
||||||
onSuccess
|
|
||||||
}: CreateOrgLabelDialogProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
const [isSubmitting, startTransition] = useTransition();
|
|
||||||
|
|
||||||
async function createOrgLabel(data: { name: string; color: string }) {
|
|
||||||
try {
|
|
||||||
const res = await api.post<
|
|
||||||
AxiosResponse<CreateOrEditLabelResponse>
|
|
||||||
>(`/org/${orgId}/labels`, data);
|
|
||||||
|
|
||||||
if (res.status === 201) {
|
|
||||||
setOpen(false);
|
|
||||||
onSuccess?.();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t("success"),
|
|
||||||
description: t("labelCreateSuccessMessage")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Credenza open={open} onOpenChange={setOpen}>
|
|
||||||
<CredenzaContent className="md:max-w-md">
|
|
||||||
<CredenzaHeader>
|
|
||||||
<CredenzaTitle>{t("createLabelDialogTitle")}</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
|
||||||
{t("createLabelDialogDescription")}
|
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
<OrgLabelForm
|
|
||||||
onSubmit={(data) => {
|
|
||||||
startTransition(async () => createOrgLabel(data));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
form="org-label-form"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
loading={isSubmitting}
|
|
||||||
>
|
|
||||||
{t("labelCreate")}
|
|
||||||
</Button>
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import type { CreateOrEditLabelResponse } from "@server/routers/labels/types";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { useTransition } from "react";
|
|
||||||
import {
|
|
||||||
Credenza,
|
|
||||||
CredenzaBody,
|
|
||||||
CredenzaClose,
|
|
||||||
CredenzaContent,
|
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
|
||||||
CredenzaHeader,
|
|
||||||
CredenzaTitle
|
|
||||||
} from "./Credenza";
|
|
||||||
import { OrgLabelForm } from "./OrgLabelForm";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
|
|
||||||
export type EditOrgLabelDialogProps = {
|
|
||||||
open: boolean;
|
|
||||||
setOpen: (val: boolean) => void;
|
|
||||||
orgId: string;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
label: {
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
labelId: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function EditOrgLabelDialog({
|
|
||||||
open,
|
|
||||||
setOpen,
|
|
||||||
orgId,
|
|
||||||
onSuccess,
|
|
||||||
label
|
|
||||||
}: EditOrgLabelDialogProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
const [isSubmitting, startTransition] = useTransition();
|
|
||||||
|
|
||||||
async function editOrgLabel(data: { name: string; color: string }) {
|
|
||||||
try {
|
|
||||||
const res = await api.patch<
|
|
||||||
AxiosResponse<CreateOrEditLabelResponse>
|
|
||||||
>(`/org/${orgId}/label/${label.labelId}`, data);
|
|
||||||
|
|
||||||
if (res.status === 200) {
|
|
||||||
setOpen(false);
|
|
||||||
onSuccess?.();
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t("success"),
|
|
||||||
description: t("labelEditSuccessMessage")
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Credenza open={open} onOpenChange={setOpen}>
|
|
||||||
<CredenzaContent className="md:max-w-md">
|
|
||||||
<CredenzaHeader>
|
|
||||||
<CredenzaTitle>{t("editLabelDialogTitle")}</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
|
||||||
{t("editLabelDialogDescription")}
|
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
<OrgLabelForm
|
|
||||||
defaultValue={label}
|
|
||||||
onSubmit={(data) => {
|
|
||||||
startTransition(async () => editOrgLabel(data));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<CredenzaClose asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setOpen(false)}
|
|
||||||
disabled={isSubmitting}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
</CredenzaClose>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
form="org-label-form"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
loading={isSubmitting}
|
|
||||||
>
|
|
||||||
{t("labelEdit")}
|
|
||||||
</Button>
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -10,11 +10,8 @@ import {
|
|||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
|
||||||
import {
|
import {
|
||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
@@ -22,26 +19,12 @@ import {
|
|||||||
CircleSlash,
|
CircleSlash,
|
||||||
ArrowDown01Icon,
|
ArrowDown01Icon,
|
||||||
ArrowUp10Icon,
|
ArrowUp10Icon,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon
|
||||||
PlusIcon
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import { useMemo, useState, useTransition } from "react";
|
||||||
startTransition,
|
|
||||||
useMemo,
|
|
||||||
useOptimistic,
|
|
||||||
useState,
|
|
||||||
useTransition
|
|
||||||
} from "react";
|
|
||||||
import { LabelBadge } from "./label-badge";
|
|
||||||
import { LabelsSelector, type SelectedLabel } from "./labels-selector";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger
|
|
||||||
} from "./ui/popover";
|
|
||||||
import { Badge } from "./ui/badge";
|
import { Badge } from "./ui/badge";
|
||||||
import type { PaginationState } from "@tanstack/react-table";
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
import { ControlledDataTable } from "./ui/controlled-data-table";
|
import { ControlledDataTable } from "./ui/controlled-data-table";
|
||||||
@@ -70,11 +53,6 @@ export type ClientRow = {
|
|||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
blocked?: boolean;
|
blocked?: boolean;
|
||||||
approvalState: "approved" | "pending" | "denied";
|
approvalState: "approved" | "pending" | "denied";
|
||||||
labels?: Array<{
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
}>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClientTableProps = {
|
type ClientTableProps = {
|
||||||
@@ -106,21 +84,17 @@ export default function MachineClientsTable({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
const api = createApiClient(useEnvContext());
|
||||||
const [isRefreshing, startRefreshTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
const [isNavigatingToAddPage, startNavigation] = useTransition();
|
||||||
|
|
||||||
const { isPaidUser } = usePaidStatus();
|
|
||||||
const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels);
|
|
||||||
|
|
||||||
const defaultMachineColumnVisibility = {
|
const defaultMachineColumnVisibility = {
|
||||||
subnet: false,
|
subnet: false,
|
||||||
userId: false,
|
userId: false,
|
||||||
niceId: false,
|
niceId: false
|
||||||
labels: false
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshData = () => {
|
const refreshData = () => {
|
||||||
startRefreshTransition(() => {
|
startTransition(() => {
|
||||||
try {
|
try {
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -410,24 +384,6 @@ export default function MachineClientsTable({
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
if (isLabelFeatureEnabled) {
|
|
||||||
baseColumns.push({
|
|
||||||
id: "labels",
|
|
||||||
accessorKey: "labels",
|
|
||||||
header: () => (
|
|
||||||
<span className="p-3 text-end w-full inline-block">
|
|
||||||
{t("labels")}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
cell: ({ row }: { row: { original: ClientRow } }) => (
|
|
||||||
<MachineClientLabelCell
|
|
||||||
client={row.original}
|
|
||||||
orgId={orgId}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only include actions column if there are rows without userIds
|
// Only include actions column if there are rows without userIds
|
||||||
if (hasRowsWithoutUserId) {
|
if (hasRowsWithoutUserId) {
|
||||||
baseColumns.push({
|
baseColumns.push({
|
||||||
@@ -508,7 +464,7 @@ export default function MachineClientsTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return baseColumns;
|
return baseColumns;
|
||||||
}, [hasRowsWithoutUserId, isLabelFeatureEnabled, orgId, t, searchParams]);
|
}, [hasRowsWithoutUserId, t, getSortDirection, toggleSort]);
|
||||||
|
|
||||||
const booleanSearchFilterSchema = z
|
const booleanSearchFilterSchema = z
|
||||||
.enum(["true", "false"])
|
.enum(["true", "false"])
|
||||||
@@ -635,95 +591,3 @@ export default function MachineClientsTable({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type MachineClientLabelCellProps = {
|
|
||||||
client: ClientRow;
|
|
||||||
orgId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function MachineClientLabelCell({ client, orgId }: MachineClientLabelCellProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const labels = client.labels ?? [];
|
|
||||||
const [optimisticLabels, setOptimisticLabels] = useOptimistic(labels);
|
|
||||||
|
|
||||||
function toggleClientLabel(label: SelectedLabel, action: "attach" | "detach") {
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
if (action === "attach") {
|
|
||||||
setOptimisticLabels([...optimisticLabels, label]);
|
|
||||||
await api.put(
|
|
||||||
`/org/${orgId}/label/${label.labelId}/attach`,
|
|
||||||
{ clientId: client.id }
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
setOptimisticLabels(
|
|
||||||
optimisticLabels.filter(
|
|
||||||
(lb) => lb.labelId !== label.labelId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
await api.put(
|
|
||||||
`/org/${orgId}/label/${label.labelId}/detach`,
|
|
||||||
{ clientId: client.id }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: formatAxiosError(e, t("errorOccurred")),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="inline-flex flex-wrap items-center justify-end w-full gap-1">
|
|
||||||
{optimisticLabels.slice(0, 3).map((label) => (
|
|
||||||
<LabelBadge
|
|
||||||
key={label.labelId}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
{...label}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
{optimisticLabels.length > 3 && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className={cn(
|
|
||||||
"inline-flex gap-1 items-center",
|
|
||||||
"rounded-full text-sm cursor-pointer",
|
|
||||||
"px-1.5 py-0 h-auto"
|
|
||||||
)}
|
|
||||||
onClick={() => setIsPopoverOpen(true)}
|
|
||||||
>
|
|
||||||
+{optimisticLabels.length - 3}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="p-1 size-auto rounded-full"
|
|
||||||
title={t("addLabels")}
|
|
||||||
>
|
|
||||||
<span className="sr-only">{t("addLabels")}</span>
|
|
||||||
<PlusIcon className="size-3" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent align="center" className="p-0 w-full">
|
|
||||||
<LabelsSelector
|
|
||||||
orgId={orgId}
|
|
||||||
selectedLabels={optimisticLabels}
|
|
||||||
toggleLabel={toggleClientLabel}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import z from "zod";
|
|
||||||
import { Input } from "./ui/input";
|
|
||||||
import { useTranslations } from "use-intl";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import {
|
|
||||||
Form,
|
|
||||||
FormControl,
|
|
||||||
FormField,
|
|
||||||
FormItem,
|
|
||||||
FormLabel,
|
|
||||||
FormMessage
|
|
||||||
} from "./ui/form";
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue
|
|
||||||
} from "./ui/select";
|
|
||||||
import { LABEL_COLORS } from "./labels-selector";
|
|
||||||
|
|
||||||
const labelFormSchema = z.object({
|
|
||||||
name: z.string().nonempty(),
|
|
||||||
color: z
|
|
||||||
.string()
|
|
||||||
.regex(/^#?([0-9a-f]{6}|[0-9a-f]{3})$/i)
|
|
||||||
.nonempty()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type LabelFormData = z.infer<typeof labelFormSchema>;
|
|
||||||
|
|
||||||
export type OrgLabelFormProps = {
|
|
||||||
onSubmit: (data: LabelFormData) => void;
|
|
||||||
defaultValue?: LabelFormData;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OrgLabelForm({ onSubmit, defaultValue }: OrgLabelFormProps) {
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
const colorValues = Object.values(LABEL_COLORS);
|
|
||||||
const randomColor =
|
|
||||||
colorValues[Math.floor(Math.random() * colorValues.length)];
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
resolver: zodResolver(labelFormSchema),
|
|
||||||
defaultValues: {
|
|
||||||
name: defaultValue?.name ?? "",
|
|
||||||
color: defaultValue?.color ?? randomColor
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Form {...form}>
|
|
||||||
<form
|
|
||||||
id="org-label-form"
|
|
||||||
className="flex flex-col gap-4 px-0.5"
|
|
||||||
action={async () => {
|
|
||||||
if (await form.trigger()) {
|
|
||||||
onSubmit(form.getValues());
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="name"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t("labelNameField")}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
placeholder={t("labelPlaceholder")}
|
|
||||||
/>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="color"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t("labelColorField")}</FormLabel>
|
|
||||||
<Select
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
value={field.value}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t("selectColor")}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Object.entries(LABEL_COLORS).map(
|
|
||||||
([color, value]) => (
|
|
||||||
<SelectItem
|
|
||||||
value={value}
|
|
||||||
key={color}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="size-4 rounded-full bg-(--color) flex-none"
|
|
||||||
style={{
|
|
||||||
// @ts-expect-error css color
|
|
||||||
"--color": value
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span data-name>{color}</span>
|
|
||||||
</SelectItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</form>
|
|
||||||
</Form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger
|
|
||||||
} from "@app/components/ui/dropdown-menu";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import { type PaginationState } from "@tanstack/react-table";
|
|
||||||
import {
|
|
||||||
ArrowDown01Icon,
|
|
||||||
ArrowUp10Icon,
|
|
||||||
ChevronsUpDownIcon,
|
|
||||||
MoreHorizontal,
|
|
||||||
PencilIcon,
|
|
||||||
PencilLineIcon
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
|
||||||
import { useActionState, useMemo, useState, useTransition } from "react";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
import {
|
|
||||||
ControlledDataTable,
|
|
||||||
type ExtendedColumnDef
|
|
||||||
} from "./ui/controlled-data-table";
|
|
||||||
import { LabelBadge } from "./label-badge";
|
|
||||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
|
|
||||||
import { CreateOrgLabelDialog } from "./CreateOrgLabelDialog";
|
|
||||||
import { EditOrgLabelDialog } from "./EditOrgLabelDialog";
|
|
||||||
|
|
||||||
export type LabelRow = {
|
|
||||||
labelId: number;
|
|
||||||
name: string;
|
|
||||||
color: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type OrgLabelsTableProps = {
|
|
||||||
labels: LabelRow[];
|
|
||||||
pagination: PaginationState;
|
|
||||||
orgId: string;
|
|
||||||
rowCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function OrgLabelsTable({
|
|
||||||
labels,
|
|
||||||
orgId,
|
|
||||||
pagination,
|
|
||||||
rowCount
|
|
||||||
}: OrgLabelsTableProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const {
|
|
||||||
navigate: filter,
|
|
||||||
isNavigating: isFiltering,
|
|
||||||
searchParams
|
|
||||||
} = useNavigationContext();
|
|
||||||
|
|
||||||
const [selectedLabel, setSelectedLabel] = useState<LabelRow | null>(null);
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
|
||||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
|
||||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
const t = useTranslations();
|
|
||||||
|
|
||||||
function refreshData() {
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
router.refresh();
|
|
||||||
} catch {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("refreshError"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePaginationChange = (newPage: PaginationState) => {
|
|
||||||
searchParams.set("page", (newPage.pageIndex + 1).toString());
|
|
||||||
searchParams.set("pageSize", newPage.pageSize.toString());
|
|
||||||
filter({ searchParams });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearchChange = useDebouncedCallback((query: string) => {
|
|
||||||
searchParams.set("query", query);
|
|
||||||
searchParams.delete("page");
|
|
||||||
filter({ searchParams });
|
|
||||||
}, 300);
|
|
||||||
|
|
||||||
const columns = useMemo<ExtendedColumnDef<LabelRow>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: "name",
|
|
||||||
enableHiding: false,
|
|
||||||
header: () => {
|
|
||||||
return <span className="p-3">{t("name")}</span>;
|
|
||||||
},
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex items-center gap-1.5 group">
|
|
||||||
<div
|
|
||||||
className="size-2.5 rounded-full bg-(--color) flex-none"
|
|
||||||
style={{
|
|
||||||
// @ts-expect-error css color
|
|
||||||
"--color": row.original.color
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{row.original.name}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "actions",
|
|
||||||
enableHiding: false,
|
|
||||||
header: () => {
|
|
||||||
return <span className="p-3">{t("actions")}</span>;
|
|
||||||
},
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
|
||||||
<span className="sr-only">{t("openMenu")}</span>
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end">
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedLabel(row.original);
|
|
||||||
setIsEditModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("edit")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedLabel(row.original);
|
|
||||||
setIsDeleteModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-red-500">
|
|
||||||
{t("delete")}
|
|
||||||
</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[searchParams, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
function deleteLabel(label: LabelRow) {
|
|
||||||
startTransition(async () => {
|
|
||||||
await api
|
|
||||||
.delete(`/org/${orgId}/label/${label.labelId}`)
|
|
||||||
.catch((e) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("labelErrorDelete"),
|
|
||||||
description: formatAxiosError(e, t("labelErrorDelete"))
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
router.refresh();
|
|
||||||
setIsDeleteModalOpen(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{selectedLabel && (
|
|
||||||
<>
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
open={isDeleteModalOpen}
|
|
||||||
setOpen={(val) => {
|
|
||||||
setIsDeleteModalOpen(val);
|
|
||||||
setSelectedLabel(null);
|
|
||||||
}}
|
|
||||||
dialog={
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p>{t("labelQuestionRemove")}</p>
|
|
||||||
<p>{t("labelMessageRemove")}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
buttonText={t("labelDeleteConfirm")}
|
|
||||||
onConfirm={async () => deleteLabel(selectedLabel)}
|
|
||||||
string={selectedLabel.name}
|
|
||||||
title={t("labelDelete")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EditOrgLabelDialog
|
|
||||||
open={isEditModalOpen}
|
|
||||||
setOpen={setIsEditModalOpen}
|
|
||||||
orgId={orgId}
|
|
||||||
onSuccess={() =>
|
|
||||||
startTransition(() => router.refresh())
|
|
||||||
}
|
|
||||||
label={selectedLabel}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CreateOrgLabelDialog
|
|
||||||
open={isCreateModalOpen}
|
|
||||||
setOpen={setIsCreateModalOpen}
|
|
||||||
orgId={orgId}
|
|
||||||
onSuccess={() => startTransition(() => router.refresh())}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<ControlledDataTable
|
|
||||||
columns={columns}
|
|
||||||
rows={labels}
|
|
||||||
addButtonText={t("labelAdd")}
|
|
||||||
onAdd={() => setIsCreateModalOpen(true)}
|
|
||||||
tableId="org-labels-table"
|
|
||||||
searchPlaceholder={t("labelSearch")}
|
|
||||||
pagination={pagination}
|
|
||||||
onPaginationChange={handlePaginationChange}
|
|
||||||
searchQuery={searchParams.get("query")?.toString()}
|
|
||||||
onSearch={handleSearchChange}
|
|
||||||
onRefresh={refreshData}
|
|
||||||
isRefreshing={isRefreshing || isFiltering}
|
|
||||||
rowCount={rowCount}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user