mirror of
https://github.com/fosrl/pangolin.git
synced 2026-09-11 21:41:41 +02:00
Compare commits
17 Commits
dev
..
clustering
| Author | SHA1 | Date | |
|---|---|---|---|
| 094d3c4051 | |||
| 695507ce9d | |||
| c7f8851806 | |||
| e22aa79f1b | |||
| 738a790b3d | |||
| 94f2e579d1 | |||
| 7537d0d792 | |||
| d49177642c | |||
| 217a59ad10 | |||
| 3e3c5cf1c3 | |||
| f9752fd6f3 | |||
| a6204ae8da | |||
| 9524a11f25 | |||
| 6297759b15 | |||
| 84ff4296f8 | |||
| b0a147e10b | |||
| 9e23a0a6ee |
@@ -46,7 +46,6 @@ public/branding
|
|||||||
server/db/index.ts
|
server/db/index.ts
|
||||||
server/build.ts
|
server/build.ts
|
||||||
postgres/
|
postgres/
|
||||||
dynamic/
|
|
||||||
*.mmdb
|
*.mmdb
|
||||||
scratch/
|
scratch/
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
you need 3 instances at a minimum: node1 running pangolin, node 2 running pangolin, and a database server running postgres and redis. the third instance does not need to be a instance - you could deploy pg and redis however you want as long as its accessable to the nodes. the redis that is deployed needs to support pub sub.
|
||||||
|
|
||||||
|
the two pangolin nodes need to have public STATIC ips accessible on the internet <NODE1_EXTERNAL_IP> AND <NODE2_EXTERNAL_IP>
|
||||||
|
the two nodes need tp be able to address each other <NODE1_INTERNAL_IP> and <NODE2_INTERNAL_IP>
|
||||||
|
|
||||||
|
update these values in the `docker-compose.yml` file under the gerbil section:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
gerbil:
|
||||||
|
image: docker.io/fosrl/gerbil:latest
|
||||||
|
container_name: gerbil
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
pangolin:
|
||||||
|
condition: service_healthy
|
||||||
|
command:
|
||||||
|
- --reachableAt=http://<NODE1_INTERNAL_IP>:3004
|
||||||
|
- --generateAndSaveKeyTo=/var/config/key
|
||||||
|
- --remoteConfig=http://pangolin:3001/api/v1/
|
||||||
|
- --trusted-upstreams=<NODE1_EXTERNAL_IP>,<NODE2_EXTERNAL_IP> # All trusted nodes in the cluster
|
||||||
|
```
|
||||||
|
|
||||||
|
open ports should look like this
|
||||||
|
|
||||||
|
**Outbound Rules**
|
||||||
|
|
||||||
|
| Name | IP version | Type | Protocol | Port range | Destination | Description |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| – | IPv4 | All traffic | All | All | 0.0.0.0/0 | Allow all outbound |
|
||||||
|
|
||||||
|
**Inbound Rules**
|
||||||
|
|
||||||
|
| Name | IP version | Type | Protocol | Port range | Source | Description |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| – | IPv4 | Custom UDP | UDP | 21820 | 0.0.0.0/0 | WireGuard Relay Port |
|
||||||
|
| – | IPv4 | DNS (UDP) | UDP | 53 | 0.0.0.0/0 | DNS |
|
||||||
|
| – | IPv4 | HTTP | TCP | 80 | 0.0.0.0/0 | Ping and redirects |
|
||||||
|
| – | IPv4 | Custom UDP | UDP | 51820 | 0.0.0.0/0 | WireGuard Port |
|
||||||
|
| – | IPv4 | Custom TCP | TCP | 3004 | <self - all other nodes> | Pangolin API |
|
||||||
|
| – | IPv4 | HTTPS | TCP | 443 | 0.0.0.0/0 | Resources inbound |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
add your email address for acme into <CONTACT_EMAIL>
|
||||||
|
|
||||||
|
only one of the nodes - in this case node1 - should be configured to run the acme client. the other node should have acme disabled. this is because only one node should be responsible for generating and renewing certificates. the other node will use the same certificates from the database. this is controlled with `acme.enable_acme_client`
|
||||||
|
|
||||||
|
delegating domains:
|
||||||
|
|
||||||
|
you need to create a namesever dns record. this is an a record pointing at the cluster for the DNS nameserver.
|
||||||
|
|
||||||
|
in the example config, this is ns.example.com. you can replace example.com with your domain or choose a any subdomain. Create an A record pointing at your cluster's load ballencer:
|
||||||
|
|
||||||
|
| Name | Type | Value |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ns.example.com | A | <LOAD_BALANCER_IP> |
|
||||||
|
|
||||||
|
if you plan to support cname delegation to the server you will need to add an additional cname record for the cluster. this is an example of a cname record pointing at the cluster for the DNS nameserver.
|
||||||
|
|
||||||
|
| Name | Type | Value |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| cname.example.com | NS | ns.example.com |
|
||||||
|
|
||||||
|
finally, if you would like to support site-to-cloud networking, you can delegate a domain to be able to resolve site addresses withing a cloud encironement to address them through remote nodes. this looks like the above
|
||||||
|
|
||||||
|
| Name | Type | Value |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| site.example.com | NS | ns.example.com |
|
||||||
|
|
||||||
|
update all three of these values in the privateConfig dns section:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
dns:
|
||||||
|
enabled: true
|
||||||
|
nameserver_name: "ns.example.com"
|
||||||
|
cname_extension: "cname.example.com"
|
||||||
|
site_extension: "site.example.com" # Optional
|
||||||
|
```
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
when you start for the first time pick one node to start first. This node will init the database and print out the init token to the logs. Use this token to visit the UI and login to create the first user. Then bring up the other nodes
|
||||||
|
|
||||||
|
|
||||||
|
notes:
|
||||||
|
|
||||||
|
traefik uses file_mode: true. this is different than the regular pangolin instrall which scrapes the http api. the file mode writes the traefik config into the dynamic directory - the routers and certs. This is because traefik will only pull cert config files from a file and not from an api. ensure there is a shared volume beteeen pangolin and traefik
|
||||||
|
|
||||||
|
be sure to download and keep up to date the maxmind databases for geoip and asn. these are used for geolocation and asn lookups. (reference the geoblocking docs here) and place them into the config directory GeoLite2-ASN.mmdb and GeoLite2-Country.mmdb
|
||||||
|
|
||||||
|
--
|
||||||
|
|
||||||
|
whats required:
|
||||||
|
|
||||||
|
a load balancer in front of the nodes. this can be a cloud load balancer or a self hosted one like traefik. the load balancer should be configured to route traffic to both nodes pangolin UI and . the load balancer should also have a health check configured to check the /ping endpoint on both nodes. if a node is unhealthy, the load balancer should stop routing traffic to that node.
|
||||||
|
|
||||||
|
|
||||||
|
todo: we should put the dynamic config back on both nodes so that all the upstream LB has to do is route to one entrypoint and we deal with the pangolin routing downstream like the websocket and api and stuff
|
||||||
|
|
||||||
|
|
||||||
|
troubleshooting:
|
||||||
|
|
||||||
|
if you run into loopback issues with the local pangolin instance not being able to address the local gerbil at the IP of the host programmed in reachble at in the docker compose file then you can set the following in the private config file. this will force it to address the docker container instead.
|
||||||
|
|
||||||
|
```
|
||||||
|
gerbil:
|
||||||
|
local_exit_node_reachable_at: "http://gerbil:3004"
|
||||||
|
```
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17
|
||||||
|
container_name: postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: postgres # Default database name
|
||||||
|
POSTGRES_USER: postgres # Default user
|
||||||
|
POSTGRES_PASSWORD: password # Default password (change for production!)
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:latest
|
||||||
|
container_name: redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# To see all available options, please visit the docs:
|
||||||
|
# https://docs.pangolin.net/
|
||||||
|
|
||||||
|
gerbil:
|
||||||
|
start_port: 51820
|
||||||
|
base_endpoint: "<THIS_NODE_EXTERNAL_IP>"
|
||||||
|
exit_node_name: "node1"
|
||||||
|
|
||||||
|
app:
|
||||||
|
dashboard_url: "https://pangolin.example.com"
|
||||||
|
log_level: "info"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
connection_string: postgresql://<POSTGRES_USERNAME>:<POSTGRES_PASSWORD>@<POSTGRES_INTERNAL_HOST>:5432/postgres
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
site_types: ["newt"] # Wireguard and local sites are not support in clustering
|
||||||
|
file_mode: true # Pangolin will generate and save yaml files in a shared volume
|
||||||
|
|
||||||
|
server:
|
||||||
|
secret: "<SECRET>"
|
||||||
|
cors:
|
||||||
|
origins: ["https://pangolin.example.com"]
|
||||||
|
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||||
|
allowed_headers: ["X-CSRF-Token", "Content-Type"]
|
||||||
|
credentials: false
|
||||||
|
maxmind_db_path: "./config/GeoLite2-Country.mmdb" # Make sure to download and place into the config dir
|
||||||
|
maxmind_asn_path: "./config/GeoLite2-ASN.mmdb"
|
||||||
|
|
||||||
|
flags:
|
||||||
|
require_email_verification: false
|
||||||
|
disable_signup_without_invite: true
|
||||||
|
disable_user_create_org: false
|
||||||
|
allow_raw_resources: false
|
||||||
|
enable_acme_cert_sync: false
|
||||||
|
disable_local_sites: true
|
||||||
|
disable_basic_wireguard_sites: true
|
||||||
|
disable_config_managed_domains: true
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
badger:
|
||||||
|
plugin:
|
||||||
|
badger:
|
||||||
|
disableForwardAuth: true
|
||||||
|
|
||||||
|
routers:
|
||||||
|
# Next.js router (handles everything except API and WebSocket paths)
|
||||||
|
next-router:
|
||||||
|
rule: "!PathPrefix(`/api/v1`)"
|
||||||
|
service: next-service
|
||||||
|
entryPoints:
|
||||||
|
- dashboard
|
||||||
|
middlewares:
|
||||||
|
- badger
|
||||||
|
|
||||||
|
# API router (handles /api/v1 paths)
|
||||||
|
api-router:
|
||||||
|
rule: "PathPrefix(`/api/v1`)"
|
||||||
|
service: api-service
|
||||||
|
entryPoints:
|
||||||
|
- dashboard
|
||||||
|
middlewares:
|
||||||
|
- badger
|
||||||
|
|
||||||
|
# WebSocket router
|
||||||
|
ws-router:
|
||||||
|
rule: "PathPrefix(`/`)"
|
||||||
|
service: api-service
|
||||||
|
entryPoints:
|
||||||
|
- dashboard
|
||||||
|
middlewares:
|
||||||
|
- badger
|
||||||
|
|
||||||
|
services:
|
||||||
|
next-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://pangolin:3002" # Next.js server
|
||||||
|
|
||||||
|
api-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://pangolin:3000" # API/WebSocket server
|
||||||
|
|
||||||
|
tcp:
|
||||||
|
serversTransports:
|
||||||
|
pp-transport-v1:
|
||||||
|
proxyProtocol:
|
||||||
|
version: 1
|
||||||
|
pp-transport-v2:
|
||||||
|
proxyProtocol:
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
udp:
|
||||||
|
routers:
|
||||||
|
dns-router:
|
||||||
|
entryPoints:
|
||||||
|
- dns
|
||||||
|
service: dns-service
|
||||||
|
|
||||||
|
services:
|
||||||
|
dns-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- address: "pangolin:53"
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
app:
|
||||||
|
region: "region1"
|
||||||
|
identity_provider_mode: "org"
|
||||||
|
redis:
|
||||||
|
host: "<REDIS_INTERNAL_HOST>"
|
||||||
|
port: 6379
|
||||||
|
flags:
|
||||||
|
enable_redis: true
|
||||||
|
use_pangolin_dns: true
|
||||||
|
acme:
|
||||||
|
cert_mode: "pangolin"
|
||||||
|
contact_email: "<CONTACT_EMAIL>"
|
||||||
|
enable_acme_client: true
|
||||||
|
dns:
|
||||||
|
enabled: true
|
||||||
|
nameserver_name: "ns.example.com"
|
||||||
|
cname_extension: "cname.example.com"
|
||||||
|
site_extension: "site.example.com" # Optional
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
providers:
|
||||||
|
file:
|
||||||
|
directory: "/var/dynamic"
|
||||||
|
watch: true
|
||||||
|
|
||||||
|
experimental:
|
||||||
|
plugins:
|
||||||
|
badger:
|
||||||
|
moduleName: "github.com/fosrl/badger"
|
||||||
|
version: "v1.7.0"
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: "INFO"
|
||||||
|
format: "common"
|
||||||
|
maxSize: 100
|
||||||
|
maxBackups: 3
|
||||||
|
maxAge: 3
|
||||||
|
compress: true
|
||||||
|
|
||||||
|
entryPoints:
|
||||||
|
web:
|
||||||
|
address: ":80"
|
||||||
|
websecure:
|
||||||
|
address: ":443"
|
||||||
|
proxyProtocol: # Just accept everything for now!
|
||||||
|
trustedIPs:
|
||||||
|
- 0.0.0.0/0
|
||||||
|
- ::1/128
|
||||||
|
transport:
|
||||||
|
respondingTimeouts:
|
||||||
|
readTimeout: "30m"
|
||||||
|
http:
|
||||||
|
encodedCharacters:
|
||||||
|
allowEncodedSlash: true
|
||||||
|
allowEncodedQuestionMark: true
|
||||||
|
dashboard:
|
||||||
|
address: ":3000"
|
||||||
|
dns:
|
||||||
|
address: ":53/udp"
|
||||||
|
|
||||||
|
serversTransport:
|
||||||
|
insecureSkipVerify: true
|
||||||
|
|
||||||
|
ping:
|
||||||
|
entryPoint: "web"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: pangolin
|
||||||
|
services:
|
||||||
|
pangolin:
|
||||||
|
image: docker.io/fosrl/pangolin:ee-latest
|
||||||
|
container_name: pangolin
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./config:/app/config
|
||||||
|
- ./config/certificates:/var/certificates
|
||||||
|
- ./config/dynamic:/var/dynamic
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"]
|
||||||
|
interval: "10s"
|
||||||
|
timeout: "10s"
|
||||||
|
retries: 15
|
||||||
|
|
||||||
|
gerbil:
|
||||||
|
image: docker.io/fosrl/gerbil:latest
|
||||||
|
container_name: gerbil
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
pangolin:
|
||||||
|
condition: service_healthy
|
||||||
|
command:
|
||||||
|
- --reachableAt=http://<NODE1_INTERNAL_IP>:3004
|
||||||
|
- --generateAndSaveKeyTo=/var/config/key
|
||||||
|
- --remoteConfig=http://pangolin:3001/api/v1/
|
||||||
|
- --trusted-upstreams=<NODE1_EXTERNAL_IP>,<NODE2_EXTERNAL_IP>
|
||||||
|
volumes:
|
||||||
|
- ./config/:/var/config
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- SYS_MODULE
|
||||||
|
ports:
|
||||||
|
- 51820:51820/udp # wireguard
|
||||||
|
- 21820:21820/udp # relay
|
||||||
|
- 53:53/udp # DNS
|
||||||
|
- 443:8443 # resources
|
||||||
|
- 80:80 # web
|
||||||
|
- 3004:3004 # gerbil api
|
||||||
|
- 3000:3000 # Pangolin UI
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
image: docker.io/traefik:v3.7.11
|
||||||
|
container_name: traefik
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||||
|
depends_on:
|
||||||
|
pangolin:
|
||||||
|
condition: service_healthy
|
||||||
|
command:
|
||||||
|
- --configFile=/etc/traefik/traefik_config.yml
|
||||||
|
volumes:
|
||||||
|
- ./config/traefik:/etc/traefik:ro
|
||||||
|
- ./config/traefik/logs:/var/log/traefik
|
||||||
|
- ./config/certificates:/var/certificates:ro
|
||||||
|
- ./config/dynamic:/var/dynamic:ro
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
driver: bridge
|
||||||
|
name: pangolin
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# To see all available options, please visit the docs:
|
||||||
|
# https://docs.pangolin.net/
|
||||||
|
|
||||||
|
gerbil:
|
||||||
|
start_port: 51820
|
||||||
|
base_endpoint: "<THIS_NODE_EXTERNAL_IP>"
|
||||||
|
exit_node_name: "node2"
|
||||||
|
|
||||||
|
app:
|
||||||
|
dashboard_url: "https://pangolin.example.com"
|
||||||
|
log_level: "info"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
connection_string: postgresql://<POSTGRES_USERNAME>:<POSTGRES_PASSWORD>@<POSTGRES_INTERNAL_HOST>:5432/postgres
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
site_types: ["newt"] # Wireguard and local sites are not support in clustering
|
||||||
|
file_mode: true # Pangolin will generate and save yaml files in a shared volume
|
||||||
|
|
||||||
|
server:
|
||||||
|
secret: "<SECRET>"
|
||||||
|
cors:
|
||||||
|
origins: ["https://pangolin.example.com"]
|
||||||
|
methods: ["GET", "POST", "PUT", "DELETE", "PATCH"]
|
||||||
|
allowed_headers: ["X-CSRF-Token", "Content-Type"]
|
||||||
|
credentials: false
|
||||||
|
maxmind_db_path: "./config/GeoLite2-Country.mmdb" # Make sure to download and place into the config dir
|
||||||
|
maxmind_asn_path: "./config/GeoLite2-ASN.mmdb"
|
||||||
|
|
||||||
|
flags:
|
||||||
|
require_email_verification: false
|
||||||
|
disable_signup_without_invite: true
|
||||||
|
disable_user_create_org: false
|
||||||
|
allow_raw_resources: false
|
||||||
|
enable_acme_cert_sync: false
|
||||||
|
disable_local_sites: true
|
||||||
|
disable_basic_wireguard_sites: true
|
||||||
|
disable_config_managed_domains: true
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
badger:
|
||||||
|
plugin:
|
||||||
|
badger:
|
||||||
|
disableForwardAuth: true
|
||||||
|
|
||||||
|
routers:
|
||||||
|
# Next.js router (handles everything except API and WebSocket paths)
|
||||||
|
next-router:
|
||||||
|
rule: "!PathPrefix(`/api/v1`)"
|
||||||
|
service: next-service
|
||||||
|
entryPoints:
|
||||||
|
- dashboard
|
||||||
|
middlewares:
|
||||||
|
- badger
|
||||||
|
|
||||||
|
# API router (handles /api/v1 paths)
|
||||||
|
api-router:
|
||||||
|
rule: "PathPrefix(`/api/v1`)"
|
||||||
|
service: api-service
|
||||||
|
entryPoints:
|
||||||
|
- dashboard
|
||||||
|
middlewares:
|
||||||
|
- badger
|
||||||
|
|
||||||
|
# WebSocket router
|
||||||
|
ws-router:
|
||||||
|
rule: "PathPrefix(`/`)"
|
||||||
|
service: api-service
|
||||||
|
entryPoints:
|
||||||
|
- dashboard
|
||||||
|
middlewares:
|
||||||
|
- badger
|
||||||
|
|
||||||
|
services:
|
||||||
|
next-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://pangolin:3002" # Next.js server
|
||||||
|
|
||||||
|
api-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- url: "http://pangolin:3000" # API/WebSocket server
|
||||||
|
|
||||||
|
tcp:
|
||||||
|
serversTransports:
|
||||||
|
pp-transport-v1:
|
||||||
|
proxyProtocol:
|
||||||
|
version: 1
|
||||||
|
pp-transport-v2:
|
||||||
|
proxyProtocol:
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
udp:
|
||||||
|
routers:
|
||||||
|
dns-router:
|
||||||
|
entryPoints:
|
||||||
|
- dns
|
||||||
|
service: dns-service
|
||||||
|
|
||||||
|
services:
|
||||||
|
dns-service:
|
||||||
|
loadBalancer:
|
||||||
|
servers:
|
||||||
|
- address: "pangolin:53"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
app:
|
||||||
|
region: "region1"
|
||||||
|
identity_provider_mode: "org"
|
||||||
|
redis:
|
||||||
|
host: "<REDIS_INTERNAL_HOST>"
|
||||||
|
port: 6379
|
||||||
|
flags:
|
||||||
|
enable_redis: true
|
||||||
|
use_pangolin_dns: true
|
||||||
|
acme:
|
||||||
|
cert_mode: "pangolin"
|
||||||
|
dns:
|
||||||
|
enabled: true
|
||||||
|
nameserver_name: "ns.example.com"
|
||||||
|
cname_extension: "cname.example.com"
|
||||||
|
site_extension: "site.example.com" # Optional
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
providers:
|
||||||
|
file:
|
||||||
|
directory: "/var/dynamic"
|
||||||
|
watch: true
|
||||||
|
|
||||||
|
experimental:
|
||||||
|
plugins:
|
||||||
|
badger:
|
||||||
|
moduleName: "github.com/fosrl/badger"
|
||||||
|
version: "v1.7.0"
|
||||||
|
|
||||||
|
log:
|
||||||
|
level: "INFO"
|
||||||
|
format: "common"
|
||||||
|
maxSize: 100
|
||||||
|
maxBackups: 3
|
||||||
|
maxAge: 3
|
||||||
|
compress: true
|
||||||
|
|
||||||
|
entryPoints:
|
||||||
|
web:
|
||||||
|
address: ":80"
|
||||||
|
websecure:
|
||||||
|
address: ":443"
|
||||||
|
proxyProtocol: # Just accept everything for now!
|
||||||
|
trustedIPs:
|
||||||
|
- 0.0.0.0/0
|
||||||
|
- ::1/128
|
||||||
|
transport:
|
||||||
|
respondingTimeouts:
|
||||||
|
readTimeout: "30m"
|
||||||
|
http:
|
||||||
|
encodedCharacters:
|
||||||
|
allowEncodedSlash: true
|
||||||
|
allowEncodedQuestionMark: true
|
||||||
|
dashboard:
|
||||||
|
address: ":3000"
|
||||||
|
dns:
|
||||||
|
address: ":53/udp"
|
||||||
|
|
||||||
|
serversTransport:
|
||||||
|
insecureSkipVerify: true
|
||||||
|
|
||||||
|
ping:
|
||||||
|
entryPoint: "web"
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
name: pangolin
|
||||||
|
services:
|
||||||
|
pangolin:
|
||||||
|
image: docker.io/fosrl/pangolin:ee-latest
|
||||||
|
container_name: pangolin
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./config:/app/config
|
||||||
|
- ./config/certificates:/var/certificates
|
||||||
|
- ./config/dynamic:/var/dynamic
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:3001/api/v1/"]
|
||||||
|
interval: "10s"
|
||||||
|
timeout: "10s"
|
||||||
|
retries: 15
|
||||||
|
|
||||||
|
gerbil:
|
||||||
|
image: docker.io/fosrl/gerbil:latest
|
||||||
|
container_name: gerbil
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
pangolin:
|
||||||
|
condition: service_healthy
|
||||||
|
command:
|
||||||
|
- --reachableAt=http://<NODE1_INTERNAL_IP>:3004
|
||||||
|
- --generateAndSaveKeyTo=/var/config/key
|
||||||
|
- --remoteConfig=http://pangolin:3001/api/v1/
|
||||||
|
- --trusted-upstreams=<NODE1_EXTERNAL_IP>,<NODE2_EXTERNAL_IP>
|
||||||
|
volumes:
|
||||||
|
- ./config/:/var/config
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
- SYS_MODULE
|
||||||
|
ports:
|
||||||
|
- 51820:51820/udp # wireguard
|
||||||
|
- 21820:21820/udp # relay
|
||||||
|
- 53:53/udp # DNS
|
||||||
|
- 443:8443 # resources
|
||||||
|
- 80:80 # web
|
||||||
|
- 3004:3004 # gerbil api
|
||||||
|
- 3000:3000 # Pangolin UI
|
||||||
|
|
||||||
|
traefik:
|
||||||
|
image: docker.io/traefik:v3.7.11
|
||||||
|
container_name: traefik
|
||||||
|
restart: unless-stopped
|
||||||
|
network_mode: service:gerbil # Ports appear on the gerbil service
|
||||||
|
depends_on:
|
||||||
|
pangolin:
|
||||||
|
condition: service_healthy
|
||||||
|
command:
|
||||||
|
- --configFile=/etc/traefik/traefik_config.yml
|
||||||
|
volumes:
|
||||||
|
- ./config/traefik:/etc/traefik:ro
|
||||||
|
- ./config/traefik/logs:/var/log/traefik
|
||||||
|
- ./config/certificates:/var/certificates:ro
|
||||||
|
- ./config/dynamic:/var/dynamic:ro
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
driver: bridge
|
||||||
|
name: pangolin
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
tls:
|
||||||
|
certificates: []
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
+16
-43
@@ -80,7 +80,7 @@
|
|||||||
"siteManageSites": "Manage Sites",
|
"siteManageSites": "Manage Sites",
|
||||||
"siteDescription": "Create and manage sites to enable connectivity to private networks",
|
"siteDescription": "Create and manage sites to enable connectivity to private networks",
|
||||||
"sitesBannerTitle": "Connect Any Network",
|
"sitesBannerTitle": "Connect Any Network",
|
||||||
"sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector anywhere you can run a binary or container to establish the connection.",
|
"sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector (Newt) anywhere you can run a binary or container to establish the connection.",
|
||||||
"sitesBannerButtonText": "Install Site Connector",
|
"sitesBannerButtonText": "Install Site Connector",
|
||||||
"approvalsBannerTitle": "Approve or Deny Device Access",
|
"approvalsBannerTitle": "Approve or Deny Device Access",
|
||||||
"approvalsBannerDescription": "Review and approve or deny device access requests from users. When device approvals are required, users must get admin approval before their devices can connect to your organization's resources.",
|
"approvalsBannerDescription": "Review and approve or deny device access requests from users. When device approvals are required, users must get admin approval before their devices can connect to your organization's resources.",
|
||||||
@@ -226,9 +226,9 @@
|
|||||||
"never": "Never",
|
"never": "Never",
|
||||||
"shareErrorSelectResource": "Please select a resource",
|
"shareErrorSelectResource": "Please select a resource",
|
||||||
"proxyResourceTitle": "Manage Public Resources",
|
"proxyResourceTitle": "Manage Public Resources",
|
||||||
"proxyResourceDescription": "Create and manage resources that are publicly accessible via a proxy",
|
"proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser",
|
||||||
"publicResourcesBannerTitle": "Web-based Public Access",
|
"publicResourcesBannerTitle": "Web-based Public Access",
|
||||||
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet, like a website or API, and include identity and context-aware access policies. Unlike private resources, they do not require any client-side software to access.",
|
"publicResourcesBannerDescription": "Public resources are proxies accessible to anyone on the internet through a web browser and include identity and context-aware access policies. Unlike private resources, they do not require client-side software.",
|
||||||
"clientResourceTitle": "Manage Private Resources",
|
"clientResourceTitle": "Manage Private Resources",
|
||||||
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
|
"clientResourceDescription": "Create and manage resources that are only accessible through a connected client",
|
||||||
"privateResourcesBannerTitle": "Zero-Trust Private Access",
|
"privateResourcesBannerTitle": "Zero-Trust Private Access",
|
||||||
@@ -465,8 +465,6 @@
|
|||||||
"apiKeysDelete": "Delete API Key",
|
"apiKeysDelete": "Delete API Key",
|
||||||
"apiKeysManage": "Manage API Keys",
|
"apiKeysManage": "Manage API Keys",
|
||||||
"apiKeysDescription": "API keys are used to authenticate with the integration API",
|
"apiKeysDescription": "API keys are used to authenticate with the integration API",
|
||||||
"orgsManage": "Manage Organizations",
|
|
||||||
"orgsDescription": "View and manage all organizations on this instance",
|
|
||||||
"provisioningKeysTitle": "Provisioning Key",
|
"provisioningKeysTitle": "Provisioning Key",
|
||||||
"provisioningKeysManage": "Manage Provisioning Keys",
|
"provisioningKeysManage": "Manage Provisioning Keys",
|
||||||
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
|
"provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.",
|
||||||
@@ -522,12 +520,12 @@
|
|||||||
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
"pendingSitesBannerDescription": "Sites that connect using a provisioning key appear here for review.",
|
||||||
"pendingSitesBannerButtonText": "Learn More",
|
"pendingSitesBannerButtonText": "Learn More",
|
||||||
"apiKeysSettings": "{apiKeyName} Settings",
|
"apiKeysSettings": "{apiKeyName} Settings",
|
||||||
"userTitle": "Manage Users",
|
"userTitle": "Manage All Users",
|
||||||
"userDescription": "View and manage all users in this instance",
|
"userDescription": "View and manage all users in the system",
|
||||||
"userAbount": "About User Management",
|
"userAbount": "About User Management",
|
||||||
"userAbountDescription": "This table displays all base user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their base user object. They will remain in the system. To completely remove a user from the system, you must delete their base user object using the delete action in this table.",
|
"userAbountDescription": "This table displays all base user objects in the system. Each user may belong to multiple organizations. Removing a user from an organization does not delete their base user object. They will remain in the system. To completely remove a user from the system, you must delete their base user object using the delete action in this table.",
|
||||||
"userServer": "Server Users",
|
"userServer": "Server Users",
|
||||||
"userSearch": "Search users...",
|
"userSearch": "Search server users...",
|
||||||
"userErrorDelete": "Error deleting user",
|
"userErrorDelete": "Error deleting user",
|
||||||
"userDeleteConfirm": "Confirm Delete User",
|
"userDeleteConfirm": "Confirm Delete User",
|
||||||
"userDeleteServer": "Delete User from Server",
|
"userDeleteServer": "Delete User from Server",
|
||||||
@@ -596,7 +594,7 @@
|
|||||||
"licensePricingPage": "For the most up-to-date pricing and discounts, please visit the ",
|
"licensePricingPage": "For the most up-to-date pricing and discounts, please visit the ",
|
||||||
"invite": "Invitations",
|
"invite": "Invitations",
|
||||||
"inviteRegenerate": "Regenerate Invitation",
|
"inviteRegenerate": "Regenerate Invitation",
|
||||||
"inviteRegenerateDescription": "Create a new invite link for this user. The previous invitation will be revoked.",
|
"inviteRegenerateDescription": "Revoke previous invitation and create a new one",
|
||||||
"inviteRemove": "Remove Invitation",
|
"inviteRemove": "Remove Invitation",
|
||||||
"inviteRemoveError": "Failed to remove invitation",
|
"inviteRemoveError": "Failed to remove invitation",
|
||||||
"inviteRemoveErrorDescription": "An error occurred while removing the invitation.",
|
"inviteRemoveErrorDescription": "An error occurred while removing the invitation.",
|
||||||
@@ -676,11 +674,11 @@
|
|||||||
"accessUserCreateDescription": "Follow the steps below to create a new user",
|
"accessUserCreateDescription": "Follow the steps below to create a new user",
|
||||||
"userSeeAll": "See All Users",
|
"userSeeAll": "See All Users",
|
||||||
"userTypeTitle": "User Type",
|
"userTypeTitle": "User Type",
|
||||||
"userTypeDescription": "Select the identity provider to use for this user",
|
"userTypeDescription": "Determine how you want to create the user",
|
||||||
"userSettings": "User Information",
|
"userSettings": "User Information",
|
||||||
"userSettingsDescription": "Enter the general details for the new user",
|
"userSettingsDescription": "Enter the details for the new user",
|
||||||
"inviteEmailSent": "Send invite email to user",
|
"inviteEmailSent": "Send invite email to user",
|
||||||
"inviteValid": "Invite Valid For",
|
"inviteValid": "Invite Valid For (days)",
|
||||||
"selectDuration": "Select duration",
|
"selectDuration": "Select duration",
|
||||||
"selectResource": "Select Resource",
|
"selectResource": "Select Resource",
|
||||||
"filterByResource": "Filter By Resource",
|
"filterByResource": "Filter By Resource",
|
||||||
@@ -718,7 +716,6 @@
|
|||||||
"nameOptional": "Name (Optional)",
|
"nameOptional": "Name (Optional)",
|
||||||
"accessControls": "Access Controls",
|
"accessControls": "Access Controls",
|
||||||
"userDescription2": "Manage the settings on this user",
|
"userDescription2": "Manage the settings on this user",
|
||||||
"userGeneralSettingsDescription": "Manage this user's roles and settings in the organization",
|
|
||||||
"accessRoleErrorAdd": "Failed to add user to role",
|
"accessRoleErrorAdd": "Failed to add user to role",
|
||||||
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
|
"accessRoleErrorAddDescription": "An error occurred while adding user to the role.",
|
||||||
"userSaved": "User saved",
|
"userSaved": "User saved",
|
||||||
@@ -1217,7 +1214,7 @@
|
|||||||
"orgPoliciesEdit": "Edit Organization Policy",
|
"orgPoliciesEdit": "Edit Organization Policy",
|
||||||
"org": "Organization",
|
"org": "Organization",
|
||||||
"orgSelect": "Select organization",
|
"orgSelect": "Select organization",
|
||||||
"orgSearch": "Search organizations...",
|
"orgSearch": "Search org",
|
||||||
"orgNotFound": "No org found.",
|
"orgNotFound": "No org found.",
|
||||||
"roleMappingPathOptional": "Role Mapping Path (Optional)",
|
"roleMappingPathOptional": "Role Mapping Path (Optional)",
|
||||||
"orgMappingPathOptional": "Organization Mapping Path (Optional)",
|
"orgMappingPathOptional": "Organization Mapping Path (Optional)",
|
||||||
@@ -1356,7 +1353,7 @@
|
|||||||
"siteLabelsDescription": "Manage labels associated with this site.",
|
"siteLabelsDescription": "Manage labels associated with this site.",
|
||||||
"labelsNotFound": "No labels found.",
|
"labelsNotFound": "No labels found.",
|
||||||
"labelsEmptyCreateHint": "Start typing above to create a label.",
|
"labelsEmptyCreateHint": "Start typing above to create a label.",
|
||||||
"labelSearch": "Search labels...",
|
"labelSearch": "Search labels",
|
||||||
"labelSearchOrCreate": "Search or create a label",
|
"labelSearchOrCreate": "Search or create a label",
|
||||||
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
"accessLabelFilterCount": "{count, plural, one {# label} other {# labels}}",
|
||||||
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
|
"labelOverflowCount": "+{count, plural, one {# label} other {# labels}}",
|
||||||
@@ -1427,24 +1424,6 @@
|
|||||||
"logoutError": "Error logging out",
|
"logoutError": "Error logging out",
|
||||||
"signingAs": "Signed in as",
|
"signingAs": "Signed in as",
|
||||||
"serverAdmin": "Server Admin",
|
"serverAdmin": "Server Admin",
|
||||||
"promoteServerAdmin": "Promote to Server admin",
|
|
||||||
"promoteServerAdminTitle": "Promote to Server Admin",
|
|
||||||
"promoteServerAdminQuestion": "Are you sure you want to promote {selectedUser} to server admin?",
|
|
||||||
"promoteServerAdminMessage": "Server admins have the highest privileges and can manage the server.",
|
|
||||||
"promoteServerAdminWarning": "This can be undone at any time by demoting the user.",
|
|
||||||
"promoteServerAdminConfirm": "Promote to Server Admin",
|
|
||||||
"promoteServerAdminSuccess": "User Promoted",
|
|
||||||
"promoteServerAdminSuccessDescription": "{selectedUser} is now a server admin.",
|
|
||||||
"promoteServerAdminError": "Failed to promote user",
|
|
||||||
"demoteServerAdmin": "Demote from Server admin",
|
|
||||||
"demoteServerAdminTitle": "Demote from Server Admin",
|
|
||||||
"demoteServerAdminQuestion": "Are you sure you want to demote {selectedUser} from server admin?",
|
|
||||||
"demoteServerAdminMessage": "{selectedUser} will lose all server admin privileges.",
|
|
||||||
"demoteServerAdminWarning": "This can be undone at any time by promoting the user.",
|
|
||||||
"demoteServerAdminConfirm": "Demote from server admin",
|
|
||||||
"demoteServerAdminSuccess": "User demoted",
|
|
||||||
"demoteServerAdminSuccessDescription": "{selectedUser} is no longer a server admin.",
|
|
||||||
"demoteServerAdminError": "Failed to demote user",
|
|
||||||
"managedSelfhosted": "Managed Self-Hosted",
|
"managedSelfhosted": "Managed Self-Hosted",
|
||||||
"otpEnable": "Enable Two-factor",
|
"otpEnable": "Enable Two-factor",
|
||||||
"otpDisable": "Disable Two-factor",
|
"otpDisable": "Disable Two-factor",
|
||||||
@@ -2116,10 +2095,9 @@
|
|||||||
"resourceBudgetSettings": "Budget",
|
"resourceBudgetSettings": "Budget",
|
||||||
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
|
"resourceBudgetSettingsDescription": "Configure how this AI gateway restricts usage based on spending or token limits",
|
||||||
"sidebarApiKeys": "API Keys",
|
"sidebarApiKeys": "API Keys",
|
||||||
"sidebarOrgs": "Organizations",
|
|
||||||
"sidebarProvisioning": "Provisioning",
|
"sidebarProvisioning": "Provisioning",
|
||||||
"sidebarSettings": "Settings",
|
"sidebarSettings": "Settings",
|
||||||
"sidebarAllUsers": "Users",
|
"sidebarAllUsers": "All Users",
|
||||||
"sidebarIdentityProviders": "Identity Providers",
|
"sidebarIdentityProviders": "Identity Providers",
|
||||||
"sidebarLicense": "License",
|
"sidebarLicense": "License",
|
||||||
"sidebarClients": "Clients",
|
"sidebarClients": "Clients",
|
||||||
@@ -3499,7 +3477,8 @@
|
|||||||
},
|
},
|
||||||
"priority": "Priority",
|
"priority": "Priority",
|
||||||
"priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.",
|
"priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.",
|
||||||
"instanceName": "Instance Name",
|
"instanceName": "Server ID",
|
||||||
|
"clearInstanceName": "Reset Server Association",
|
||||||
"pathMatchModalTitle": "Configure Path Matching",
|
"pathMatchModalTitle": "Configure Path Matching",
|
||||||
"pathMatchModalDescription": "Set up how incoming requests should be matched based on their path.",
|
"pathMatchModalDescription": "Set up how incoming requests should be matched based on their path.",
|
||||||
"pathMatchType": "Match Type",
|
"pathMatchType": "Match Type",
|
||||||
@@ -3996,12 +3975,11 @@
|
|||||||
"disconnected": "Disconnected",
|
"disconnected": "Disconnected",
|
||||||
"approvalsEmptyStateTitle": "Device Approvals Not Enabled",
|
"approvalsEmptyStateTitle": "Device Approvals Not Enabled",
|
||||||
"approvalsEmptyStateDescription": "Enable device approvals for roles to require admin approval before users can connect new devices.",
|
"approvalsEmptyStateDescription": "Enable device approvals for roles to require admin approval before users can connect new devices.",
|
||||||
"approvalsEmptyStateHowToTitle": "How to Enable",
|
|
||||||
"approvalsEmptyStateStep1Title": "Go to Roles",
|
"approvalsEmptyStateStep1Title": "Go to Roles",
|
||||||
"approvalsEmptyStateStep1Description": "Navigate to your organization's roles settings to configure device approvals.",
|
"approvalsEmptyStateStep1Description": "Navigate to your organization's roles settings to configure device approvals.",
|
||||||
"approvalsEmptyStateStep2Title": "Enable Device Approvals",
|
"approvalsEmptyStateStep2Title": "Enable Device Approvals",
|
||||||
"approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.",
|
"approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.",
|
||||||
"approvalsEmptyStatePreviewDescription": "When enabled, pending device requests will appear here for review.",
|
"approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review",
|
||||||
"approvalsEmptyStateButtonText": "Manage Roles",
|
"approvalsEmptyStateButtonText": "Manage Roles",
|
||||||
"domainErrorTitle": "We are having trouble verifying your domain",
|
"domainErrorTitle": "We are having trouble verifying your domain",
|
||||||
"idpAdminAutoProvisionPoliciesTabHint": "Configure role mapping and organization policies on the <policiesTabLink>Auto Provision Settings</policiesTabLink> tab.",
|
"idpAdminAutoProvisionPoliciesTabHint": "Configure role mapping and organization policies on the <policiesTabLink>Auto Provision Settings</policiesTabLink> tab.",
|
||||||
@@ -4276,11 +4254,6 @@
|
|||||||
"resourceLauncherViewAsAdmin": "View as Admin",
|
"resourceLauncherViewAsAdmin": "View as Admin",
|
||||||
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
"resourceLauncherResourceDetailsDescription": "Connection information and status for this resource.",
|
||||||
"resourceLauncherResourceDetails": "Resource Details",
|
"resourceLauncherResourceDetails": "Resource Details",
|
||||||
"resourceLauncherSitesDescription": "The resource is accessible via the following sites.",
|
|
||||||
"resourceLauncherViewSiteAsAdmin": "View Site as Admin",
|
|
||||||
"resourceLauncherFilterBySite": "Filter by Site",
|
|
||||||
"resourceLauncherSshCommand": "SSH Command",
|
|
||||||
"resourceLauncherSshCommandDescription": "Use the Pangolin CLI to open an SSH session to this resource.",
|
|
||||||
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
"resourceLauncherAuthMethodsDescription": "Authentication methods enabled for this resource.",
|
||||||
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
"resourceLauncherPrivateClientRequired": "Connect with a client on your device to access this resource privately.",
|
||||||
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
"resourceLauncherPrivateClientRequiredTitle": "Client Connection Required",
|
||||||
|
|||||||
@@ -34,11 +34,6 @@ const nextConfig: NextConfig = {
|
|||||||
source: "/:orgId/settings/resources/client/:path*",
|
source: "/:orgId/settings/resources/client/:path*",
|
||||||
destination: "/:orgId/settings/resources/private/:path*",
|
destination: "/:orgId/settings/resources/private/:path*",
|
||||||
permanent: true
|
permanent: true
|
||||||
},
|
|
||||||
{
|
|
||||||
source: "/:orgId/settings/access/users/:userId/access-controls",
|
|
||||||
destination: "/:orgId/settings/access/users/:userId/general",
|
|
||||||
permanent: false
|
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+56
@@ -50,6 +50,7 @@
|
|||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"acme-client": "^5.4.0",
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.20.0",
|
"axios": "1.20.0",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
"cors": "2.8.6",
|
"cors": "2.8.6",
|
||||||
"crypto-js": "4.2.0",
|
"crypto-js": "4.2.0",
|
||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
|
"dns-packet": "^5.6.1",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.7.0",
|
"express-rate-limit": "8.7.0",
|
||||||
@@ -124,6 +126,7 @@
|
|||||||
"@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/dns-packet": "^5.6.5",
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/express-session": "1.19.0",
|
"@types/express-session": "1.19.0",
|
||||||
"@types/jmespath": "0.15.2",
|
"@types/jmespath": "0.15.2",
|
||||||
@@ -2445,6 +2448,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@leichtgewicht/ip-codec": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@levischuck/tiny-cbor": {
|
"node_modules/@levischuck/tiny-cbor": {
|
||||||
"version": "0.2.11",
|
"version": "0.2.11",
|
||||||
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
"resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz",
|
||||||
@@ -6764,6 +6773,16 @@
|
|||||||
"@types/d3-selection": "*"
|
"@types/d3-selection": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/dns-packet": {
|
||||||
|
"version": "5.6.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/dns-packet/-/dns-packet-5.6.5.tgz",
|
||||||
|
"integrity": "sha512-qXOC7XLOEe43ehtWJCMnQXvgcIpv6rPmQ1jXT98Ad8A3TB1Ue50jsCbSSSyuazScEuZ/Q026vHbrOTVkmwA+7Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/esrecurse": {
|
"node_modules/@types/esrecurse": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||||
@@ -7435,6 +7454,22 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/acme-client": {
|
||||||
|
"version": "5.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/acme-client/-/acme-client-5.4.0.tgz",
|
||||||
|
"integrity": "sha512-mORqg60S8iML6XSmVjqjGHJkINrCGLMj2QvDmFzI9vIlv1RGlyjmw3nrzaINJjkNsYXC41XhhD5pfy7CtuGcbA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@peculiar/x509": "^1.11.0",
|
||||||
|
"asn1js": "^3.0.5",
|
||||||
|
"axios": "^1.7.2",
|
||||||
|
"debug": "^4.3.5",
|
||||||
|
"node-forge": "^1.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
@@ -9261,6 +9296,18 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dns-packet": {
|
||||||
|
"version": "5.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
|
||||||
|
"integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@leichtgewicht/ip-codec": "^2.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/doctrine": {
|
"node_modules/doctrine": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||||
@@ -13140,6 +13187,15 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-forge": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ==",
|
||||||
|
"license": "(BSD-3-Clause OR GPL-2.0)",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.54",
|
"version": "2.0.54",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
|
||||||
|
|||||||
+4
-1
@@ -73,6 +73,7 @@
|
|||||||
"@xterm/addon-fit": "^0.11.0",
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
"@xterm/addon-web-links": "^0.12.0",
|
"@xterm/addon-web-links": "^0.12.0",
|
||||||
"@xterm/xterm": "^6.0.0",
|
"@xterm/xterm": "^6.0.0",
|
||||||
|
"acme-client": "^5.4.0",
|
||||||
"arctic": "3.7.0",
|
"arctic": "3.7.0",
|
||||||
"axios": "1.20.0",
|
"axios": "1.20.0",
|
||||||
"better-sqlite3": "11.9.1",
|
"better-sqlite3": "11.9.1",
|
||||||
@@ -84,6 +85,7 @@
|
|||||||
"cors": "2.8.6",
|
"cors": "2.8.6",
|
||||||
"crypto-js": "4.2.0",
|
"crypto-js": "4.2.0",
|
||||||
"d3": "7.9.0",
|
"d3": "7.9.0",
|
||||||
|
"dns-packet": "^5.6.1",
|
||||||
"drizzle-orm": "0.45.2",
|
"drizzle-orm": "0.45.2",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"express-rate-limit": "8.7.0",
|
"express-rate-limit": "8.7.0",
|
||||||
@@ -96,7 +98,6 @@
|
|||||||
"jmespath": "0.16.0",
|
"jmespath": "0.16.0",
|
||||||
"js-yaml": "5.4.1",
|
"js-yaml": "5.4.1",
|
||||||
"jsonwebtoken": "9.0.3",
|
"jsonwebtoken": "9.0.3",
|
||||||
"lru-cache": "11.5.2",
|
|
||||||
"lucide-react": "1.38.0",
|
"lucide-react": "1.38.0",
|
||||||
"maxmind": "5.0.7",
|
"maxmind": "5.0.7",
|
||||||
"moment": "2.30.1",
|
"moment": "2.30.1",
|
||||||
@@ -104,6 +105,7 @@
|
|||||||
"next-intl": "4.14.1",
|
"next-intl": "4.14.1",
|
||||||
"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",
|
||||||
"nodemailer": "9.1.0",
|
"nodemailer": "9.1.0",
|
||||||
"oslo": "1.2.1",
|
"oslo": "1.2.1",
|
||||||
"pg": "8.23.0",
|
"pg": "8.23.0",
|
||||||
@@ -147,6 +149,7 @@
|
|||||||
"@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/dns-packet": "^5.6.5",
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/express-session": "1.19.0",
|
"@types/express-session": "1.19.0",
|
||||||
"@types/jmespath": "0.15.2",
|
"@types/jmespath": "0.15.2",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 410 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 802 KiB After Width: | Height: | Size: 800 KiB |
@@ -0,0 +1,8 @@
|
|||||||
|
export async function startCertificateManager() {
|
||||||
|
// No-op: ACME certificate generation/management is only available in
|
||||||
|
// builds that include the private/enterprise feature set.
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopCertificateManager() {
|
||||||
|
// No-op counterpart to startCertificateManager.
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export async function startDnsServer() {
|
||||||
|
// No-op: the authoritative DNS server is only available in builds
|
||||||
|
// that include the private/enterprise feature set.
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopDnsServer() {
|
||||||
|
// No-op counterpart to startDnsServer.
|
||||||
|
}
|
||||||
@@ -25,6 +25,8 @@ import { setHostMeta } from "@server/lib/hostMeta";
|
|||||||
import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
|
import { TraefikConfigManager } from "@server/lib/traefik/TraefikConfigManager";
|
||||||
import { initCleanup } from "#dynamic/cleanup";
|
import { initCleanup } from "#dynamic/cleanup";
|
||||||
import { startSchedulers } from "#dynamic/startSchedulers";
|
import { startSchedulers } from "#dynamic/startSchedulers";
|
||||||
|
import { startDnsServer } from "#dynamic/dns";
|
||||||
|
import { startCertificateManager } from "#dynamic/certificates";
|
||||||
import license from "#dynamic/license/license";
|
import license from "#dynamic/license/license";
|
||||||
import { fetchServerIp } from "@server/lib/serverIpService";
|
import { fetchServerIp } from "@server/lib/serverIpService";
|
||||||
import { initAiModelCatalog } from "@server/lib/aiModelCatalog";
|
import { initAiModelCatalog } from "@server/lib/aiModelCatalog";
|
||||||
@@ -45,6 +47,10 @@ async function startServers() {
|
|||||||
|
|
||||||
startSchedulers();
|
startSchedulers();
|
||||||
|
|
||||||
|
await startDnsServer();
|
||||||
|
|
||||||
|
await startCertificateManager();
|
||||||
|
|
||||||
// Start all servers
|
// Start all servers
|
||||||
const apiServer = createApiServer();
|
const apiServer = createApiServer();
|
||||||
const internalServer = createInternalServer();
|
const internalServer = createInternalServer();
|
||||||
|
|||||||
+8
-2
@@ -1,7 +1,13 @@
|
|||||||
|
import NodeCache from "node-cache";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { createLocalCache } from "@server/lib/createLocalCache";
|
|
||||||
|
|
||||||
export const localCache = createLocalCache();
|
// Create local cache with maxKeys limit to prevent memory leaks
|
||||||
|
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||||
|
export const localCache = new NodeCache({
|
||||||
|
stdTTL: 3600,
|
||||||
|
checkperiod: 120,
|
||||||
|
maxKeys: 10000
|
||||||
|
});
|
||||||
|
|
||||||
// Log cache statistics periodically for monitoring
|
// Log cache statistics periodically for monitoring
|
||||||
// setInterval(() => {
|
// setInterval(() => {
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import { LRUCache } from "lru-cache";
|
|
||||||
|
|
||||||
const DEFAULT_MAX_KEYS = 10000;
|
|
||||||
const DEFAULT_TTL_MS = 3600 * 1000;
|
|
||||||
|
|
||||||
export type LocalCache = {
|
|
||||||
get<T>(key: string): T | undefined;
|
|
||||||
set(key: string, value: unknown, ttlSeconds?: number): boolean;
|
|
||||||
del(key: string | string[]): number;
|
|
||||||
has(key: string): boolean;
|
|
||||||
keys(): string[];
|
|
||||||
flushAll(): void;
|
|
||||||
getStats(): { keys: number };
|
|
||||||
getTtl(key: string): number | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createLocalCache(max = DEFAULT_MAX_KEYS): LocalCache {
|
|
||||||
const lru = new LRUCache<string, {}>({
|
|
||||||
max,
|
|
||||||
ttl: DEFAULT_TTL_MS,
|
|
||||||
updateAgeOnGet: false
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
get<T>(key: string): T | undefined {
|
|
||||||
return lru.get(key) as T | undefined;
|
|
||||||
},
|
|
||||||
|
|
||||||
set(key: string, value: unknown, ttlSeconds?: number): boolean {
|
|
||||||
const stored = value as {};
|
|
||||||
if (ttlSeconds === undefined) {
|
|
||||||
lru.set(key, stored);
|
|
||||||
} else if (ttlSeconds === 0) {
|
|
||||||
lru.set(key, stored, { ttl: 0 });
|
|
||||||
} else {
|
|
||||||
lru.set(key, stored, { ttl: ttlSeconds * 1000 });
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
del(key: string | string[]): number {
|
|
||||||
const keys = Array.isArray(key) ? key : [key];
|
|
||||||
let deleted = 0;
|
|
||||||
for (const k of keys) {
|
|
||||||
if (lru.delete(k)) {
|
|
||||||
deleted++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return deleted;
|
|
||||||
},
|
|
||||||
|
|
||||||
has(key: string): boolean {
|
|
||||||
return lru.has(key);
|
|
||||||
},
|
|
||||||
|
|
||||||
keys(): string[] {
|
|
||||||
return [...lru.keys()];
|
|
||||||
},
|
|
||||||
|
|
||||||
flushAll(): void {
|
|
||||||
lru.clear();
|
|
||||||
},
|
|
||||||
|
|
||||||
getStats(): { keys: number } {
|
|
||||||
return { keys: lru.size };
|
|
||||||
},
|
|
||||||
|
|
||||||
getTtl(key: string): number | undefined {
|
|
||||||
if (!lru.has(key)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const remaining = lru.getRemainingTTL(key);
|
|
||||||
if (!Number.isFinite(remaining)) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return Date.now() + remaining;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function createCname(domainId: string, baseDomain: string) {}
|
||||||
|
|
||||||
|
export function createNs() {}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Tracks, per process lifetime, whether a given exit node has ever checked in
|
||||||
|
// (called /gerbil/get-config) since this Pangolin instance started. This lets
|
||||||
|
// callers distinguish "gerbil hasn't come up yet" (expected briefly after a
|
||||||
|
// restart, since gerbil depends on pangolin's container starting first) from
|
||||||
|
// "gerbil was reachable and now isn't" (a real problem worth an error log).
|
||||||
|
const checkedInExitNodeIds = new Set<number>();
|
||||||
|
|
||||||
|
export function markExitNodeCheckedIn(exitNodeId: number): void {
|
||||||
|
checkedInExitNodeIds.add(exitNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasExitNodeCheckedIn(exitNodeId: number): boolean {
|
||||||
|
return checkedInExitNodeIds.has(exitNodeId);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { ExitNode } from "@server/db";
|
import { ExitNode } from "@server/db";
|
||||||
|
import { hasExitNodeCheckedIn } from "./exitNodeCheckIn";
|
||||||
|
|
||||||
interface ExitNodeRequest {
|
interface ExitNodeRequest {
|
||||||
remoteType?: string;
|
remoteType?: string;
|
||||||
@@ -72,13 +73,19 @@ export async function sendToExitNode(
|
|||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error)) {
|
const message = axios.isAxiosError(error)
|
||||||
logger.error(
|
? `Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}`
|
||||||
`Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}`
|
: `Error making ${method} request for exit node at ${exitNode.reachableAt}: ${error}`;
|
||||||
);
|
|
||||||
|
// The exit node (gerbil) may still be starting up and not yet
|
||||||
|
// reachable. Until it has checked in at least once, log this at a
|
||||||
|
// lower level since it's expected; once it has checked in, a
|
||||||
|
// connection failure is a real problem.
|
||||||
|
if (hasExitNodeCheckedIn(exitNode.exitNodeId)) {
|
||||||
|
logger.error(message);
|
||||||
} else {
|
} else {
|
||||||
logger.error(
|
logger.warn(
|
||||||
`Error making ${method} request for exit node at ${exitNode.reachableAt}: ${error}`
|
`${message} (exit node has not checked in yet since startup, this is expected briefly)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export * from "./exitNodes";
|
export * from "./exitNodes";
|
||||||
export * from "./exitNodeComms";
|
export * from "./exitNodeComms";
|
||||||
|
export * from "./exitNodeCheckIn";
|
||||||
export * from "./subnet";
|
export * from "./subnet";
|
||||||
export * from "./getCurrentExitNodeId";
|
export * from "./getCurrentExitNodeId";
|
||||||
export * from "./calculateExitNodeWeight";
|
export * from "./calculateExitNodeWeight";
|
||||||
|
|||||||
@@ -493,23 +493,6 @@ export const configSchema = z
|
|||||||
.prefault({})
|
.prefault({})
|
||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
|
||||||
dns: z
|
|
||||||
.object({
|
|
||||||
nameservers: z
|
|
||||||
.array(z.string().optional().optional())
|
|
||||||
.optional()
|
|
||||||
.default([
|
|
||||||
"ns1.pangolin.net",
|
|
||||||
"ns2.pangolin.net",
|
|
||||||
"ns3.pangolin.net"
|
|
||||||
]),
|
|
||||||
cname_extension: z
|
|
||||||
.string()
|
|
||||||
.optional()
|
|
||||||
.default("cname.pangolin.net")
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
.prefault({})
|
.prefault({})
|
||||||
})
|
})
|
||||||
.refine(
|
.refine(
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import logger from "@server/logger";
|
||||||
|
|
||||||
|
export async function withRetry<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options: {
|
||||||
|
retries?: number;
|
||||||
|
baseDelayMs?: number;
|
||||||
|
label?: string;
|
||||||
|
// Called with each caught error to decide whether it's worth
|
||||||
|
// retrying. Defaults to retrying everything (existing behavior) -
|
||||||
|
// pass this to exclude errors that are known to be permanent (e.g.
|
||||||
|
// an upstream rate limit or validation rejection) rather than
|
||||||
|
// transient, so they fail fast instead of wasting retry attempts.
|
||||||
|
shouldRetry?: (error: unknown) => boolean;
|
||||||
|
} = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
retries = 3,
|
||||||
|
baseDelayMs = 250,
|
||||||
|
label = "operation",
|
||||||
|
shouldRetry = () => true
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let attempt = 0;
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
attempt++;
|
||||||
|
if (attempt > retries || !shouldRetry(error)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff with jitter so retries don't all land at once.
|
||||||
|
const delay =
|
||||||
|
baseDelayMs * 2 ** (attempt - 1) * (0.5 + Math.random());
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`${label} failed (attempt ${attempt}/${retries + 1}), retrying in ${delay.toFixed(0)}ms`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounds an operation that has no timeout of its own (e.g. acme-client's
|
||||||
|
// axios instance never sets one, so a stalled TCP connection to the ACME
|
||||||
|
// server hangs forever instead of erroring). Without this, a single hung
|
||||||
|
// call can leave its caller's promise permanently unsettled - fatal for
|
||||||
|
// code that gates future work on that promise resolving, like the
|
||||||
|
// scheduler's runExclusive() waiting on a batch's Promise.all.
|
||||||
|
export async function withTimeout<T>(
|
||||||
|
promise: Promise<T>,
|
||||||
|
ms: number,
|
||||||
|
label = "operation"
|
||||||
|
): Promise<T> {
|
||||||
|
let timer: NodeJS.Timeout;
|
||||||
|
const timeout = new Promise<never>((_, reject) => {
|
||||||
|
timer = setTimeout(
|
||||||
|
() => reject(new Error(`${label} timed out after ${ms}ms`)),
|
||||||
|
ms
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Promise.race([promise, timeout]);
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,10 @@ import * as yaml from "js-yaml";
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { db, exitNodes } from "@server/db";
|
import { db, exitNodes } from "@server/db";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { getCurrentExitNodeId } from "@server/lib/exitNodes";
|
import {
|
||||||
|
getCurrentExitNodeId,
|
||||||
|
hasExitNodeCheckedIn
|
||||||
|
} from "@server/lib/exitNodes";
|
||||||
import { getTraefikConfig } from "#dynamic/lib/traefik";
|
import { getTraefikConfig } from "#dynamic/lib/traefik";
|
||||||
import { getValidCertificatesForDomains } from "@server/lib/certificates";
|
import { getValidCertificatesForDomains } from "@server/lib/certificates";
|
||||||
import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
import { sendToExitNode } from "#dynamic/lib/exitNodes";
|
||||||
@@ -341,10 +344,6 @@ export class TraefikConfigManager {
|
|||||||
|
|
||||||
const { domains, traefikConfig } = getTraefikConfig;
|
const { domains, traefikConfig } = getTraefikConfig;
|
||||||
|
|
||||||
// Add static domains from config
|
|
||||||
// const staticDomains = [config.getRawConfig().app.dashboard_url];
|
|
||||||
// staticDomains.forEach((domain) => domains.add(domain));
|
|
||||||
|
|
||||||
// Log if domains changed
|
// Log if domains changed
|
||||||
if (
|
if (
|
||||||
this.lastActiveDomains.size !== domains.size ||
|
this.lastActiveDomains.size !== domains.size ||
|
||||||
@@ -358,7 +357,7 @@ export class TraefikConfigManager {
|
|||||||
this.lastActiveDomains = new Set(domains);
|
this.lastActiveDomains = new Set(domains);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.USE_PANGOLIN_DNS === "true" && build != "oss") {
|
if (process.env.CERT_MODE === "pangolin" && build != "oss") {
|
||||||
// Scan current local certificate state
|
// Scan current local certificate state
|
||||||
this.lastLocalCertificateState =
|
this.lastLocalCertificateState =
|
||||||
await this.scanLocalCertificateState();
|
await this.scanLocalCertificateState();
|
||||||
@@ -439,13 +438,13 @@ export class TraefikConfigManager {
|
|||||||
// Always ensure all existing certificates (including wildcards) are in the config
|
// Always ensure all existing certificates (including wildcards) are in the config
|
||||||
await this.updateDynamicConfigFromLocalCerts(domains);
|
await this.updateDynamicConfigFromLocalCerts(domains);
|
||||||
} else {
|
} else {
|
||||||
const timeSinceLastFetch = this.lastCertificateFetch
|
// const timeSinceLastFetch = this.lastCertificateFetch
|
||||||
? Math.round(
|
// ? Math.round(
|
||||||
(Date.now() -
|
// (Date.now() -
|
||||||
this.lastCertificateFetch.getTime()) /
|
// this.lastCertificateFetch.getTime()) /
|
||||||
(1000 * 60)
|
// (1000 * 60)
|
||||||
)
|
// )
|
||||||
: 0;
|
// : 0;
|
||||||
|
|
||||||
// logger.debug(
|
// logger.debug(
|
||||||
// `Skipping certificate fetch - no changes detected and within 24-hour window (last fetch: ${timeSinceLastFetch} minutes ago)`
|
// `Skipping certificate fetch - no changes detected and within 24-hour window (last fetch: ${timeSinceLastFetch} minutes ago)`
|
||||||
@@ -466,33 +465,52 @@ export class TraefikConfigManager {
|
|||||||
await this.writeTraefikDynamicConfig(traefikConfig);
|
await this.writeTraefikDynamicConfig(traefikConfig);
|
||||||
|
|
||||||
// Send domains to SNI proxy
|
// Send domains to SNI proxy
|
||||||
|
let exitNodeForSni: typeof exitNodes.$inferSelect | undefined;
|
||||||
try {
|
try {
|
||||||
let exitNode;
|
|
||||||
if (config.getRawConfig().gerbil.exit_node_name) {
|
if (config.getRawConfig().gerbil.exit_node_name) {
|
||||||
const exitNodeName =
|
const exitNodeName =
|
||||||
config.getRawConfig().gerbil.exit_node_name!;
|
config.getRawConfig().gerbil.exit_node_name!;
|
||||||
[exitNode] = await db
|
[exitNodeForSni] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(exitNodes)
|
.from(exitNodes)
|
||||||
.where(eq(exitNodes.name, exitNodeName))
|
.where(eq(exitNodes.name, exitNodeName))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
} else {
|
} else {
|
||||||
[exitNode] = await db.select().from(exitNodes).limit(1);
|
[exitNodeForSni] = await db
|
||||||
|
.select()
|
||||||
|
.from(exitNodes)
|
||||||
|
.limit(1);
|
||||||
}
|
}
|
||||||
if (exitNode) {
|
if (exitNodeForSni) {
|
||||||
await sendToExitNode(exitNode, {
|
await sendToExitNode(exitNodeForSni, {
|
||||||
localPath: "/update-local-snis",
|
localPath: "/update-local-snis",
|
||||||
method: "POST",
|
method: "POST",
|
||||||
data: { fullDomains: Array.from(domains) }
|
data: {
|
||||||
|
fullDomains: [
|
||||||
|
...Array.from(domains),
|
||||||
|
...config.getRawConfig().traefik.static_domains
|
||||||
|
]
|
||||||
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
logger.error(
|
logger.warn(
|
||||||
"No exit node found. Has gerbil registered yet?"
|
"No exit node found. Has gerbil registered yet?"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// sendToExitNode already logs the underlying connection
|
||||||
|
// error at the appropriate level (warn before the exit node
|
||||||
|
// has checked in since startup, error after), so avoid
|
||||||
|
// double-logging it as an error here.
|
||||||
|
if (
|
||||||
|
exitNodeForSni &&
|
||||||
|
!hasExitNodeCheckedIn(exitNodeForSni.exitNodeId)
|
||||||
|
) {
|
||||||
|
logger.warn("Failed to post domains to SNI proxy:", err);
|
||||||
|
} else {
|
||||||
logger.error("Failed to post domains to SNI proxy:", err);
|
logger.error("Failed to post domains to SNI proxy:", err);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update active domains tracking
|
// Update active domains tracking
|
||||||
this.activeDomains = domains;
|
this.activeDomains = domains;
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
/**
|
// Build the Host()/HostRegexp() Traefik rule for a resource's domain.
|
||||||
* Build the Host()/HostRegexp() Traefik rule for a resource's domain.
|
// Wildcard resources match any single subdomain via HostRegexp.
|
||||||
* Wildcard resources match any single subdomain via HostRegexp.
|
|
||||||
*/
|
|
||||||
export function buildHostRule(
|
export function buildHostRule(
|
||||||
fullDomain: string,
|
fullDomain: string,
|
||||||
wildcard?: boolean | null
|
wildcard?: boolean | null
|
||||||
@@ -14,10 +12,8 @@ export function buildHostRule(
|
|||||||
return `Host(\`${fullDomain}\`)`;
|
return `Host(\`${fullDomain}\`)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Append a path-matching clause to a Traefik rule based on the resource's
|
||||||
* Append a path-matching clause to a Traefik rule based on the resource's
|
// configured path and pathMatchType.
|
||||||
* configured path and pathMatchType.
|
|
||||||
*/
|
|
||||||
export function appendPathMatch(
|
export function appendPathMatch(
|
||||||
rule: string,
|
rule: string,
|
||||||
path: string | null | undefined,
|
path: string | null | undefined,
|
||||||
@@ -40,10 +36,8 @@ export function appendPathMatch(
|
|||||||
return rule;
|
return rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Compute the router priority for a resource, favoring an explicit override
|
||||||
* Compute the router priority for a resource, favoring an explicit override
|
// and otherwise deriving it from the path match specificity.
|
||||||
* and otherwise deriving it from the path match specificity.
|
|
||||||
*/
|
|
||||||
export function computeRoutePriority(
|
export function computeRoutePriority(
|
||||||
priority: number | null | undefined,
|
priority: number | null | undefined,
|
||||||
path: string | null | undefined,
|
path: string | null | undefined,
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
/*
|
||||||
|
* 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 {
|
||||||
|
startCertificateManager,
|
||||||
|
stopCertificateManager
|
||||||
|
} from "./lib/certificates";
|
||||||
@@ -20,6 +20,8 @@ import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth"
|
|||||||
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
|
import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator";
|
||||||
import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement";
|
import { shutdownUsageRecorder } from "@server/lib/aiBudgetEnforcement";
|
||||||
import { shutdownAiSessionLogger } from "@server/routers/aiGateway/logAiSession";
|
import { shutdownAiSessionLogger } from "@server/routers/aiGateway/logAiSession";
|
||||||
|
import { stopDnsServer } from "./dns";
|
||||||
|
import { stopCertificateManager } from "./certificates";
|
||||||
|
|
||||||
async function cleanup() {
|
async function cleanup() {
|
||||||
await stopPingAccumulator();
|
await stopPingAccumulator();
|
||||||
@@ -31,6 +33,8 @@ async function cleanup() {
|
|||||||
await rateLimitService.cleanup();
|
await rateLimitService.cleanup();
|
||||||
await wsCleanup();
|
await wsCleanup();
|
||||||
await logStreamingManager.shutdown();
|
await logStreamingManager.shutdown();
|
||||||
|
await stopDnsServer();
|
||||||
|
await stopCertificateManager();
|
||||||
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* 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 { AuthoritativeDNSServer } from "#private/lib/dns";
|
||||||
|
import { privateConfig } from "#private/lib/config";
|
||||||
|
|
||||||
|
let dnsServer: AuthoritativeDNSServer | undefined;
|
||||||
|
|
||||||
|
export async function startDnsServer() {
|
||||||
|
const dnsConfig = privateConfig.getRawPrivateConfig().dns;
|
||||||
|
if (!dnsConfig || !dnsConfig.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheOptions = {
|
||||||
|
stdTTL: 300, // 5 minutes default TTL
|
||||||
|
checkperiod: 60, // Check for expired keys every 60 seconds
|
||||||
|
useClones: false // Better performance
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create DNS server
|
||||||
|
dnsServer = new AuthoritativeDNSServer(dnsConfig.listen_port, cacheOptions);
|
||||||
|
|
||||||
|
await dnsServer.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopDnsServer() {
|
||||||
|
if (!dnsServer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await dnsServer.stop();
|
||||||
|
dnsServer = undefined;
|
||||||
|
}
|
||||||
+25
-15
@@ -11,11 +11,17 @@
|
|||||||
* This file is not licensed under the AGPLv3.
|
* This file is not licensed under the AGPLv3.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import NodeCache from "node-cache";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { createLocalCache } from "@server/lib/createLocalCache";
|
import { redisManager, regionalRedisManager } from "#private/lib/redis";
|
||||||
import { redisManager, regionalRedisManager } from "@server/private/lib/redis";
|
|
||||||
|
|
||||||
export const localCache = createLocalCache();
|
// Create local cache with maxKeys limit to prevent memory leaks
|
||||||
|
// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient
|
||||||
|
export const localCache = new NodeCache({
|
||||||
|
stdTTL: 3600,
|
||||||
|
checkperiod: 120,
|
||||||
|
maxKeys: 10000
|
||||||
|
});
|
||||||
|
|
||||||
// Log cache statistics periodically for monitoring
|
// Log cache statistics periodically for monitoring
|
||||||
// setInterval(() => {
|
// setInterval(() => {
|
||||||
@@ -91,11 +97,11 @@ class AdaptiveCache {
|
|||||||
const value = await redisManager.get(key);
|
const value = await redisManager.get(key);
|
||||||
|
|
||||||
if (value !== null) {
|
if (value !== null) {
|
||||||
logger.debug(`Cache hit in Redis: ${key}`);
|
// logger.debug(`Cache hit in Redis: ${key}`);
|
||||||
return JSON.parse(value) as T;
|
return JSON.parse(value) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.debug(`Cache miss in Redis: ${key}`);
|
// logger.debug(`Cache miss in Redis: ${key}`);
|
||||||
return undefined;
|
return undefined;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Redis get error for key ${key}:`, error);
|
logger.error(`Redis get error for key ${key}:`, error);
|
||||||
@@ -128,7 +134,7 @@ class AdaptiveCache {
|
|||||||
const success = await redisManager.del(k);
|
const success = await redisManager.del(k);
|
||||||
if (success) {
|
if (success) {
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
logger.debug(`Deleted key from Redis: ${k}`);
|
// logger.debug(`Deleted key from Redis: ${k}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,7 +161,7 @@ class AdaptiveCache {
|
|||||||
const success = localCache.del(k);
|
const success = localCache.del(k);
|
||||||
if (success > 0) {
|
if (success > 0) {
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
logger.debug(`Deleted key from local cache: ${k}`);
|
// logger.debug(`Deleted key from local cache: ${k}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +229,7 @@ class AdaptiveCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
localCache.flushAll();
|
localCache.flushAll();
|
||||||
logger.debug("Flushed local cache");
|
// logger.debug("Flushed local cache");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -295,11 +301,15 @@ export default cache;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Regional adaptive cache backed by the in-cluster Redis instance.
|
* Regional adaptive cache backed by the in-cluster Redis instance.
|
||||||
* Falls back to a local LRU cache when the regional Redis is unavailable.
|
* Falls back to a local NodeCache when the regional Redis is unavailable.
|
||||||
* Use this for data that is regional in nature (e.g. status history) so
|
* Use this for data that is regional in nature (e.g. status history) so
|
||||||
* reads are served from the same cluster the user is hitting.
|
* reads are served from the same cluster the user is hitting.
|
||||||
*/
|
*/
|
||||||
const regionalLocalCache = createLocalCache();
|
const regionalLocalCache = new NodeCache({
|
||||||
|
stdTTL: 3600,
|
||||||
|
checkperiod: 120,
|
||||||
|
maxKeys: 10000
|
||||||
|
});
|
||||||
|
|
||||||
class RegionalAdaptiveCache {
|
class RegionalAdaptiveCache {
|
||||||
private useRedis(): boolean {
|
private useRedis(): boolean {
|
||||||
@@ -322,7 +332,7 @@ class RegionalAdaptiveCache {
|
|||||||
redisTtl
|
redisTtl
|
||||||
);
|
);
|
||||||
if (success) {
|
if (success) {
|
||||||
logger.debug(`[regional] Set key in Redis: ${key}`);
|
// logger.debug(`[regional] Set key in Redis: ${key}`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -343,10 +353,10 @@ class RegionalAdaptiveCache {
|
|||||||
try {
|
try {
|
||||||
const value = await regionalRedisManager.get(key);
|
const value = await regionalRedisManager.get(key);
|
||||||
if (value !== null) {
|
if (value !== null) {
|
||||||
logger.debug(`[regional] Cache hit in Redis: ${key}`);
|
// logger.debug(`[regional] Cache hit in Redis: ${key}`);
|
||||||
return JSON.parse(value) as T;
|
return JSON.parse(value) as T;
|
||||||
}
|
}
|
||||||
logger.debug(`[regional] Cache miss in Redis: ${key}`);
|
// logger.debug(`[regional] Cache miss in Redis: ${key}`);
|
||||||
return undefined;
|
return undefined;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -375,7 +385,7 @@ class RegionalAdaptiveCache {
|
|||||||
const success = await regionalRedisManager.del(k);
|
const success = await regionalRedisManager.del(k);
|
||||||
if (success) {
|
if (success) {
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
logger.debug(`[regional] Deleted key from Redis: ${k}`);
|
// logger.debug(`[regional] Deleted key from Redis: ${k}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (deletedCount === keys.length) return deletedCount;
|
if (deletedCount === keys.length) return deletedCount;
|
||||||
@@ -390,7 +400,7 @@ class RegionalAdaptiveCache {
|
|||||||
const count = regionalLocalCache.del(k);
|
const count = regionalLocalCache.del(k);
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
deletedCount++;
|
deletedCount++;
|
||||||
logger.debug(`[regional] Deleted key from local cache: ${k}`);
|
// logger.debug(`[regional] Deleted key from local cache: ${k}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return deletedCount;
|
return deletedCount;
|
||||||
|
|||||||
@@ -0,0 +1,298 @@
|
|||||||
|
/*
|
||||||
|
* 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 * as acme from "acme-client";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import { eq } from "drizzle-orm/sql";
|
||||||
|
import { privateConfig as config } from "#private/lib/config";
|
||||||
|
import { DnsChallenge, db, dnsChallenge } from "@server/db";
|
||||||
|
import { withRetry } from "@server/lib/retry";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { acmeRateLimiter } from "./acmeRateLimiter";
|
||||||
|
|
||||||
|
// acme-client's own retry/backoff logging (429 retries, 5xx retries, each
|
||||||
|
// status-poll tick in waitForValidStatus) is a no-op by default - it only
|
||||||
|
// activates via DEBUG=acme-client or this call, neither of which was wired
|
||||||
|
// up. Without it, a cert silently retrying a Let's Encrypt rate limit for
|
||||||
|
// several minutes is indistinguishable in our logs from one that's actually
|
||||||
|
// hung, since our own logging only wraps the call, not what happens inside
|
||||||
|
// it. Must run before any AcmeClient method is called.
|
||||||
|
acme.setLogger((msg: string) => logger.info(`[acme-client] ${msg}`));
|
||||||
|
|
||||||
|
// acme-client's axios retry wrapper treats any response-less request error
|
||||||
|
// (timeout, connection reset, DNS blip reaching the ACME server) as
|
||||||
|
// retryable, but once its internal retries are exhausted it falls through to
|
||||||
|
// `validateStatus(response)` with `response` still undefined, throwing this
|
||||||
|
// uninformative TypeError instead of the real network error.
|
||||||
|
// https://github.com/publishlab/node-acme-client/blob/master/src/axios.js
|
||||||
|
function isUnresponsiveAcmeError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
error instanceof TypeError &&
|
||||||
|
error.message ===
|
||||||
|
"Cannot read properties of undefined (reading 'config')"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAcmeError(error: unknown): Error {
|
||||||
|
if (isUnresponsiveAcmeError(error)) {
|
||||||
|
return new Error(
|
||||||
|
"ACME server did not respond after repeated attempts (network error reaching the ACME endpoint)",
|
||||||
|
{ cause: error }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error : new Error(String(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AcmeClientManager {
|
||||||
|
private client: acme.Client | null = null;
|
||||||
|
private accountKey: string | null = null;
|
||||||
|
|
||||||
|
async initialize() {
|
||||||
|
try {
|
||||||
|
this.accountKey = await this.loadAccountKey();
|
||||||
|
|
||||||
|
this.client = new acme.Client({
|
||||||
|
directoryUrl: config.getRawConfig().acme!.acme_directory_url,
|
||||||
|
accountKey: this.accountKey
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to create account or get existing one
|
||||||
|
await this.client.createAccount({
|
||||||
|
termsOfServiceAgreed: true,
|
||||||
|
contact: [`mailto:${config.getRawConfig().acme!.contact_email}`]
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info("ACME client initialized successfully");
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to initialize ACME client:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadAccountKey(): Promise<string> {
|
||||||
|
const keyPath = config.getRawConfig().acme!.acme_account_key_path;
|
||||||
|
|
||||||
|
if (fs.existsSync(keyPath)) {
|
||||||
|
logger.info("Loading existing account key");
|
||||||
|
return fs.readFileSync(keyPath, "utf8");
|
||||||
|
} else {
|
||||||
|
logger.info("Generating new account key");
|
||||||
|
const privateKey = await acme.crypto.createPrivateKey();
|
||||||
|
const privateKeyString = privateKey.toString();
|
||||||
|
fs.writeFileSync(keyPath, privateKeyString);
|
||||||
|
return privateKeyString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getClient(): acme.Client {
|
||||||
|
if (!this.client) {
|
||||||
|
throw new Error("ACME client not initialized");
|
||||||
|
}
|
||||||
|
return this.client;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOrder(domain: string, wildcard: boolean = false): Promise<any> {
|
||||||
|
const client = this.getClient();
|
||||||
|
|
||||||
|
const identifiers = wildcard
|
||||||
|
? [
|
||||||
|
{ type: "dns", value: domain },
|
||||||
|
{ type: "dns", value: `*.${domain}` }
|
||||||
|
]
|
||||||
|
: [{ type: "dns", value: domain }];
|
||||||
|
|
||||||
|
await acmeRateLimiter.acquire();
|
||||||
|
const order = await client.createOrder({
|
||||||
|
identifiers
|
||||||
|
});
|
||||||
|
|
||||||
|
if (wildcard) {
|
||||||
|
logger.info(`Created wildcard order for domain: ${domain}`);
|
||||||
|
} else {
|
||||||
|
logger.info(`Created order for domain: ${domain}`);
|
||||||
|
}
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAuthorizations(order: any): Promise<any[]> {
|
||||||
|
const client = this.getClient();
|
||||||
|
await acmeRateLimiter.acquire();
|
||||||
|
return client.getAuthorizations(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDnsChallenge(
|
||||||
|
dnsChallenges: {
|
||||||
|
authz: any;
|
||||||
|
challenge: any;
|
||||||
|
}[]
|
||||||
|
): Promise<void> {
|
||||||
|
const client = this.getClient();
|
||||||
|
|
||||||
|
let challengeDomains: DnsChallenge[] = [];
|
||||||
|
|
||||||
|
for (const { authz, challenge } of dnsChallenges) {
|
||||||
|
const keyAuthorization =
|
||||||
|
await client.getChallengeKeyAuthorization(challenge);
|
||||||
|
|
||||||
|
// Extract the domain from authorization
|
||||||
|
const domain = authz.identifier.value;
|
||||||
|
|
||||||
|
// Store challenge in database for DNS server to pick up
|
||||||
|
challengeDomains = await withRetry(
|
||||||
|
() =>
|
||||||
|
db
|
||||||
|
.insert(dnsChallenge)
|
||||||
|
.values({
|
||||||
|
domain: domain,
|
||||||
|
token: challenge.token,
|
||||||
|
keyAuthorization,
|
||||||
|
createdAt: Math.floor(Date.now() / 1000),
|
||||||
|
expiresAt: Math.floor(
|
||||||
|
(Date.now() +
|
||||||
|
config.getRawConfig().acme!
|
||||||
|
.challenge_ttl_ms) /
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.returning(),
|
||||||
|
{ label: `insert dnsChallenge for domain ${domain}` }
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`DNS challenge stored for domain: ${domain} as token ${challenge.token} and keyAuthorization`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
const failedDomains: string[] = [];
|
||||||
|
|
||||||
|
for (const { authz, challenge } of dnsChallenges) {
|
||||||
|
const domain = authz.identifier.value;
|
||||||
|
const challengeDomain = `_acme-challenge.${domain}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// The ACME server occasionally has a transient network blip
|
||||||
|
// mid-sequence; retry the whole verify/complete/wait sequence
|
||||||
|
// rather than just the DNS challenge propagation wait, since
|
||||||
|
// these calls are safe to repeat against the ACME server.
|
||||||
|
await withRetry(
|
||||||
|
async () => {
|
||||||
|
// Verify challenge
|
||||||
|
await acmeRateLimiter.acquire();
|
||||||
|
await client.verifyChallenge(authz, challenge);
|
||||||
|
|
||||||
|
// Complete challenge
|
||||||
|
logger.info(
|
||||||
|
`Completing challenge for domain: ${challengeDomain}`
|
||||||
|
);
|
||||||
|
await acmeRateLimiter.acquire();
|
||||||
|
await client.completeChallenge(challenge);
|
||||||
|
|
||||||
|
// Wait for validation
|
||||||
|
logger.info(
|
||||||
|
`Waiting for challenge to be validated for domain: ${challengeDomain}...`
|
||||||
|
);
|
||||||
|
await acmeRateLimiter.acquire();
|
||||||
|
await client.waitForValidStatus(challenge);
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retries: 2,
|
||||||
|
baseDelayMs: 5000,
|
||||||
|
label: `ACME challenge completion for domain ${domain}`,
|
||||||
|
// Only retry the known network-blip crash - a
|
||||||
|
// genuine validation failure (e.g. challenge marked
|
||||||
|
// "invalid" because the DNS record wasn't found) is
|
||||||
|
// permanent and should fail immediately instead of
|
||||||
|
// burning Let's Encrypt's per-hostname failed-
|
||||||
|
// validation rate limit on retries that can't help.
|
||||||
|
shouldRetry: isUnresponsiveAcmeError
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(`Challenge completed for domain: ${domain}`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to complete challenge for domain ${domain}:`,
|
||||||
|
normalizeAcmeError(error)
|
||||||
|
);
|
||||||
|
failedDomains.push(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const challengeDomain of challengeDomains) {
|
||||||
|
await this.removeDnsChallenge(challengeDomain.dnsChallengeId);
|
||||||
|
logger.info(
|
||||||
|
`Removed DNS challenge for domain: ${challengeDomain.domain}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed dns-01 challenge leaves the order stuck in "pending" -
|
||||||
|
// finalizing it would just fail with a confusing ACME error, so
|
||||||
|
// stop here and let the caller mark the certificate as failed.
|
||||||
|
if (failedDomains.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`DNS-01 challenge validation failed for domain(s): ${failedDomains.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeDnsChallenge(dnsChallengeId: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
await withRetry(
|
||||||
|
() =>
|
||||||
|
db
|
||||||
|
.delete(dnsChallenge)
|
||||||
|
.where(eq(dnsChallenge.dnsChallengeId, dnsChallengeId)),
|
||||||
|
{ label: `delete dnsChallenge ${dnsChallengeId}` }
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to clean up DNS challenge for id ${dnsChallengeId}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async finalizeCertificate(
|
||||||
|
order: any,
|
||||||
|
domain: string,
|
||||||
|
wildcard: boolean = false
|
||||||
|
): Promise<{ certificate: string; privateKey: string }> {
|
||||||
|
const client = this.getClient();
|
||||||
|
|
||||||
|
const altNames = wildcard ? [`*.${domain}`, domain] : [domain];
|
||||||
|
|
||||||
|
// Create CSR
|
||||||
|
const [privateKey, csr] = await acme.crypto.createCsr({
|
||||||
|
altNames
|
||||||
|
});
|
||||||
|
|
||||||
|
// Finalize order
|
||||||
|
await acmeRateLimiter.acquire();
|
||||||
|
const finalizedOrder = await client.finalizeOrder(order, csr);
|
||||||
|
|
||||||
|
// Get certificate
|
||||||
|
await acmeRateLimiter.acquire();
|
||||||
|
const certificate = await client.getCertificate(finalizedOrder);
|
||||||
|
|
||||||
|
logger.info(`Certificate obtained for domain: ${domain}`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
certificate: certificate.toString(),
|
||||||
|
privateKey: privateKey.toString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const acmeClientManager = new AcmeClientManager();
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
/*
|
||||||
|
* 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 { privateConfig as config } from "#private/lib/config";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { redis } from "../redis";
|
||||||
|
// Caps outgoing ACME API calls to a fixed budget per wall-clock second,
|
||||||
|
// shared across all pops workers via Redis (mirrors the lockManager pattern
|
||||||
|
// in @lib/lock) - a per-process limiter wouldn't be enough since multiple
|
||||||
|
// workers issue certificates against the same Let's Encrypt account.
|
||||||
|
const ACQUIRE_SCRIPT = `
|
||||||
|
local key = KEYS[1]
|
||||||
|
local limit = tonumber(ARGV[1])
|
||||||
|
local current = redis.call('INCR', key)
|
||||||
|
if current == 1 then
|
||||||
|
redis.call('PEXPIRE', key, 2000)
|
||||||
|
end
|
||||||
|
if current > limit then
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
return 1
|
||||||
|
end
|
||||||
|
`;
|
||||||
|
|
||||||
|
class AcmeRateLimiter {
|
||||||
|
async acquire(): Promise<void> {
|
||||||
|
const limit =
|
||||||
|
config.getRawConfig().acme?.acme_requests_per_second ?? 15;
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const bucket = Math.floor(Date.now() / 1000);
|
||||||
|
const key = `acme_rate_limit:${bucket}`;
|
||||||
|
|
||||||
|
let allowed: number;
|
||||||
|
try {
|
||||||
|
allowed = (await redis.eval(
|
||||||
|
ACQUIRE_SCRIPT,
|
||||||
|
1,
|
||||||
|
key,
|
||||||
|
limit.toString()
|
||||||
|
)) as number;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
"ACME rate limiter check failed, proceeding without throttling:",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (allowed === 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Budget for this second is spent - wait for the next window.
|
||||||
|
const waitMs = 1000 - (Date.now() % 1000) + 10;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const acmeRateLimiter = new AcmeRateLimiter();
|
||||||
@@ -0,0 +1,511 @@
|
|||||||
|
/*
|
||||||
|
* 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 { acmeClientManager } from "./acme-client";
|
||||||
|
import { dnsValidator } from "./dns-validator";
|
||||||
|
import { getTableColumns } from "drizzle-orm";
|
||||||
|
import { eq, and, or, isNull, lt, asc } from "drizzle-orm/sql";
|
||||||
|
import { config } from "@server/lib/config";
|
||||||
|
import { db, certificates, domains, Certificate } from "@server/db";
|
||||||
|
import { encrypt } from "@server/lib/crypto";
|
||||||
|
import { withTimeout, withRetry } from "@server/lib/retry";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { lockManager } from "../lock";
|
||||||
|
import { pushCertUpdateToAffectedNewts } from "@server/lib/acmeCertSync";
|
||||||
|
import crypto from "crypto";
|
||||||
|
|
||||||
|
// Number of on-demand DNS validation attempts made right before a
|
||||||
|
// certificate is (re)issued, to avoid burning Let's Encrypt rate limits on
|
||||||
|
// domains whose DNS has drifted since they were last verified.
|
||||||
|
const PRE_CERT_DNS_VALIDATION_ATTEMPTS = 3;
|
||||||
|
|
||||||
|
// Hard ceiling on a single certificate's issuance/renewal flow. acme-client's
|
||||||
|
// axios instance never sets a request timeout, so a stalled connection to
|
||||||
|
// the ACME server hangs forever instead of erroring - and since
|
||||||
|
// processPendingCertificates/processRenewalCandidates gate the *next* batch
|
||||||
|
// on Promise.all(...) over the current one, one hung certificate would
|
||||||
|
// otherwise stall every other domain permanently. Sized generously above the
|
||||||
|
// legitimate worst case (acme-client's own bounded backoff is ~3.6min per
|
||||||
|
// status-polling loop, and a wildcard cert's two identifiers plus order
|
||||||
|
// finalization can chain a few of those) so this only fires on a genuine hang.
|
||||||
|
const CERTIFICATE_ISSUANCE_TIMEOUT_MS = 20 * 60 * 1000;
|
||||||
|
|
||||||
|
// "requested" is set the instant a cert starts processing and is never
|
||||||
|
// queried anywhere else - processPendingCertificates only selects "pending"
|
||||||
|
// and processRenewalCandidates only selects "valid". So if the *process*
|
||||||
|
// dies mid-flight (OOM, node eviction, a rolling deploy) rather than just
|
||||||
|
// hanging, the row is orphaned in "requested" permanently with nothing to
|
||||||
|
// ever pick it back up, no matter how good the in-process timeouts are.
|
||||||
|
// Threshold is set comfortably above CERTIFICATE_ISSUANCE_TIMEOUT_MS plus the
|
||||||
|
// scheduler's own outer backstop so this never reclaims a cert that's still
|
||||||
|
// genuinely being worked on.
|
||||||
|
const STUCK_CERTIFICATE_THRESHOLD_MS = 40 * 60 * 1000;
|
||||||
|
|
||||||
|
export class CertificateService {
|
||||||
|
// Runs at the top of every processPendingCertificates tick so an
|
||||||
|
// interrupted worker's leftovers always get put back in the queue
|
||||||
|
// instead of sitting invisible to every query forever.
|
||||||
|
private async reclaimStuckCertificates(): Promise<void> {
|
||||||
|
const staleBefore =
|
||||||
|
Math.floor(Date.now() / 1000) -
|
||||||
|
Math.floor(STUCK_CERTIFICATE_THRESHOLD_MS / 1000);
|
||||||
|
|
||||||
|
const reclaimed = await db
|
||||||
|
.update(certificates)
|
||||||
|
.set({
|
||||||
|
status: "pending",
|
||||||
|
errorMessage:
|
||||||
|
'Reclaimed after being stuck in "requested" state - the worker processing it likely restarted or crashed',
|
||||||
|
updatedAt: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(certificates.status, "requested"),
|
||||||
|
lt(certificates.updatedAt, staleBefore)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.returning({ domain: certificates.domain });
|
||||||
|
|
||||||
|
if (reclaimed.length > 0) {
|
||||||
|
logger.warn(
|
||||||
|
`Reclaimed ${reclaimed.length} certificate(s) stuck in "requested" state: ${reclaimed
|
||||||
|
.map((c) => c.domain)
|
||||||
|
.join(", ")}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async processPendingCertificates(): Promise<void> {
|
||||||
|
logger.debug("Checking for pending certificates...");
|
||||||
|
|
||||||
|
await this.reclaimStuckCertificates();
|
||||||
|
|
||||||
|
const pendingCerts = await db
|
||||||
|
.select(getTableColumns(certificates))
|
||||||
|
.from(certificates)
|
||||||
|
.leftJoin(domains, eq(certificates.domainId, domains.domainId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(certificates.status, "pending"),
|
||||||
|
or(
|
||||||
|
// Certs with no linked domain row (e.g. legacy certs
|
||||||
|
// imported from acme.json) aren't gated on domain
|
||||||
|
// verification since there's nothing to check.
|
||||||
|
isNull(certificates.domainId),
|
||||||
|
and(
|
||||||
|
eq(domains.verified, true),
|
||||||
|
eq(domains.failed, false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
if (pendingCerts.length === 0) {
|
||||||
|
logger.debug("No pending certificates found");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Found ${pendingCerts.length} pending certificates`);
|
||||||
|
|
||||||
|
// Process the batch concurrently so one domain stuck retrying a slow
|
||||||
|
// DNS-01 challenge (the ACME client's waitForValidStatus can spend
|
||||||
|
// minutes on a bad domain) doesn't stall the rest of the batch.
|
||||||
|
// processSingleCertificate catches its own errors and each cert uses
|
||||||
|
// an independent per-domain lock, so this is safe to parallelize.
|
||||||
|
await Promise.all(
|
||||||
|
pendingCerts.map((cert) => this.processSingleCertificate(cert))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async processRenewalCandidates(): Promise<void> {
|
||||||
|
logger.debug("Checking for certificates needing renewal...");
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
const renewalCandidates = await db
|
||||||
|
.select(getTableColumns(certificates))
|
||||||
|
.from(certificates)
|
||||||
|
.leftJoin(domains, eq(certificates.domainId, domains.domainId))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(certificates.status, "valid"),
|
||||||
|
lt(certificates.expiresAt, now + 15 * 24 * 60 * 60), // 15 days from now
|
||||||
|
or(
|
||||||
|
// Certs with no linked domain row (e.g. legacy certs
|
||||||
|
// imported from acme.json) aren't gated on domain
|
||||||
|
// verification since there's nothing to check.
|
||||||
|
isNull(certificates.domainId),
|
||||||
|
and(
|
||||||
|
eq(domains.verified, true),
|
||||||
|
eq(domains.failed, false)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// Most urgent first, so already-expired certs aren't starved
|
||||||
|
// behind the limit by certs that still have weeks of runway.
|
||||||
|
.orderBy(asc(certificates.expiresAt))
|
||||||
|
.limit(50);
|
||||||
|
|
||||||
|
if (renewalCandidates.length === 0) {
|
||||||
|
logger.debug("No certificates need renewal");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Found ${renewalCandidates.length} certificates needing renewal`
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const cert of renewalCandidates) {
|
||||||
|
if (cert.expiresAt !== null && cert.expiresAt < now) {
|
||||||
|
logger.warn(
|
||||||
|
`Certificate for ${cert.domain} is marked "valid" but already expired at ${new Date(cert.expiresAt * 1000).toISOString()} (bad state) - renewing immediately`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process the batch concurrently - see processPendingCertificates for why.
|
||||||
|
await Promise.all(
|
||||||
|
renewalCandidates.map((cert) => this.renewCertificate(cert))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processSingleCertificate(cert: Certificate): Promise<void> {
|
||||||
|
const lockKey = `cert:${cert.domain}`;
|
||||||
|
|
||||||
|
const lockToken = await lockManager.acquireLock(lockKey);
|
||||||
|
if (!lockToken) {
|
||||||
|
logger.debug(
|
||||||
|
`Could not acquire lock for certificate: ${cert.domain}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`Processing certificate for domain: ${cert.domain}`);
|
||||||
|
|
||||||
|
// Update status to processing
|
||||||
|
await db
|
||||||
|
.update(certificates)
|
||||||
|
.set({
|
||||||
|
status: "requested",
|
||||||
|
updatedAt: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
.where(eq(certificates.certId, cert.certId));
|
||||||
|
//
|
||||||
|
|
||||||
|
await withTimeout(
|
||||||
|
this.obtainCertificate(cert),
|
||||||
|
CERTIFICATE_ISSUANCE_TIMEOUT_MS,
|
||||||
|
`certificate issuance for ${cert.domain}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to process certificate for ${cert.domain}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(certificates)
|
||||||
|
.set({
|
||||||
|
status: "failed",
|
||||||
|
errorMessage:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unknown error",
|
||||||
|
updatedAt: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
.where(eq(certificates.certId, cert.certId));
|
||||||
|
} finally {
|
||||||
|
await lockManager.releaseLock(lockKey, lockToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async renewCertificate(cert: Certificate): Promise<void> {
|
||||||
|
const lockKey = `cert:${cert.domain}`;
|
||||||
|
|
||||||
|
const lockToken = await lockManager.acquireLock(lockKey);
|
||||||
|
if (!lockToken) {
|
||||||
|
logger.debug(
|
||||||
|
`Could not acquire lock for certificate renewal: ${cert.domain}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`Renewing certificate for domain: ${cert.domain}`);
|
||||||
|
|
||||||
|
// Update last renewal attempt
|
||||||
|
await db
|
||||||
|
.update(certificates)
|
||||||
|
.set({
|
||||||
|
lastRenewalAttempt: Math.floor(Date.now() / 1000),
|
||||||
|
updatedAt: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
.where(eq(certificates.certId, cert.certId));
|
||||||
|
|
||||||
|
await withTimeout(
|
||||||
|
this.obtainCertificate(cert),
|
||||||
|
CERTIFICATE_ISSUANCE_TIMEOUT_MS,
|
||||||
|
`certificate renewal for ${cert.domain}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
`Failed to renew certificate for ${cert.domain}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(certificates)
|
||||||
|
.set({
|
||||||
|
status: "failed",
|
||||||
|
errorMessage:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unknown error",
|
||||||
|
lastRenewalAttempt: Math.floor(Date.now() / 1000),
|
||||||
|
updatedAt: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
.where(eq(certificates.certId, cert.certId));
|
||||||
|
} finally {
|
||||||
|
await lockManager.releaseLock(lockKey, lockToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-checks the domain's DNS records right before we spend a Let's
|
||||||
|
// Encrypt order on it, so drift that happened after the domain was
|
||||||
|
// originally verified doesn't burn ACME rate limits. Certs with no
|
||||||
|
// linked domain row (e.g. legacy/manually-managed certs) skip this and
|
||||||
|
// proceed as before, since there are no tracked DNS records to check.
|
||||||
|
private async verifyDomainBeforeIssuance(cert: Certificate): Promise<void> {
|
||||||
|
if (!cert.domainId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [domain] = await db
|
||||||
|
.select()
|
||||||
|
.from(domains)
|
||||||
|
.where(eq(domains.domainId, cert.domainId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (
|
||||||
|
let attempt = 1;
|
||||||
|
attempt <= PRE_CERT_DNS_VALIDATION_ATTEMPTS;
|
||||||
|
attempt++
|
||||||
|
) {
|
||||||
|
// Offset `tries` so each attempt round-robins to a different
|
||||||
|
// privateConfigured DNS resolver instead of re-querying the same one.
|
||||||
|
const probe = { ...domain, tries: domain.tries + attempt - 1 };
|
||||||
|
if (
|
||||||
|
await dnsValidator.validateDomain(probe, {
|
||||||
|
forceRecheck: true
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
await db
|
||||||
|
.update(domains)
|
||||||
|
.set({ verified: true, failed: false, errorMessage: null })
|
||||||
|
.where(eq(domains.domainId, domain.domainId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`Pre-certificate DNS check ${attempt}/${PRE_CERT_DNS_VALIDATION_ATTEMPTS} failed for domain ${domain.baseDomain} (cert: ${cert.domain})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = `Domain failed DNS validation ${PRE_CERT_DNS_VALIDATION_ATTEMPTS} times before certificate issuance`;
|
||||||
|
await db
|
||||||
|
.update(domains)
|
||||||
|
.set({ verified: false, failed: true, errorMessage })
|
||||||
|
.where(eq(domains.domainId, domain.domainId));
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async obtainCertificate(cert: Certificate): Promise<void> {
|
||||||
|
await this.verifyDomainBeforeIssuance(cert);
|
||||||
|
|
||||||
|
// Create order
|
||||||
|
const order = await acmeClientManager.createOrder(
|
||||||
|
cert.domain,
|
||||||
|
cert.wildcard || false
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update with order ID
|
||||||
|
await withRetry(
|
||||||
|
() =>
|
||||||
|
db
|
||||||
|
.update(certificates)
|
||||||
|
.set({
|
||||||
|
orderId: order.url,
|
||||||
|
updatedAt: Math.floor(Date.now() / 1000)
|
||||||
|
})
|
||||||
|
.where(eq(certificates.certId, cert.certId)),
|
||||||
|
{ label: `update orderId for certificate ${cert.domain}` }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get authorizations
|
||||||
|
const authorizations = await acmeClientManager.getAuthorizations(order);
|
||||||
|
|
||||||
|
// Aggregate all DNS-01 challenges
|
||||||
|
const dnsChallenges = authorizations.map((authz: any) => {
|
||||||
|
const dnsChallenge = authz.challenges.find(
|
||||||
|
(c: any) => c.type === "dns-01"
|
||||||
|
);
|
||||||
|
if (!dnsChallenge) {
|
||||||
|
throw new Error(
|
||||||
|
`No DNS-01 challenge found for ${authz.identifier.value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
authz,
|
||||||
|
challenge: dnsChallenge
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send all DNS-01 challenges in one request to handleDnsChallenge
|
||||||
|
await acmeClientManager.handleDnsChallenge(dnsChallenges);
|
||||||
|
|
||||||
|
// Finalize certificate
|
||||||
|
const { certificate, privateKey } =
|
||||||
|
await acmeClientManager.finalizeCertificate(
|
||||||
|
order,
|
||||||
|
cert.domain,
|
||||||
|
cert.wildcard || false
|
||||||
|
);
|
||||||
|
|
||||||
|
const encryptionKey = config.getRawConfig().server.secret;
|
||||||
|
if (!encryptionKey) {
|
||||||
|
throw new Error("Encryption key not provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt certificate and private key
|
||||||
|
const encryptedCert = encrypt(certificate, encryptionKey);
|
||||||
|
const encryptedKey = encrypt(privateKey, encryptionKey);
|
||||||
|
|
||||||
|
// Parse certificate to get expiration date
|
||||||
|
const expiresAt = this.extractExpirationDate(certificate);
|
||||||
|
|
||||||
|
// Update database record. This persists the certificate we just
|
||||||
|
// obtained from the ACME server, so it's retried aggressively -
|
||||||
|
// losing this write means re-issuing the cert from scratch.
|
||||||
|
await withRetry(
|
||||||
|
() =>
|
||||||
|
db
|
||||||
|
.update(certificates)
|
||||||
|
.set({
|
||||||
|
status: "valid",
|
||||||
|
expiresAt: Math.floor(expiresAt.getTime() / 1000),
|
||||||
|
renewalCount: (cert.renewalCount || 0) + 1,
|
||||||
|
errorMessage: null,
|
||||||
|
updatedAt: Math.floor(Date.now() / 1000),
|
||||||
|
certFile: encryptedCert,
|
||||||
|
keyFile: encryptedKey
|
||||||
|
})
|
||||||
|
.where(eq(certificates.certId, cert.certId)),
|
||||||
|
{
|
||||||
|
retries: 5,
|
||||||
|
label: `persist issued certificate for ${cert.domain}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Certificate successfully obtained/renewed for domain: ${cert.domain}`
|
||||||
|
);
|
||||||
|
|
||||||
|
await pushCertUpdateToAffectedNewts(
|
||||||
|
cert.domain,
|
||||||
|
cert.domainId ?? null,
|
||||||
|
certificate,
|
||||||
|
privateKey
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private extractExpirationDate(certificate: string): Date {
|
||||||
|
try {
|
||||||
|
// Extract the certificate block
|
||||||
|
const pem = certificate
|
||||||
|
.replace(/-----BEGIN CERTIFICATE-----/g, "")
|
||||||
|
.replace(/-----END CERTIFICATE-----/g, "")
|
||||||
|
.replace(/\s+/g, "");
|
||||||
|
const der = Buffer.from(pem, "base64");
|
||||||
|
|
||||||
|
// Use Node.js crypto to parse the certificate
|
||||||
|
const x509 = new crypto.X509Certificate(der);
|
||||||
|
return new Date(x509.validTo);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
"Failed to parse certificate expiration date, using default",
|
||||||
|
error
|
||||||
|
);
|
||||||
|
// Default to 90 days from now (Let's Encrypt default)
|
||||||
|
return new Date(Date.now() + 90 * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addCertificateRequest(domain: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await db.insert(certificates).values({
|
||||||
|
domain,
|
||||||
|
status: "pending",
|
||||||
|
createdAt: Math.floor(Date.now() / 1000),
|
||||||
|
updatedAt: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
logger.info(`Certificate request added for domain: ${domain}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes("unique")) {
|
||||||
|
logger.warn(
|
||||||
|
`Certificate request already exists for domain: ${domain}`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCertificateStatus(domain: string) {
|
||||||
|
const cert = await db
|
||||||
|
.select()
|
||||||
|
.from(certificates)
|
||||||
|
.where(eq(certificates.domain, domain))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return cert[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async cleanupExpiredChallenges(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const result = await db
|
||||||
|
.delete(certificates)
|
||||||
|
.where(
|
||||||
|
lt(certificates.expiresAt, Math.floor(Date.now() / 1000))
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (result.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
`Cleaned up ${result.length} expired DNS challenges`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Failed to cleanup expired challenges:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const certificateService = new CertificateService();
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
/*
|
||||||
|
* 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 { eq, and, lt } from "drizzle-orm";
|
||||||
|
import * as dns from "dns/promises";
|
||||||
|
import { privateConfig as config } from "#private/lib/config";
|
||||||
|
import { db, domains, DnsRecord, dnsRecords, Domain } from "@server/db";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { lockManager } from "../lock";
|
||||||
|
|
||||||
|
export const DNS_VALIDATOR_MAX_TRIES = 300;
|
||||||
|
|
||||||
|
export class DNSValidator {
|
||||||
|
private static readonly MAX_TRIES = DNS_VALIDATOR_MAX_TRIES;
|
||||||
|
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
async validateAll(): Promise<void> {
|
||||||
|
// Get all domains that are not yet verified and haven't exceeded max tries
|
||||||
|
const unverifiedDomains: Domain[] = await db
|
||||||
|
.select()
|
||||||
|
.from(domains)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(domains.verified, false),
|
||||||
|
lt(domains.tries, DNSValidator.MAX_TRIES)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (unverifiedDomains.length === 0) {
|
||||||
|
logger.debug("No unverified domains found for DNS validation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Validating ${unverifiedDomains.length} DNS records`);
|
||||||
|
|
||||||
|
for (const domain of unverifiedDomains) {
|
||||||
|
const lockKey = `dns:${domain.baseDomain}`;
|
||||||
|
const lockToken = await lockManager.acquireLock(lockKey);
|
||||||
|
if (!lockToken) {
|
||||||
|
logger.debug(
|
||||||
|
`Could not acquire lock for DNS validation: ${domain.baseDomain}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const isValid = await this.validateDomain(domain);
|
||||||
|
if (isValid) {
|
||||||
|
await db
|
||||||
|
.update(domains)
|
||||||
|
.set({
|
||||||
|
verified: true,
|
||||||
|
failed: false,
|
||||||
|
tries: 0,
|
||||||
|
errorMessage: null
|
||||||
|
})
|
||||||
|
.where(eq(domains.domainId, domain.domainId));
|
||||||
|
logger.info(
|
||||||
|
`Domain ${domain.baseDomain} validated successfully`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const newTries = domain.tries + 1;
|
||||||
|
const shouldMarkAsFailed =
|
||||||
|
newTries >= DNSValidator.MAX_TRIES;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(domains)
|
||||||
|
.set({
|
||||||
|
tries: newTries,
|
||||||
|
failed: shouldMarkAsFailed
|
||||||
|
})
|
||||||
|
.where(eq(domains.domainId, domain.domainId));
|
||||||
|
|
||||||
|
if (shouldMarkAsFailed) {
|
||||||
|
logger.warn(
|
||||||
|
`Domain ${domain.baseDomain} exceeded maximum tries (${DNSValidator.MAX_TRIES}), marking as failed`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
logger.debug(
|
||||||
|
`Domain ${domain.baseDomain} did not validate (attempt ${newTries}/${DNSValidator.MAX_TRIES})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`Error validating domain ${domain.baseDomain}:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
// Increment tries even on error
|
||||||
|
const newTries = domain.tries + 1;
|
||||||
|
const shouldMarkAsFailed = newTries >= DNSValidator.MAX_TRIES;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(domains)
|
||||||
|
.set({
|
||||||
|
tries: newTries,
|
||||||
|
failed: shouldMarkAsFailed
|
||||||
|
})
|
||||||
|
.where(eq(domains.domainId, domain.domainId));
|
||||||
|
} finally {
|
||||||
|
await lockManager.releaseLock(lockKey, lockToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async validateDomain(
|
||||||
|
domain: Domain,
|
||||||
|
opts: { forceRecheck?: boolean } = {}
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { forceRecheck = false } = opts;
|
||||||
|
const resolver = new dns.Resolver();
|
||||||
|
const servers = config.getRawConfig().acme?.dns_resolvers;
|
||||||
|
if (!servers || servers.length === 0) {
|
||||||
|
throw new Error("No DNS resolvers configured");
|
||||||
|
}
|
||||||
|
const dnsServer = servers[domain.tries % servers.length]!;
|
||||||
|
resolver.setServers([dnsServer]);
|
||||||
|
logger.debug(
|
||||||
|
`Using DNS server ${dnsServer} for domain ${domain.baseDomain} (try ${domain.tries})`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get all DNS records for this domain
|
||||||
|
const records: DnsRecord[] = await db
|
||||||
|
.select()
|
||||||
|
.from(dnsRecords)
|
||||||
|
.where(eq(dnsRecords.domainId, domain.domainId));
|
||||||
|
|
||||||
|
if (records.length === 0) {
|
||||||
|
logger.warn(`No DNS records found for domain ${domain.baseDomain}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!forceRecheck && records.every((r) => r.verified)) {
|
||||||
|
logger.info(
|
||||||
|
`All DNS records already verified for domain ${domain.baseDomain}`
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Validating ${records.length} DNS records for domain ${domain.baseDomain}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Collect the full set of expected NS values for this domain so we can
|
||||||
|
// detect extra records that are present in DNS but not in our DB.
|
||||||
|
const expectedNsValues = new Set<string>(
|
||||||
|
records.filter((r) => r.recordType === "NS").map((r) => r.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cache resolved NS records across iterations — there will be 3 NS
|
||||||
|
// records in the DB and we don't need to hit the upstream server 3 times.
|
||||||
|
let previousNs: string[] | null = null;
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
// Skip already verified records, unless a live recheck was requested
|
||||||
|
if (record.verified && !forceRecheck) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let isValid = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (record.recordType === "NS") {
|
||||||
|
let nsRecords: string[] | null = previousNs;
|
||||||
|
if (!nsRecords) {
|
||||||
|
nsRecords = await resolver.resolveNs(
|
||||||
|
record.baseDomain || domain.baseDomain
|
||||||
|
);
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
`NS records for ${
|
||||||
|
record.baseDomain || domain.baseDomain
|
||||||
|
}:`,
|
||||||
|
nsRecords
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if this expected NS value is present in the live records.
|
||||||
|
// A stale/legacy expected value (e.g. left over from a
|
||||||
|
// nameserver rebrand) is also accepted as long as the live
|
||||||
|
// records resolve to some other known-valid nameserver —
|
||||||
|
// the specific literal hostname stored per-domain isn't
|
||||||
|
// meaningful once it's a recognized alias.
|
||||||
|
isValid = nsRecords.some((ns) => ns === record.value);
|
||||||
|
|
||||||
|
previousNs = nsRecords;
|
||||||
|
} else if (record.recordType === "CNAME") {
|
||||||
|
const cnameRecords = await resolver.resolveCname(
|
||||||
|
record.baseDomain || domain.baseDomain
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`CNAME records for ${
|
||||||
|
record.baseDomain || domain.baseDomain
|
||||||
|
}:`,
|
||||||
|
cnameRecords
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the CNAME record matches the expected value
|
||||||
|
isValid =
|
||||||
|
cnameRecords.length === 1 &&
|
||||||
|
cnameRecords[0] === record.value;
|
||||||
|
} else if (record.recordType === "TXT") {
|
||||||
|
const txtRecords = await resolver.resolveTxt(
|
||||||
|
record.baseDomain || domain.baseDomain
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`TXT records for ${
|
||||||
|
record.baseDomain || domain.baseDomain
|
||||||
|
}:`,
|
||||||
|
txtRecords
|
||||||
|
);
|
||||||
|
|
||||||
|
// TXT records come as an array of arrays, flatten and check
|
||||||
|
const flatTxtRecords = txtRecords.flat();
|
||||||
|
isValid = flatTxtRecords.includes(record.value);
|
||||||
|
} else if (record.recordType === "A") {
|
||||||
|
const aRecords = await resolver.resolve4(
|
||||||
|
record.baseDomain || domain.baseDomain
|
||||||
|
);
|
||||||
|
logger.info(
|
||||||
|
`A records for ${
|
||||||
|
record.baseDomain || domain.baseDomain
|
||||||
|
}:`,
|
||||||
|
aRecords
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the A record matches the expected value
|
||||||
|
isValid = aRecords.includes(record.value);
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`Unsupported record type: ${record.recordType}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
isValid = false;
|
||||||
|
logger.debug(
|
||||||
|
`Did not resolve ${record.recordType} record for ${
|
||||||
|
record.baseDomain || domain.baseDomain
|
||||||
|
}:`,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the individual record verification status. Runs for
|
||||||
|
// both a mismatched value and a failed/thrown DNS lookup, so a
|
||||||
|
// previously-verified record that stops resolving (e.g. NXDOMAIN
|
||||||
|
// after NS delegation is dropped) gets downgraded instead of
|
||||||
|
// leaving stale `verified: true` state behind.
|
||||||
|
if (isValid) {
|
||||||
|
await db
|
||||||
|
.update(dnsRecords)
|
||||||
|
.set({ verified: true })
|
||||||
|
.where(eq(dnsRecords.id, record.id));
|
||||||
|
logger.info(
|
||||||
|
`DNS record ${record.id} (${record.recordType}) for ${
|
||||||
|
record.baseDomain || domain.baseDomain
|
||||||
|
} verified successfully`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (record.verified) {
|
||||||
|
await db
|
||||||
|
.update(dnsRecords)
|
||||||
|
.set({ verified: false })
|
||||||
|
.where(eq(dnsRecords.id, record.id));
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
`DNS record ${record.id} (${record.recordType}) for ${
|
||||||
|
record.baseDomain || domain.baseDomain
|
||||||
|
} does not match expected value: ${record.value}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Extra NS record check ---
|
||||||
|
// If we resolved NS records during this pass, verify that the live DNS
|
||||||
|
// has no nameservers beyond the ones we expect. Individual records may
|
||||||
|
// already be marked verified above, but we must block full domain
|
||||||
|
// verification until the extra records are removed.
|
||||||
|
if (previousNs !== null && expectedNsValues.size > 0) {
|
||||||
|
const extraNsRecords = previousNs.filter(
|
||||||
|
(ns) => !expectedNsValues.has(ns)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (extraNsRecords.length > 0) {
|
||||||
|
const errorMessage = `Extra NS records found that are not expected: ${extraNsRecords.join(", ")}. Remove these nameservers to complete domain verification.`;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(domains)
|
||||||
|
.set({ errorMessage })
|
||||||
|
.where(eq(domains.domainId, domain.domainId));
|
||||||
|
|
||||||
|
logger.warn(
|
||||||
|
`Domain ${domain.baseDomain} has extra NS records that prevent verification: ${extraNsRecords.join(", ")}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No extras — clear any stale error that was previously written
|
||||||
|
await db
|
||||||
|
.update(domains)
|
||||||
|
.set({ errorMessage: null })
|
||||||
|
.where(eq(domains.domainId, domain.domainId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all records are now verified
|
||||||
|
const updatedRecords: DnsRecord[] = await db
|
||||||
|
.select()
|
||||||
|
.from(dnsRecords)
|
||||||
|
.where(eq(dnsRecords.domainId, domain.domainId));
|
||||||
|
|
||||||
|
const allRecordsVerified = updatedRecords.every((r) => r.verified);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Domain ${domain.baseDomain}: ${
|
||||||
|
updatedRecords.filter((r) => r.verified).length
|
||||||
|
}/${updatedRecords.length} records verified`
|
||||||
|
);
|
||||||
|
|
||||||
|
return allRecordsVerified;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const dnsValidator = new DNSValidator();
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/*
|
||||||
|
* 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 { eq, and, or, isNull, lt } from "drizzle-orm";
|
||||||
|
import * as dns from "dns/promises";
|
||||||
|
import { DNS_VALIDATOR_MAX_TRIES } from "./dns-validator";
|
||||||
|
import { db, domains, DnsRecord, dnsRecords, Domain } from "@server/db";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { lockManager } from "../lock";
|
||||||
|
import { privateConfig as config } from "#private/lib/config";
|
||||||
|
|
||||||
|
// Module-level counter so successive domains in a batch round-robin across servers.
|
||||||
|
let serverIndex = 0;
|
||||||
|
|
||||||
|
export class DomainReverifier {
|
||||||
|
async reverifyAll(): Promise<void> {
|
||||||
|
const certConfig = config.getRawConfig().acme;
|
||||||
|
if (!certConfig) {
|
||||||
|
logger.debug(
|
||||||
|
"No certificate config — skipping domain reverification"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const windowMs = certConfig.domain_reverification_window_ms;
|
||||||
|
const batchSize = certConfig.domain_reverification_batch_size;
|
||||||
|
const windowSecs = Math.floor(windowMs / 1000);
|
||||||
|
const cutoff = Math.floor(Date.now() / 1000) - windowSecs;
|
||||||
|
|
||||||
|
const domainsToCheck: Domain[] = await db
|
||||||
|
.select()
|
||||||
|
.from(domains)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(domains.verified, true),
|
||||||
|
or(
|
||||||
|
isNull(domains.lastCheckedAt),
|
||||||
|
lt(domains.lastCheckedAt, cutoff)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.limit(batchSize);
|
||||||
|
|
||||||
|
if (domainsToCheck.length === 0) {
|
||||||
|
logger.debug("No verified domains due for reverification");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Reverifying ${domainsToCheck.length} domains`);
|
||||||
|
|
||||||
|
for (const domain of domainsToCheck) {
|
||||||
|
const lockKey = `dns-reverify:${domain.baseDomain}`;
|
||||||
|
const lockToken = await lockManager.acquireLock(lockKey);
|
||||||
|
if (!lockToken) {
|
||||||
|
logger.debug(
|
||||||
|
`Could not acquire lock for domain reverification: ${domain.baseDomain}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.reverifyDomain(domain, certConfig.dns_resolvers);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(
|
||||||
|
`Unexpected error reverifying domain ${domain.baseDomain}:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
// Still stamp lastCheckedAt so we don't hammer a broken domain every run.
|
||||||
|
await db
|
||||||
|
.update(domains)
|
||||||
|
.set({ lastCheckedAt: Math.floor(Date.now() / 1000) })
|
||||||
|
.where(eq(domains.domainId, domain.domainId));
|
||||||
|
} finally {
|
||||||
|
await lockManager.releaseLock(lockKey, lockToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async reverifyDomain(
|
||||||
|
domain: Domain,
|
||||||
|
servers: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
if (!servers || servers.length === 0) {
|
||||||
|
throw new Error("No DNS resolvers configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-robin across servers; advance the global counter so the next
|
||||||
|
// domain in the same batch gets a different server.
|
||||||
|
const dnsServer = servers[serverIndex % servers.length]!;
|
||||||
|
serverIndex++;
|
||||||
|
|
||||||
|
const resolver = new dns.Resolver();
|
||||||
|
resolver.setServers([dnsServer]);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
`Reverifying domain ${domain.baseDomain} using DNS server ${dnsServer}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const records: DnsRecord[] = await db
|
||||||
|
.select()
|
||||||
|
.from(dnsRecords)
|
||||||
|
.where(eq(dnsRecords.domainId, domain.domainId));
|
||||||
|
|
||||||
|
if (records.length === 0) {
|
||||||
|
logger.warn(
|
||||||
|
`No DNS records found for domain ${domain.baseDomain} during reverification — marking failed`
|
||||||
|
);
|
||||||
|
await this.markFailed(
|
||||||
|
domain.domainId,
|
||||||
|
"No DNS records found during periodic reverification"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedNsValues = new Set<string>(
|
||||||
|
records.filter((r) => r.recordType === "NS").map((r) => r.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
let allValid = true;
|
||||||
|
let errorMessage: string | null = null;
|
||||||
|
let resolvedNs: string[] | null = null;
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
let isValid = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (record.recordType === "NS") {
|
||||||
|
if (!resolvedNs) {
|
||||||
|
resolvedNs = await resolver.resolveNs(
|
||||||
|
record.baseDomain || domain.baseDomain
|
||||||
|
);
|
||||||
|
}
|
||||||
|
isValid = resolvedNs.some((ns) => ns === record.value);
|
||||||
|
} else if (record.recordType === "CNAME") {
|
||||||
|
const cnameRecords = await resolver.resolveCname(
|
||||||
|
record.baseDomain || domain.baseDomain
|
||||||
|
);
|
||||||
|
isValid =
|
||||||
|
cnameRecords.length === 1 &&
|
||||||
|
cnameRecords[0] === record.value;
|
||||||
|
} else if (record.recordType === "TXT") {
|
||||||
|
const txtRecords = await resolver.resolveTxt(
|
||||||
|
record.baseDomain || domain.baseDomain
|
||||||
|
);
|
||||||
|
isValid = txtRecords.flat().includes(record.value);
|
||||||
|
} else if (record.recordType === "A") {
|
||||||
|
const aRecords = await resolver.resolve4(
|
||||||
|
record.baseDomain || domain.baseDomain
|
||||||
|
);
|
||||||
|
isValid = aRecords.includes(record.value);
|
||||||
|
} else {
|
||||||
|
logger.warn(
|
||||||
|
`Unsupported record type ${record.recordType} during reverification of ${domain.baseDomain}`
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug(
|
||||||
|
`DNS lookup failed for ${record.recordType} record on ${record.baseDomain || domain.baseDomain}:`,
|
||||||
|
err
|
||||||
|
);
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
allValid = false;
|
||||||
|
errorMessage = `${record.recordType} record for ${record.baseDomain || domain.baseDomain} no longer resolves to expected value "${record.value}"`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for extra NS records beyond what we expect.
|
||||||
|
if (allValid && resolvedNs !== null && expectedNsValues.size > 0) {
|
||||||
|
const extraNs = resolvedNs.filter(
|
||||||
|
(ns) => !expectedNsValues.has(ns)
|
||||||
|
);
|
||||||
|
if (extraNs.length > 0) {
|
||||||
|
allValid = false;
|
||||||
|
errorMessage = `Extra NS records found: ${extraNs.join(", ")}. Remove these nameservers.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
if (allValid) {
|
||||||
|
await db
|
||||||
|
.update(domains)
|
||||||
|
.set({ lastCheckedAt: now, errorMessage: null })
|
||||||
|
.where(eq(domains.domainId, domain.domainId));
|
||||||
|
logger.debug(
|
||||||
|
`Domain ${domain.baseDomain} passed periodic reverification`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.markFailed(domain.domainId, errorMessage);
|
||||||
|
logger.warn(
|
||||||
|
`Domain ${domain.baseDomain} failed periodic reverification: ${errorMessage}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markFailed(
|
||||||
|
domainId: string,
|
||||||
|
errorMessage: string | null
|
||||||
|
): Promise<void> {
|
||||||
|
await db
|
||||||
|
.update(domains)
|
||||||
|
.set({
|
||||||
|
verified: false,
|
||||||
|
failed: true,
|
||||||
|
// Three below MAX_TRIES: keeps the domain out of the DNS
|
||||||
|
// validator's immediate retry loop, while still leaving it
|
||||||
|
// eligible (tries < MAX_TRIES) for a few more validation
|
||||||
|
// passes instead of being excluded forever once tries hits
|
||||||
|
// MAX_TRIES.
|
||||||
|
tries: DNS_VALIDATOR_MAX_TRIES - 3,
|
||||||
|
lastCheckedAt: Math.floor(Date.now() / 1000),
|
||||||
|
errorMessage
|
||||||
|
})
|
||||||
|
.where(eq(domains.domainId, domainId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const domainReverifier = new DomainReverifier();
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
/*
|
||||||
|
* 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 logger from "@server/logger";
|
||||||
|
import { privateConfig } from "#private/lib/config";
|
||||||
|
import { acmeClientManager } from "./acme-client";
|
||||||
|
import { jobScheduler } from "./scheduler";
|
||||||
|
|
||||||
|
export async function startCertificateManager() {
|
||||||
|
const acmeConfig = privateConfig.getRawPrivateConfig().acme;
|
||||||
|
if (
|
||||||
|
acmeConfig &&
|
||||||
|
acmeConfig.cert_mode === "pangolin" &&
|
||||||
|
acmeConfig.enable_acme_client
|
||||||
|
) {
|
||||||
|
logger.info("Starting certificate management server...");
|
||||||
|
|
||||||
|
// Initialize ACME client
|
||||||
|
await acmeClientManager.initialize();
|
||||||
|
|
||||||
|
// Start certificate issuance/renewal jobs
|
||||||
|
await jobScheduler.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
||||||
|
// DNS record validation/reverification doesn't require certs, so it
|
||||||
|
// runs whenever Pangolin is acting as the authoritative DNS server,
|
||||||
|
// independent of the cert manager above.
|
||||||
|
await jobScheduler.startDnsJobs();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function stopCertificateManager() {
|
||||||
|
await jobScheduler.stop();
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
/*
|
||||||
|
* 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 { withTimeout } from "@server/lib/retry";
|
||||||
|
import logger from "@server/logger";
|
||||||
|
import { certificateService } from "./certificate-service";
|
||||||
|
import { privateConfig as config } from "#private/lib/config";
|
||||||
|
import { dnsValidator } from "./dns-validator";
|
||||||
|
import { domainReverifier } from "./domain-reverifier";
|
||||||
|
import license from "#private/license/license";
|
||||||
|
|
||||||
|
// Backstop for runExclusive: no single job's own internal timeouts (e.g.
|
||||||
|
// certificate-service's per-cert issuance timeout) are relied on here. This
|
||||||
|
// is the last line of defense - if *anything* inside a job hangs with no
|
||||||
|
// error (a stalled Redis/DB call, a future code path that forgets to bound
|
||||||
|
// itself, etc.), state.active must still reset so the next tick can run.
|
||||||
|
// Without it, one hung run permanently skips every future tick for that job,
|
||||||
|
// since runExclusive only clears state.active after the job promise settles.
|
||||||
|
const RUN_EXCLUSIVE_TIMEOUT_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
export class JobScheduler {
|
||||||
|
private certIntervals: NodeJS.Timeout[] = [];
|
||||||
|
private dnsIntervals: NodeJS.Timeout[] = [];
|
||||||
|
private certRunning = false;
|
||||||
|
private dnsRunning = false;
|
||||||
|
|
||||||
|
// Guards against a slow batch (e.g. 10 certs whose DNS challenges take a
|
||||||
|
// while) still being processed when the next interval tick fires -
|
||||||
|
// without this, overlapping ticks would each pull their own batch of up
|
||||||
|
// to 10 pending/renewal certs and process them concurrently instead of
|
||||||
|
// waiting for the prior batch to finish.
|
||||||
|
private runExclusive(
|
||||||
|
job: () => Promise<void>,
|
||||||
|
state: { active: boolean },
|
||||||
|
label: string
|
||||||
|
): () => Promise<void> {
|
||||||
|
return async () => {
|
||||||
|
if (!(await license.isUnlocked())) {
|
||||||
|
logger.debug(
|
||||||
|
`Skipping ${label} tick - license is not subscribed`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.active) {
|
||||||
|
logger.debug(
|
||||||
|
`Skipping ${label} tick - previous run still in progress`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.active = true;
|
||||||
|
try {
|
||||||
|
await withTimeout(job(), RUN_EXCLUSIVE_TIMEOUT_MS, label);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Error in ${label}:`, error);
|
||||||
|
} finally {
|
||||||
|
state.active = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Certificate issuance/renewal - requires an ACME client, so this is
|
||||||
|
// only started when Pangolin is actually managing certs.
|
||||||
|
async start(): Promise<void> {
|
||||||
|
if (this.certRunning) {
|
||||||
|
logger.warn("Certificate job scheduler is already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.certRunning = true;
|
||||||
|
logger.info("Starting certificate job scheduler");
|
||||||
|
|
||||||
|
const newCertState = { active: false };
|
||||||
|
const renewalState = { active: false };
|
||||||
|
|
||||||
|
const runNewCertCheck = this.runExclusive(
|
||||||
|
() => certificateService.processPendingCertificates(),
|
||||||
|
newCertState,
|
||||||
|
"processing pending certificates"
|
||||||
|
);
|
||||||
|
const runRenewalCheck = this.runExclusive(
|
||||||
|
() => certificateService.processRenewalCandidates(),
|
||||||
|
renewalState,
|
||||||
|
"processing renewal candidates"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schedule new certificate processing
|
||||||
|
const newCertInterval = setInterval(
|
||||||
|
runNewCertCheck,
|
||||||
|
config.getRawConfig().acme!.new_cert_check_interval_ms
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schedule renewal processing (every 24 hours)
|
||||||
|
const renewalInterval = setInterval(
|
||||||
|
runRenewalCheck,
|
||||||
|
config.getRawConfig().acme!.renewal_check_interval_ms
|
||||||
|
);
|
||||||
|
|
||||||
|
this.certIntervals.push(newCertInterval, renewalInterval);
|
||||||
|
|
||||||
|
// Run initial checks
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await runNewCertCheck();
|
||||||
|
// await runRenewalCheck();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error in initial certificate processing:", error);
|
||||||
|
}
|
||||||
|
}, 1000); // Wait 1 second after startup
|
||||||
|
|
||||||
|
logger.info("Certificate job scheduler started successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
// DNS record validation/reverification - doesn't touch certs at all, so
|
||||||
|
// this runs independently whenever Pangolin is acting as the
|
||||||
|
// authoritative DNS server, regardless of cert_mode.
|
||||||
|
async startDnsJobs(): Promise<void> {
|
||||||
|
if (this.dnsRunning) {
|
||||||
|
logger.warn("DNS validation job scheduler is already running");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dnsRunning = true;
|
||||||
|
logger.info("Starting DNS validation job scheduler");
|
||||||
|
|
||||||
|
const dnsValidationState = { active: false };
|
||||||
|
const reverifyState = { active: false };
|
||||||
|
|
||||||
|
const runDnsValidation = this.runExclusive(
|
||||||
|
() => dnsValidator.validateAll(),
|
||||||
|
dnsValidationState,
|
||||||
|
"validating DNS records"
|
||||||
|
);
|
||||||
|
const runReverify = this.runExclusive(
|
||||||
|
() => domainReverifier.reverifyAll(),
|
||||||
|
reverifyState,
|
||||||
|
"reverifying domains"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schedule DNS validation
|
||||||
|
const dnsValidationInterval = setInterval(
|
||||||
|
runDnsValidation,
|
||||||
|
config.getRawConfig().acme?.dns_check_interval_ms ?? 60000
|
||||||
|
);
|
||||||
|
|
||||||
|
// Schedule periodic reverification of already-verified domains
|
||||||
|
const reverifyInterval = setInterval(
|
||||||
|
runReverify,
|
||||||
|
config.getRawConfig().acme?.domain_reverification_interval_ms ??
|
||||||
|
3600000
|
||||||
|
);
|
||||||
|
|
||||||
|
this.dnsIntervals.push(dnsValidationInterval, reverifyInterval);
|
||||||
|
|
||||||
|
// Run an initial validation pass shortly after startup
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await runDnsValidation();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error in initial DNS validation:", error);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
logger.info("DNS validation job scheduler started successfully");
|
||||||
|
}
|
||||||
|
|
||||||
|
async stop(): Promise<void> {
|
||||||
|
if (this.certRunning) {
|
||||||
|
logger.info("Stopping certificate job scheduler");
|
||||||
|
this.certRunning = false;
|
||||||
|
this.certIntervals.forEach((interval) => clearInterval(interval));
|
||||||
|
this.certIntervals = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.dnsRunning) {
|
||||||
|
logger.info("Stopping DNS validation job scheduler");
|
||||||
|
this.dnsRunning = false;
|
||||||
|
this.dnsIntervals.forEach((interval) => clearInterval(interval));
|
||||||
|
this.dnsIntervals = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning(): boolean {
|
||||||
|
return this.certRunning || this.dnsRunning;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const jobScheduler = new JobScheduler();
|
||||||
@@ -146,12 +146,20 @@ export class PrivateConfig {
|
|||||||
process.env.USE_PANGOLIN_DNS =
|
process.env.USE_PANGOLIN_DNS =
|
||||||
this.rawPrivateConfig.flags.use_pangolin_dns.toString();
|
this.rawPrivateConfig.flags.use_pangolin_dns.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.rawPrivateConfig.acme?.cert_mode) {
|
||||||
|
process.env.CERT_MODE = this.rawPrivateConfig.acme.cert_mode;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public getRawPrivateConfig() {
|
public getRawPrivateConfig() {
|
||||||
return this.rawPrivateConfig;
|
return this.rawPrivateConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getRawConfig() {
|
||||||
|
return this.getRawPrivateConfig();
|
||||||
|
}
|
||||||
|
|
||||||
// `flags.enable_acme_cert_sync`, `flags.disable_private_http_placeholder`,
|
// `flags.enable_acme_cert_sync`, `flags.disable_private_http_placeholder`,
|
||||||
// and `acme` used to live in the private config file. They now live in
|
// and `acme` used to live in the private config file. They now live in
|
||||||
// the public config file. If an operator still has them set in the
|
// the public config file. If an operator still has them set in the
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/*
|
||||||
|
* 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 { build } from "@server/build";
|
||||||
|
import privateConfig from "#private/lib/config";
|
||||||
|
|
||||||
|
export function createCname(domainId: string, baseDomain: string) {
|
||||||
|
if (!privateConfig.getRawPrivateConfig().dns?.cname_extension) {
|
||||||
|
throw new Error("CNAME extension not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
let cnameRecords = [
|
||||||
|
{
|
||||||
|
value: `${domainId}.${privateConfig.getRawPrivateConfig().dns?.cname_extension}`,
|
||||||
|
baseDomain: baseDomain
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: `_acme-challenge.${domainId}.${privateConfig.getRawPrivateConfig().dns?.cname_extension}`,
|
||||||
|
baseDomain: `_acme-challenge.${baseDomain}`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
return cnameRecords;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createNs() {
|
||||||
|
if (!privateConfig.getRawPrivateConfig().dns?.nameserver_name) {
|
||||||
|
throw new Error("Nameservers not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nsRecords = [
|
||||||
|
privateConfig.getRawPrivateConfig().dns?.nameserver_name,
|
||||||
|
...(privateConfig.getRawPrivateConfig().dns?.alternate_nameservers ||
|
||||||
|
[])
|
||||||
|
] as string[];
|
||||||
|
return nsRecords;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* 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 "./server";
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -18,6 +18,7 @@ import { eq } from "drizzle-orm";
|
|||||||
import { sendToClient } from "#private/routers/ws";
|
import { sendToClient } from "#private/routers/ws";
|
||||||
import privateConfig from "#private/lib/config";
|
import privateConfig from "#private/lib/config";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import { hasExitNodeCheckedIn } from "@server/lib/exitNodes";
|
||||||
|
|
||||||
interface ExitNodeRequest {
|
interface ExitNodeRequest {
|
||||||
remoteType?: string;
|
remoteType?: string;
|
||||||
@@ -138,13 +139,19 @@ export async function sendToExitNode(
|
|||||||
|
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error)) {
|
const message = axios.isAxiosError(error)
|
||||||
logger.error(
|
? `Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${hostname} (status: ${error.response?.status}): ${error.message}`
|
||||||
`Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${hostname} (status: ${error.response?.status}): ${error.message}`
|
: `Error making ${method} request for exit node at ${hostname}: ${error}`;
|
||||||
);
|
|
||||||
|
// The exit node (gerbil) may still be starting up and not yet
|
||||||
|
// reachable. Until it has checked in at least once, log this at a
|
||||||
|
// lower level since it's expected; once it has checked in, a
|
||||||
|
// connection failure is a real problem.
|
||||||
|
if (hasExitNodeCheckedIn(exitNode.exitNodeId)) {
|
||||||
|
logger.error(message);
|
||||||
} else {
|
} else {
|
||||||
logger.error(
|
logger.warn(
|
||||||
`Error making ${method} request for exit node at ${hostname}: ${error}`
|
`${message} (exit node has not checked in yet since startup, this is expected briefly)`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,6 +95,70 @@ export const privateConfigSchema = z
|
|||||||
.optional()
|
.optional()
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
dns: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().optional().default(false),
|
||||||
|
listen_port: z.number().int().positive().optional().default(53),
|
||||||
|
nameserver_name: z.string(),
|
||||||
|
cname_extension: z.string(),
|
||||||
|
site_extension: z.string().optional(),
|
||||||
|
cname_alternate_extensions: z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.default([]),
|
||||||
|
alternate_nameservers: z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.default([]),
|
||||||
|
rate_limit: z
|
||||||
|
.object({
|
||||||
|
enabled: z.boolean().optional().default(true),
|
||||||
|
window_ms: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1000)
|
||||||
|
.max(600000)
|
||||||
|
.optional()
|
||||||
|
.default(60000),
|
||||||
|
max_requests: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(50)
|
||||||
|
.max(100000)
|
||||||
|
.optional()
|
||||||
|
.default(1200),
|
||||||
|
max_requests_per_query_type: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(10)
|
||||||
|
.max(50000)
|
||||||
|
.optional()
|
||||||
|
.default(600)
|
||||||
|
})
|
||||||
|
.default({
|
||||||
|
enabled: true,
|
||||||
|
window_ms: 60000,
|
||||||
|
max_requests: 1200,
|
||||||
|
max_requests_per_query_type: 600
|
||||||
|
}),
|
||||||
|
static_records: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
domain: z.string(),
|
||||||
|
type: z.enum(["TXT", "CNAME", "A", "NS"]),
|
||||||
|
value: z.string(),
|
||||||
|
ttl: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.optional()
|
||||||
|
.default(300)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.default([])
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
gerbil: z
|
gerbil: z
|
||||||
.object({
|
.object({
|
||||||
local_exit_node_reachable_at: z
|
local_exit_node_reachable_at: z
|
||||||
@@ -125,15 +189,86 @@ export const privateConfigSchema = z
|
|||||||
})
|
})
|
||||||
.optional()
|
.optional()
|
||||||
.prefault({}),
|
.prefault({}),
|
||||||
// @deprecated Moved to the public config file as `acme`
|
acme: z
|
||||||
|
.object({
|
||||||
|
cert_mode: z
|
||||||
|
.enum(["traefik", "pangolin"])
|
||||||
|
.optional()
|
||||||
|
.default("traefik"),
|
||||||
|
enable_acme_client: z.boolean().optional().default(false),
|
||||||
|
// @deprecated Moved to the public config file
|
||||||
// (server/lib/readConfigFile.ts). Kept here only so existing private
|
// (server/lib/readConfigFile.ts). Kept here only so existing private
|
||||||
// config files keep parsing; any value set here is migrated into the
|
// config files keep parsing; any value set here is migrated into the
|
||||||
// public config at startup by PrivateConfig (server/private/lib/config.ts).
|
// public config at startup by PrivateConfig (server/private/lib/config.ts).
|
||||||
acme: z
|
|
||||||
.object({
|
|
||||||
acme_json_path: z.string().optional(),
|
acme_json_path: z.string().optional(),
|
||||||
|
// @deprecated Moved to the public config file
|
||||||
|
// (server/lib/readConfigFile.ts). Kept here only so existing private
|
||||||
|
// config files keep parsing; any value set here is migrated into the
|
||||||
|
// public config at startup by PrivateConfig (server/private/lib/config.ts).
|
||||||
acme_http_endpoint: z.string().optional(),
|
acme_http_endpoint: z.string().optional(),
|
||||||
sync_interval_ms: z.number().optional()
|
// @deprecated Moved to the public config file
|
||||||
|
// (server/lib/readConfigFile.ts). Kept here only so existing private
|
||||||
|
// config files keep parsing; any value set here is migrated into the
|
||||||
|
// public config at startup by PrivateConfig (server/private/lib/config.ts).
|
||||||
|
sync_interval_ms: z.number().optional(),
|
||||||
|
acme_directory_url: z
|
||||||
|
.string()
|
||||||
|
.url()
|
||||||
|
.default("https://acme-v02.api.letsencrypt.org/directory"),
|
||||||
|
contact_email: z.string().email().optional(),
|
||||||
|
acme_account_key_path: z
|
||||||
|
.string()
|
||||||
|
.default("./config/account.key"),
|
||||||
|
challenge_ttl_ms: z.number().int().positive().default(300000),
|
||||||
|
renewal_check_interval_ms: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(3600000),
|
||||||
|
new_cert_check_interval_ms: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(5000),
|
||||||
|
// Kept safely under Let's Encrypt's ~20 req/s limit since this
|
||||||
|
// budget is shared across all pops workers and only covers the
|
||||||
|
// request-issuing calls we make directly (not every request
|
||||||
|
// acme-client makes internally, e.g. while polling for
|
||||||
|
// challenge/order status).
|
||||||
|
acme_requests_per_second: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(15),
|
||||||
|
dns_check_interval_ms: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(60000),
|
||||||
|
domain_reverification_interval_ms: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(3600000), // 1 hour — how often to run the reverification pass
|
||||||
|
domain_reverification_window_ms: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(259200000), // 72 hours — how old checkedAt must be before rechecking
|
||||||
|
domain_reverification_batch_size: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(20), // max domains to recheck per pass
|
||||||
|
dns_resolvers: z
|
||||||
|
.array(z.string())
|
||||||
|
.optional()
|
||||||
|
.default([
|
||||||
|
"8.8.8.8",
|
||||||
|
"1.1.1.1",
|
||||||
|
"9.9.9.9",
|
||||||
|
"208.67.222.222"
|
||||||
|
])
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
branding: z
|
branding: z
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ export async function getTraefikConfig(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let validCerts: CertificateResult[] = [];
|
let validCerts: CertificateResult[] = [];
|
||||||
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
if (privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin") {
|
||||||
// create a list of all domains to get certs for
|
// create a list of all domains to get certs for
|
||||||
const domains = new Set<string>();
|
const domains = new Set<string>();
|
||||||
for (const resource of resourcesMap.values()) {
|
for (const resource of resourcesMap.values()) {
|
||||||
@@ -522,7 +522,10 @@ export async function getTraefikConfig(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let tls = {};
|
let tls = {};
|
||||||
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
if (
|
||||||
|
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||||
|
"pangolin"
|
||||||
|
) {
|
||||||
tls = buildWildcardTls({
|
tls = buildWildcardTls({
|
||||||
fullDomain,
|
fullDomain,
|
||||||
hasSubdomain: !!resource.subdomain,
|
hasSubdomain: !!resource.subdomain,
|
||||||
@@ -789,7 +792,8 @@ export async function getTraefikConfig(
|
|||||||
preferWildcardCert
|
preferWildcardCert
|
||||||
}) => {
|
}) => {
|
||||||
if (
|
if (
|
||||||
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||||
|
"pangolin"
|
||||||
) {
|
) {
|
||||||
return buildWildcardTls({
|
return buildWildcardTls({
|
||||||
fullDomain,
|
fullDomain,
|
||||||
@@ -832,7 +836,8 @@ export async function getTraefikConfig(
|
|||||||
redirectHttpsMiddlewareName,
|
redirectHttpsMiddlewareName,
|
||||||
resolveTls: (fullDomain) => {
|
resolveTls: (fullDomain) => {
|
||||||
if (
|
if (
|
||||||
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||||
|
"pangolin"
|
||||||
) {
|
) {
|
||||||
// siteResource aliases don't have a per-domain cert
|
// siteResource aliases don't have a per-domain cert
|
||||||
// resolver stored, so always fall back to the global
|
// resolver stored, so always fall back to the global
|
||||||
@@ -924,7 +929,10 @@ export async function getTraefikConfig(
|
|||||||
const rule = buildHostRule(fullDomain, ir.wildcard);
|
const rule = buildHostRule(fullDomain, ir.wildcard);
|
||||||
|
|
||||||
let tls: any = {};
|
let tls: any = {};
|
||||||
if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
if (
|
||||||
|
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||||
|
"pangolin"
|
||||||
|
) {
|
||||||
tls = buildWildcardTls({
|
tls = buildWildcardTls({
|
||||||
fullDomain,
|
fullDomain,
|
||||||
hasSubdomain: !!ir.subdomain,
|
hasSubdomain: !!ir.subdomain,
|
||||||
@@ -1005,7 +1013,8 @@ export async function getTraefikConfig(
|
|||||||
|
|
||||||
let tls: any = {};
|
let tls: any = {};
|
||||||
if (
|
if (
|
||||||
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||||
|
"pangolin"
|
||||||
) {
|
) {
|
||||||
// siteResource aliases don't have a per-domain cert
|
// siteResource aliases don't have a per-domain cert
|
||||||
// resolver stored, so always fall back to the global
|
// resolver stored, so always fall back to the global
|
||||||
@@ -1080,7 +1089,7 @@ export async function getTraefikConfig(
|
|||||||
.where(eq(exitNodes.exitNodeId, exitNodeId));
|
.where(eq(exitNodes.exitNodeId, exitNodeId));
|
||||||
|
|
||||||
let validCertsLoginPages: CertificateResult[] = [];
|
let validCertsLoginPages: CertificateResult[] = [];
|
||||||
if (privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) {
|
if (privateConfig.getRawPrivateConfig().acme?.cert_mode == "pangolin") {
|
||||||
// create a list of all domains to get certs for
|
// create a list of all domains to get certs for
|
||||||
const domains = new Set<string>();
|
const domains = new Set<string>();
|
||||||
for (const lp of exitNodeLoginPages) {
|
for (const lp of exitNodeLoginPages) {
|
||||||
@@ -1126,7 +1135,8 @@ export async function getTraefikConfig(
|
|||||||
|
|
||||||
const tls = {};
|
const tls = {};
|
||||||
if (
|
if (
|
||||||
!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns
|
privateConfig.getRawPrivateConfig().acme?.cert_mode !=
|
||||||
|
"pangolin"
|
||||||
) {
|
) {
|
||||||
// TODO: we need to add the wildcard logic here too
|
// TODO: we need to add the wildcard logic here too
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { db, HostMeta, sites, users } from "@server/db";
|
import { db, HostMeta, sites, users } from "@server/db";
|
||||||
import { hostMeta, licenseKey } from "@server/db";
|
import { hostMeta, licenseKey } from "@server/db";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { createLocalCache } from "@server/lib/createLocalCache";
|
import NodeCache from "node-cache";
|
||||||
import { validateJWT } from "./licenseJwt";
|
import { validateJWT } from "./licenseJwt";
|
||||||
import { count, eq } from "drizzle-orm";
|
import { count, eq } from "drizzle-orm";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
@@ -65,8 +65,8 @@ export class License {
|
|||||||
private validationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/validate`;
|
private validationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/validate`;
|
||||||
private activationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/activate`;
|
private activationServerUrl = `${this.serverBaseUrl}/api/v1/license/enterprise/activate`;
|
||||||
|
|
||||||
private statusCache = createLocalCache();
|
private statusCache = new NodeCache();
|
||||||
private licenseKeyCache = createLocalCache();
|
private licenseKeyCache = new NodeCache();
|
||||||
|
|
||||||
private statusKey = "status";
|
private statusKey = "status";
|
||||||
private serverSecret!: string;
|
private serverSecret!: string;
|
||||||
@@ -179,7 +179,7 @@ LQIDAQAB
|
|||||||
status.isHostLicensed = false;
|
status.isHostLicensed = false;
|
||||||
// Invalidate all and set new cache (empty)
|
// Invalidate all and set new cache (empty)
|
||||||
this.licenseKeyCache.flushAll();
|
this.licenseKeyCache.flushAll();
|
||||||
this.statusCache.set(this.statusKey, status, 0);
|
this.statusCache.set(this.statusKey, status);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -389,7 +389,7 @@ LQIDAQAB
|
|||||||
// Invalidate old cache and set new cache
|
// Invalidate old cache and set new cache
|
||||||
this.licenseKeyCache.flushAll();
|
this.licenseKeyCache.flushAll();
|
||||||
for (const [key, value] of newCache.entries()) {
|
for (const [key, value] of newCache.entries()) {
|
||||||
this.licenseKeyCache.set(key, value, 0);
|
this.licenseKeyCache.set<LicenseKeyCache>(key, value);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error checking license status:");
|
logger.error("Error checking license status:");
|
||||||
@@ -398,7 +398,7 @@ LQIDAQAB
|
|||||||
this.checkInProgress = false;
|
this.checkInProgress = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.statusCache.set(this.statusKey, status, 0);
|
this.statusCache.set(this.statusKey, status);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
import { getRandomItemInArray } from "@app/lib/getRandomItemInArray";
|
import { getRandomItemInArray } from "@app/lib/getRandomItemInArray";
|
||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { processTestAlerts } from "@server/private/lib/alerts/processTestAlerts";
|
import { processTestAlerts } from "#private/lib/alerts/processTestAlerts";
|
||||||
import { type AlertAction } from "@server/routers/alertRule/types";
|
import { type AlertAction } from "@server/routers/alertRule/types";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import { NextFunction, Request, Response } from "express";
|
import { NextFunction, Request, Response } from "express";
|
||||||
|
|||||||
@@ -33,8 +33,11 @@ import { OpenAPITags, registry } from "@server/openApi";
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import { encrypt } from "@server/lib/crypto";
|
import { encrypt } from "@server/lib/crypto";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
import { HC_EVENT_TYPES, SITE_EVENT_TYPES, RESOURCE_EVENT_TYPES } from "./createAlertRule";
|
import {
|
||||||
import { invalidateAllRemoteExitNodeSessions } from "@server/private/auth/sessions/remoteExitNode";
|
HC_EVENT_TYPES,
|
||||||
|
SITE_EVENT_TYPES,
|
||||||
|
RESOURCE_EVENT_TYPES
|
||||||
|
} from "./createAlertRule";
|
||||||
|
|
||||||
const paramsSchema = z
|
const paramsSchema = z
|
||||||
.object({
|
.object({
|
||||||
@@ -85,35 +88,57 @@ const bodySchema = z
|
|||||||
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
|
const isHcEvent = (HC_EVENT_TYPES as readonly string[]).includes(
|
||||||
val.eventType
|
val.eventType
|
||||||
);
|
);
|
||||||
const isResourceEvent = (RESOURCE_EVENT_TYPES as readonly string[]).includes(
|
const isResourceEvent = (
|
||||||
val.eventType
|
RESOURCE_EVENT_TYPES as readonly string[]
|
||||||
);
|
).includes(val.eventType);
|
||||||
|
|
||||||
if (isSiteEvent && val.siteIds !== undefined && val.siteIds.length === 0 && !val.allSites) {
|
if (
|
||||||
|
isSiteEvent &&
|
||||||
|
val.siteIds !== undefined &&
|
||||||
|
val.siteIds.length === 0 &&
|
||||||
|
!val.allSites
|
||||||
|
) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: "At least one siteId is required for site event types when allSites is false",
|
message:
|
||||||
|
"At least one siteId is required for site event types when allSites is false",
|
||||||
path: ["siteIds"]
|
path: ["siteIds"]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isHcEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length === 0 && !val.allHealthChecks) {
|
if (
|
||||||
|
isHcEvent &&
|
||||||
|
val.healthCheckIds !== undefined &&
|
||||||
|
val.healthCheckIds.length === 0 &&
|
||||||
|
!val.allHealthChecks
|
||||||
|
) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: "At least one healthCheckId is required for health check event types when allHealthChecks is false",
|
message:
|
||||||
|
"At least one healthCheckId is required for health check event types when allHealthChecks is false",
|
||||||
path: ["healthCheckIds"]
|
path: ["healthCheckIds"]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isResourceEvent && val.resourceIds !== undefined && val.resourceIds.length === 0 && !val.allResources) {
|
if (
|
||||||
|
isResourceEvent &&
|
||||||
|
val.resourceIds !== undefined &&
|
||||||
|
val.resourceIds.length === 0 &&
|
||||||
|
!val.allResources
|
||||||
|
) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: "At least one resourceId is required for resource event types when allResources is false",
|
message:
|
||||||
|
"At least one resourceId is required for resource event types when allResources is false",
|
||||||
path: ["resourceIds"]
|
path: ["resourceIds"]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSiteEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
|
if (
|
||||||
|
isSiteEvent &&
|
||||||
|
val.healthCheckIds !== undefined &&
|
||||||
|
val.healthCheckIds.length > 0
|
||||||
|
) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: "healthCheckIds must not be set for site event types",
|
message: "healthCheckIds must not be set for site event types",
|
||||||
@@ -129,7 +154,11 @@ const bodySchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isResourceEvent && val.siteIds !== undefined && val.siteIds.length > 0) {
|
if (
|
||||||
|
isResourceEvent &&
|
||||||
|
val.siteIds !== undefined &&
|
||||||
|
val.siteIds.length > 0
|
||||||
|
) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: "siteIds must not be set for resource event types",
|
message: "siteIds must not be set for resource event types",
|
||||||
@@ -137,10 +166,15 @@ const bodySchema = z
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isResourceEvent && val.healthCheckIds !== undefined && val.healthCheckIds.length > 0) {
|
if (
|
||||||
|
isResourceEvent &&
|
||||||
|
val.healthCheckIds !== undefined &&
|
||||||
|
val.healthCheckIds.length > 0
|
||||||
|
) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
message: "healthCheckIds must not be set for resource event types",
|
message:
|
||||||
|
"healthCheckIds must not be set for resource event types",
|
||||||
path: ["healthCheckIds"]
|
path: ["healthCheckIds"]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -153,7 +187,6 @@ const UpdateAlertRuleResponseDataSchema = z.object({
|
|||||||
alertRuleId: z.number()
|
alertRuleId: z.number()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
registry.registerPath({
|
registry.registerPath({
|
||||||
method: "post",
|
method: "post",
|
||||||
path: "/org/{orgId}/alert-rule/{alertRuleId}",
|
path: "/org/{orgId}/alert-rule/{alertRuleId}",
|
||||||
@@ -174,7 +207,9 @@ registry.registerPath({
|
|||||||
description: "Successful response",
|
description: "Successful response",
|
||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
schema: createApiResponseSchema(UpdateAlertRuleResponseDataSchema)
|
schema: createApiResponseSchema(
|
||||||
|
UpdateAlertRuleResponseDataSchema
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -250,9 +285,11 @@ export async function updateAlertRule(
|
|||||||
if (name !== undefined) updateData.name = name;
|
if (name !== undefined) updateData.name = name;
|
||||||
if (eventType !== undefined) updateData.eventType = eventType;
|
if (eventType !== undefined) updateData.eventType = eventType;
|
||||||
if (enabled !== undefined) updateData.enabled = enabled;
|
if (enabled !== undefined) updateData.enabled = enabled;
|
||||||
if (cooldownSeconds !== undefined) updateData.cooldownSeconds = cooldownSeconds;
|
if (cooldownSeconds !== undefined)
|
||||||
|
updateData.cooldownSeconds = cooldownSeconds;
|
||||||
if (allSites !== undefined) updateData.allSites = allSites;
|
if (allSites !== undefined) updateData.allSites = allSites;
|
||||||
if (allHealthChecks !== undefined) updateData.allHealthChecks = allHealthChecks;
|
if (allHealthChecks !== undefined)
|
||||||
|
updateData.allHealthChecks = allHealthChecks;
|
||||||
if (allResources !== undefined) updateData.allResources = allResources;
|
if (allResources !== undefined) updateData.allResources = allResources;
|
||||||
|
|
||||||
await db
|
await db
|
||||||
@@ -273,7 +310,11 @@ export async function updateAlertRule(
|
|||||||
|
|
||||||
// Only insert junction rows when allSites is not true
|
// Only insert junction rows when allSites is not true
|
||||||
const effectiveAllSites = allSites ?? false;
|
const effectiveAllSites = allSites ?? false;
|
||||||
if (!effectiveAllSites && siteIds !== undefined && siteIds.length > 0) {
|
if (
|
||||||
|
!effectiveAllSites &&
|
||||||
|
siteIds !== undefined &&
|
||||||
|
siteIds.length > 0
|
||||||
|
) {
|
||||||
await db.insert(alertSites).values(
|
await db.insert(alertSites).values(
|
||||||
siteIds.map((siteId) => ({
|
siteIds.map((siteId) => ({
|
||||||
alertRuleId,
|
alertRuleId,
|
||||||
@@ -290,7 +331,11 @@ export async function updateAlertRule(
|
|||||||
.where(eq(alertHealthChecks.alertRuleId, alertRuleId));
|
.where(eq(alertHealthChecks.alertRuleId, alertRuleId));
|
||||||
|
|
||||||
const effectiveAllHealthChecks = allHealthChecks ?? false;
|
const effectiveAllHealthChecks = allHealthChecks ?? false;
|
||||||
if (!effectiveAllHealthChecks && healthCheckIds !== undefined && healthCheckIds.length > 0) {
|
if (
|
||||||
|
!effectiveAllHealthChecks &&
|
||||||
|
healthCheckIds !== undefined &&
|
||||||
|
healthCheckIds.length > 0
|
||||||
|
) {
|
||||||
await db.insert(alertHealthChecks).values(
|
await db.insert(alertHealthChecks).values(
|
||||||
healthCheckIds.map((healthCheckId) => ({
|
healthCheckIds.map((healthCheckId) => ({
|
||||||
alertRuleId,
|
alertRuleId,
|
||||||
@@ -307,7 +352,11 @@ export async function updateAlertRule(
|
|||||||
.where(eq(alertResources.alertRuleId, alertRuleId));
|
.where(eq(alertResources.alertRuleId, alertRuleId));
|
||||||
|
|
||||||
const effectiveAllResources = allResources ?? false;
|
const effectiveAllResources = allResources ?? false;
|
||||||
if (!effectiveAllResources && resourceIds !== undefined && resourceIds.length > 0) {
|
if (
|
||||||
|
!effectiveAllResources &&
|
||||||
|
resourceIds !== undefined &&
|
||||||
|
resourceIds.length > 0
|
||||||
|
) {
|
||||||
await db.insert(alertResources).values(
|
await db.insert(alertResources).values(
|
||||||
resourceIds.map((resourceId) => ({
|
resourceIds.map((resourceId) => ({
|
||||||
alertRuleId,
|
alertRuleId,
|
||||||
@@ -392,7 +441,10 @@ export async function updateAlertRule(
|
|||||||
webhookActions.map((wa) => ({
|
webhookActions.map((wa) => ({
|
||||||
alertRuleId,
|
alertRuleId,
|
||||||
webhookUrl: wa.webhookUrl,
|
webhookUrl: wa.webhookUrl,
|
||||||
config: wa.config != null ? encrypt(wa.config, serverSecret) : null,
|
config:
|
||||||
|
wa.config != null
|
||||||
|
? encrypt(wa.config, serverSecret)
|
||||||
|
: null,
|
||||||
enabled: wa.enabled
|
enabled: wa.enabled
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ export async function clearInstanceName(
|
|||||||
next: NextFunction
|
next: NextFunction
|
||||||
): Promise<any> {
|
): Promise<any> {
|
||||||
try {
|
try {
|
||||||
const parsedParams = clearInstanceNameParamsSchema.safeParse(req.params);
|
const parsedParams = clearInstanceNameParamsSchema.safeParse(
|
||||||
|
req.params
|
||||||
|
);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
@@ -63,7 +65,8 @@ export async function clearInstanceName(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
data.status || HttpCode.BAD_REQUEST,
|
data.status || HttpCode.BAD_REQUEST,
|
||||||
data.message || "Failed to clear instance name from Fossorial API"
|
data.message ||
|
||||||
|
"Failed to clear server ID from Fossorial API"
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -72,7 +75,7 @@ export async function clearInstanceName(
|
|||||||
data: null,
|
data: null,
|
||||||
success: true,
|
success: true,
|
||||||
error: false,
|
error: false,
|
||||||
message: "Instance name cleared successfully",
|
message: "Server ID cleared successfully",
|
||||||
status: HttpCode.OK
|
status: HttpCode.OK
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -80,7 +83,7 @@ export async function clearInstanceName(
|
|||||||
return next(
|
return next(
|
||||||
createHttpError(
|
createHttpError(
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
HttpCode.INTERNAL_SERVER_ERROR,
|
||||||
"An error occurred while clearing the instance name."
|
"An error occurred while clearing the server ID."
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { db, ExitNode, exitNodes } from "@server/db";
|
import { db, ExitNode, exitNodes } from "@server/db";
|
||||||
import { getUniqueExitNodeEndpointName } from "@server/db/names";
|
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import privateConfig from "#private/lib/config";
|
||||||
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
|
import { getNextAvailableSubnet } from "@server/lib/exitNodes";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
@@ -45,6 +45,8 @@ export async function createExitNode(
|
|||||||
.values({
|
.values({
|
||||||
publicKey,
|
publicKey,
|
||||||
endpoint: config.getRawConfig().gerbil.base_endpoint,
|
endpoint: config.getRawConfig().gerbil.base_endpoint,
|
||||||
|
region:
|
||||||
|
privateConfig.getRawPrivateConfig().app.region || null,
|
||||||
address,
|
address,
|
||||||
listenPort,
|
listenPort,
|
||||||
online: true,
|
online: true,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
validateRemoteExitNodeSessionToken,
|
validateRemoteExitNodeSessionToken,
|
||||||
EXPIRES
|
EXPIRES
|
||||||
} from "#private/auth/sessions/remoteExitNode";
|
} from "#private/auth/sessions/remoteExitNode";
|
||||||
import { getOrCreateCachedToken } from "@server/private/lib/tokenCache";
|
import { getOrCreateCachedToken } from "#private/lib/tokenCache";
|
||||||
import { verifyPassword } from "@server/auth/password";
|
import { verifyPassword } from "@server/auth/password";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
|||||||
@@ -691,10 +691,8 @@ export async function verifyResourceSession(
|
|||||||
);
|
);
|
||||||
|
|
||||||
resourceSession = result?.resourceSession;
|
resourceSession = result?.resourceSession;
|
||||||
if (resourceSession) {
|
|
||||||
localCache.set(sessionCacheKey, resourceSession, 5);
|
localCache.set(sessionCacheKey, resourceSession, 5);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (resourceSession?.isRequestToken) {
|
if (resourceSession?.isRequestToken) {
|
||||||
logger.debug(
|
logger.debug(
|
||||||
@@ -1123,10 +1121,8 @@ async function allowAccessToken(
|
|||||||
resource.resourceId
|
resource.resourceId
|
||||||
);
|
);
|
||||||
resourceSession = result?.resourceSession;
|
resourceSession = result?.resourceSession;
|
||||||
if (resourceSession) {
|
|
||||||
localCache.set(sessionCacheKey, resourceSession, 5);
|
localCache.set(sessionCacheKey, resourceSession, 5);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
resourceSession &&
|
resourceSession &&
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { LimitId } from "@server/lib/billing";
|
|||||||
import { isSecondLevelDomain, isValidDomain } from "@server/lib/validators";
|
import { isSecondLevelDomain, isValidDomain } from "@server/lib/validators";
|
||||||
import { build } from "@server/build";
|
import { build } from "@server/build";
|
||||||
import config from "@server/lib/config";
|
import config from "@server/lib/config";
|
||||||
|
import { createNs, createCname } from "#dynamic/lib/dns/generateDomains";
|
||||||
|
|
||||||
const paramsSchema = z.strictObject({
|
const paramsSchema = z.strictObject({
|
||||||
orgId: z.string()
|
orgId: z.string()
|
||||||
@@ -283,8 +284,7 @@ export async function createOrgDomain(
|
|||||||
|
|
||||||
// TODO: This needs to be cross region and not hardcoded
|
// TODO: This needs to be cross region and not hardcoded
|
||||||
if (type === "ns") {
|
if (type === "ns") {
|
||||||
nsRecords = config.getRawConfig().dns.nameservers as string[];
|
nsRecords = createNs();
|
||||||
|
|
||||||
// Save NS records to database
|
// Save NS records to database
|
||||||
for (const nsValue of nsRecords) {
|
for (const nsValue of nsRecords) {
|
||||||
recordsToInsert.push({
|
recordsToInsert.push({
|
||||||
@@ -296,16 +296,7 @@ export async function createOrgDomain(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (type === "cname") {
|
} else if (type === "cname") {
|
||||||
cnameRecords = [
|
cnameRecords = createCname(domainId, baseDomain);
|
||||||
{
|
|
||||||
value: `${domainId}.${config.getRawConfig().dns.cname_extension}`,
|
|
||||||
baseDomain: baseDomain
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: `_acme-challenge.${domainId}.${config.getRawConfig().dns.cname_extension}`,
|
|
||||||
baseDomain: `_acme-challenge.${baseDomain}`
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// Save CNAME records to database
|
// Save CNAME records to database
|
||||||
for (const cnameRecord of cnameRecords) {
|
for (const cnameRecord of cnameRecords) {
|
||||||
|
|||||||
@@ -87,12 +87,6 @@ authenticated.get("/org/checkId", org.checkId);
|
|||||||
authenticated.put("/org", getUserOrgs, org.createOrg);
|
authenticated.put("/org", getUserOrgs, org.createOrg);
|
||||||
|
|
||||||
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
|
authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs);
|
||||||
authenticated.get("/admin/orgs", verifyUserIsServerAdmin, org.adminListOrgs);
|
|
||||||
authenticated.delete(
|
|
||||||
"/admin/org/:orgId",
|
|
||||||
verifyUserIsServerAdmin,
|
|
||||||
org.adminDeleteOrg
|
|
||||||
);
|
|
||||||
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
|
authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs);
|
||||||
|
|
||||||
authenticated.get(
|
authenticated.get(
|
||||||
@@ -1384,12 +1378,6 @@ if (build !== "saas") {
|
|||||||
user.adminGeneratePasswordResetCode
|
user.adminGeneratePasswordResetCode
|
||||||
);
|
);
|
||||||
|
|
||||||
authenticated.post(
|
|
||||||
"/user/:userId/server-admin",
|
|
||||||
verifyUserIsServerAdmin,
|
|
||||||
user.adminSetServerAdmin
|
|
||||||
);
|
|
||||||
|
|
||||||
authenticated.delete(
|
authenticated.delete(
|
||||||
"/user/:userId",
|
"/user/:userId",
|
||||||
verifyUserIsServerAdmin,
|
verifyUserIsServerAdmin,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import config from "@server/lib/config";
|
|||||||
import { fromError } from "zod-validation-error";
|
import { fromError } from "zod-validation-error";
|
||||||
import { getAllowedIps } from "../target/helpers";
|
import { getAllowedIps } from "../target/helpers";
|
||||||
import { createExitNode } from "#dynamic/routers/gerbil/createExitNode";
|
import { createExitNode } from "#dynamic/routers/gerbil/createExitNode";
|
||||||
|
import { markExitNodeCheckedIn } from "@server/lib/exitNodes";
|
||||||
|
|
||||||
// Define Zod schema for request validation
|
// Define Zod schema for request validation
|
||||||
const getConfigSchema = z.object({
|
const getConfigSchema = z.object({
|
||||||
@@ -65,6 +66,8 @@ export async function getConfig(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markExitNodeCheckedIn(exitNode.exitNodeId);
|
||||||
|
|
||||||
const configResponse = await generateGerbilConfig(exitNode);
|
const configResponse = await generateGerbilConfig(exitNode);
|
||||||
|
|
||||||
logger.debug("Sending config: ", configResponse);
|
logger.debug("Sending config: ", configResponse);
|
||||||
|
|||||||
@@ -522,7 +522,6 @@ async function fetchLabelsForResources(
|
|||||||
type SiteGroupRow = {
|
type SiteGroupRow = {
|
||||||
siteId: number;
|
siteId: number;
|
||||||
name: string;
|
name: string;
|
||||||
niceId: string;
|
|
||||||
type: string;
|
type: string;
|
||||||
online: boolean;
|
online: boolean;
|
||||||
itemCount: number;
|
itemCount: number;
|
||||||
@@ -557,7 +556,6 @@ async function listSiteGroups(
|
|||||||
.select({
|
.select({
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
name: sites.name,
|
name: sites.name,
|
||||||
niceId: sites.niceId,
|
|
||||||
type: sites.type,
|
type: sites.type,
|
||||||
online: sites.online,
|
online: sites.online,
|
||||||
itemCount: countDistinct(resources.resourceId)
|
itemCount: countDistinct(resources.resourceId)
|
||||||
@@ -578,13 +576,7 @@ async function listSiteGroups(
|
|||||||
|
|
||||||
const publicRows = await publicQuery
|
const publicRows = await publicQuery
|
||||||
.where(and(...publicConditions))
|
.where(and(...publicConditions))
|
||||||
.groupBy(
|
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
|
||||||
sites.siteId,
|
|
||||||
sites.name,
|
|
||||||
sites.niceId,
|
|
||||||
sites.type,
|
|
||||||
sites.online
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of publicRows) {
|
for (const row of publicRows) {
|
||||||
const existing = siteCountMap.get(row.siteId);
|
const existing = siteCountMap.get(row.siteId);
|
||||||
@@ -594,7 +586,6 @@ async function listSiteGroups(
|
|||||||
siteCountMap.set(row.siteId, {
|
siteCountMap.set(row.siteId, {
|
||||||
siteId: row.siteId,
|
siteId: row.siteId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
niceId: row.niceId,
|
|
||||||
type: row.type,
|
type: row.type,
|
||||||
online: row.online,
|
online: row.online,
|
||||||
itemCount: Number(row.itemCount)
|
itemCount: Number(row.itemCount)
|
||||||
@@ -621,7 +612,6 @@ async function listSiteGroups(
|
|||||||
.select({
|
.select({
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
name: sites.name,
|
name: sites.name,
|
||||||
niceId: sites.niceId,
|
|
||||||
type: sites.type,
|
type: sites.type,
|
||||||
online: sites.online,
|
online: sites.online,
|
||||||
itemCount: countDistinct(siteResources.siteResourceId)
|
itemCount: countDistinct(siteResources.siteResourceId)
|
||||||
@@ -648,13 +638,7 @@ async function listSiteGroups(
|
|||||||
|
|
||||||
const siteRows = await siteResourceQuery
|
const siteRows = await siteResourceQuery
|
||||||
.where(and(...siteConditions))
|
.where(and(...siteConditions))
|
||||||
.groupBy(
|
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
|
||||||
sites.siteId,
|
|
||||||
sites.name,
|
|
||||||
sites.niceId,
|
|
||||||
sites.type,
|
|
||||||
sites.online
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of siteRows) {
|
for (const row of siteRows) {
|
||||||
const existing = siteCountMap.get(row.siteId);
|
const existing = siteCountMap.get(row.siteId);
|
||||||
@@ -664,7 +648,6 @@ async function listSiteGroups(
|
|||||||
siteCountMap.set(row.siteId, {
|
siteCountMap.set(row.siteId, {
|
||||||
siteId: row.siteId,
|
siteId: row.siteId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
niceId: row.niceId,
|
|
||||||
type: row.type,
|
type: row.type,
|
||||||
online: row.online,
|
online: row.online,
|
||||||
itemCount: Number(row.itemCount)
|
itemCount: Number(row.itemCount)
|
||||||
@@ -1078,43 +1061,6 @@ export async function listLauncherGroupsForUser(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toLauncherSiteInfo(row: {
|
|
||||||
siteId: number | null;
|
|
||||||
siteName: string | null;
|
|
||||||
siteNiceId: string | null;
|
|
||||||
siteType: string | null;
|
|
||||||
siteOnline: boolean | null;
|
|
||||||
}): LauncherSiteInfo | null {
|
|
||||||
if (
|
|
||||||
row.siteId == null ||
|
|
||||||
row.siteName == null ||
|
|
||||||
row.siteNiceId == null ||
|
|
||||||
row.siteType == null
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
siteId: row.siteId,
|
|
||||||
name: row.siteName,
|
|
||||||
niceId: row.siteNiceId,
|
|
||||||
type: row.siteType,
|
|
||||||
online: row.siteOnline ?? undefined
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickPrimarySite(
|
|
||||||
sites: LauncherSiteInfo[],
|
|
||||||
siteIdFilter?: number
|
|
||||||
): LauncherSiteInfo | undefined {
|
|
||||||
if (sites.length === 0) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
if (siteIdFilter != null) {
|
|
||||||
return sites.find((site) => site.siteId === siteIdFilter) ?? sites[0];
|
|
||||||
}
|
|
||||||
return sites[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mapPublicResources(
|
async function mapPublicResources(
|
||||||
orgId: string,
|
orgId: string,
|
||||||
resourceIds: number[],
|
resourceIds: number[],
|
||||||
@@ -1138,7 +1084,6 @@ async function mapPublicResources(
|
|||||||
enabled: resources.enabled,
|
enabled: resources.enabled,
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
siteName: sites.name,
|
siteName: sites.name,
|
||||||
siteNiceId: sites.niceId,
|
|
||||||
siteType: sites.type,
|
siteType: sites.type,
|
||||||
siteOnline: sites.online,
|
siteOnline: sites.online,
|
||||||
exitNodeEndpoint: exitNodes.endpoint
|
exitNodeEndpoint: exitNodes.endpoint
|
||||||
@@ -1152,18 +1097,23 @@ async function mapPublicResources(
|
|||||||
inArray(resources.resourceId, resourceIds),
|
inArray(resources.resourceId, resourceIds),
|
||||||
eq(resources.orgId, orgId),
|
eq(resources.orgId, orgId),
|
||||||
eq(resources.enabled, true),
|
eq(resources.enabled, true),
|
||||||
eq(resources.status, "approved")
|
eq(resources.status, "approved"),
|
||||||
|
siteIdFilter != null
|
||||||
|
? eq(sites.siteId, siteIdFilter)
|
||||||
|
: undefined
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const byKey = new Map<string, LauncherResource>();
|
const seen = new Set<string>();
|
||||||
const siteIdsByKey = new Map<string, Set<number>>();
|
const result: LauncherResource[] = [];
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const key = `public:${row.resourceId}`;
|
const key = `public:${row.resourceId}`;
|
||||||
let item = byKey.get(key);
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
const access = formatPublicResourceAccess({
|
const access = formatPublicResourceAccess({
|
||||||
mode: row.mode,
|
mode: row.mode,
|
||||||
fullDomain: row.fullDomain,
|
fullDomain: row.fullDomain,
|
||||||
@@ -1173,7 +1123,7 @@ async function mapPublicResources(
|
|||||||
exitNodeEndpoint: row.exitNodeEndpoint
|
exitNodeEndpoint: row.exitNodeEndpoint
|
||||||
});
|
});
|
||||||
|
|
||||||
item = {
|
result.push({
|
||||||
launcherResourceKey: key,
|
launcherResourceKey: key,
|
||||||
resourceType: "public",
|
resourceType: "public",
|
||||||
resourceId: row.resourceId,
|
resourceId: row.resourceId,
|
||||||
@@ -1184,33 +1134,19 @@ async function mapPublicResources(
|
|||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
mode: row.mode,
|
mode: row.mode,
|
||||||
labels: labelMaps.byResourceId.get(row.resourceId) ?? [],
|
labels: labelMaps.byResourceId.get(row.resourceId) ?? [],
|
||||||
sites: []
|
site:
|
||||||
};
|
row.siteId != null
|
||||||
byKey.set(key, item);
|
? {
|
||||||
siteIdsByKey.set(key, new Set());
|
siteId: row.siteId,
|
||||||
|
name: row.siteName!,
|
||||||
|
type: row.siteType!,
|
||||||
|
online: row.siteOnline ?? undefined
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const site = toLauncherSiteInfo(row);
|
return result;
|
||||||
if (!site) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seenSiteIds = siteIdsByKey.get(key)!;
|
|
||||||
if (seenSiteIds.has(site.siteId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seenSiteIds.add(site.siteId);
|
|
||||||
item.sites.push(site);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of byKey.values()) {
|
|
||||||
item.sites.sort((a, b) =>
|
|
||||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
|
||||||
);
|
|
||||||
item.site = pickPrimarySite(item.sites, siteIdFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(byKey.values());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function mapSiteResources(
|
async function mapSiteResources(
|
||||||
@@ -1239,7 +1175,6 @@ async function mapSiteResources(
|
|||||||
enabled: siteResources.enabled,
|
enabled: siteResources.enabled,
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
siteName: sites.name,
|
siteName: sites.name,
|
||||||
siteNiceId: sites.niceId,
|
|
||||||
siteType: sites.type,
|
siteType: sites.type,
|
||||||
siteOnline: sites.online
|
siteOnline: sites.online
|
||||||
})
|
})
|
||||||
@@ -1254,18 +1189,23 @@ async function mapSiteResources(
|
|||||||
inArray(siteResources.siteResourceId, siteResourceIds),
|
inArray(siteResources.siteResourceId, siteResourceIds),
|
||||||
eq(siteResources.orgId, orgId),
|
eq(siteResources.orgId, orgId),
|
||||||
eq(siteResources.enabled, true),
|
eq(siteResources.enabled, true),
|
||||||
eq(siteResources.status, "approved")
|
eq(siteResources.status, "approved"),
|
||||||
|
siteIdFilter != null
|
||||||
|
? eq(sites.siteId, siteIdFilter)
|
||||||
|
: undefined
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const byKey = new Map<string, LauncherResource>();
|
const seen = new Set<string>();
|
||||||
const siteIdsByKey = new Map<string, Set<number>>();
|
const result: LauncherResource[] = [];
|
||||||
|
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
const key = `site:${row.siteResourceId}`;
|
const key = `site:${row.siteResourceId}`;
|
||||||
let item = byKey.get(key);
|
if (seen.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(key);
|
||||||
|
|
||||||
if (!item) {
|
|
||||||
const access = formatSiteResourceAccess({
|
const access = formatSiteResourceAccess({
|
||||||
mode: row.mode,
|
mode: row.mode,
|
||||||
destination: row.destination,
|
destination: row.destination,
|
||||||
@@ -1277,7 +1217,7 @@ async function mapSiteResources(
|
|||||||
aliasAddress: row.aliasAddress
|
aliasAddress: row.aliasAddress
|
||||||
});
|
});
|
||||||
|
|
||||||
item = {
|
result.push({
|
||||||
launcherResourceKey: key,
|
launcherResourceKey: key,
|
||||||
resourceType: "site",
|
resourceType: "site",
|
||||||
resourceId: row.siteResourceId,
|
resourceId: row.siteResourceId,
|
||||||
@@ -1288,35 +1228,20 @@ async function mapSiteResources(
|
|||||||
iconUrl: null,
|
iconUrl: null,
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
mode: row.mode,
|
mode: row.mode,
|
||||||
labels:
|
labels: labelMaps.bySiteResourceId.get(row.siteResourceId) ?? [],
|
||||||
labelMaps.bySiteResourceId.get(row.siteResourceId) ?? [],
|
site:
|
||||||
sites: []
|
row.siteId != null
|
||||||
};
|
? {
|
||||||
byKey.set(key, item);
|
siteId: row.siteId,
|
||||||
siteIdsByKey.set(key, new Set());
|
name: row.siteName!,
|
||||||
|
type: row.siteType!,
|
||||||
|
online: row.siteOnline ?? undefined
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const site = toLauncherSiteInfo(row);
|
return result;
|
||||||
if (!site) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const seenSiteIds = siteIdsByKey.get(key)!;
|
|
||||||
if (seenSiteIds.has(site.siteId)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
seenSiteIds.add(site.siteId);
|
|
||||||
item.sites.push(site);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of byKey.values()) {
|
|
||||||
item.sites.sort((a, b) =>
|
|
||||||
a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
|
|
||||||
);
|
|
||||||
item.site = pickPrimarySite(item.sites, siteIdFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(byKey.values());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterResourcesBySite(
|
function filterResourcesBySite(
|
||||||
@@ -1327,17 +1252,13 @@ function filterResourcesBySite(
|
|||||||
return items.filter((item) => item.mode === "inference");
|
return items.filter((item) => item.mode === "inference");
|
||||||
}
|
}
|
||||||
if (groupKey === LAUNCHER_NO_SITE_GROUP_KEY) {
|
if (groupKey === LAUNCHER_NO_SITE_GROUP_KEY) {
|
||||||
return items.filter(
|
return items.filter((item) => !item.site && item.mode !== "inference");
|
||||||
(item) => item.sites.length === 0 && item.mode !== "inference"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const siteId = Number.parseInt(groupKey, 10);
|
const siteId = Number.parseInt(groupKey, 10);
|
||||||
if (!Number.isFinite(siteId)) {
|
if (!Number.isFinite(siteId)) {
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
return items.filter((item) =>
|
return items.filter((item) => item.site?.siteId === siteId);
|
||||||
item.sites.some((site) => site.siteId === siteId)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterResourcesByLabel(
|
function filterResourcesByLabel(
|
||||||
@@ -1578,7 +1499,6 @@ async function collectAccessibleSites(
|
|||||||
.select({
|
.select({
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
name: sites.name,
|
name: sites.name,
|
||||||
niceId: sites.niceId,
|
|
||||||
type: sites.type,
|
type: sites.type,
|
||||||
online: sites.online,
|
online: sites.online,
|
||||||
itemCount: countDistinct(resources.resourceId)
|
itemCount: countDistinct(resources.resourceId)
|
||||||
@@ -1587,13 +1507,7 @@ async function collectAccessibleSites(
|
|||||||
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
.innerJoin(resources, eq(targets.resourceId, resources.resourceId))
|
||||||
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
.innerJoin(sites, eq(targets.siteId, sites.siteId))
|
||||||
.where(and(...publicConditions))
|
.where(and(...publicConditions))
|
||||||
.groupBy(
|
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
|
||||||
sites.siteId,
|
|
||||||
sites.name,
|
|
||||||
sites.niceId,
|
|
||||||
sites.type,
|
|
||||||
sites.online
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of publicRows) {
|
for (const row of publicRows) {
|
||||||
const existing = siteCountMap.get(row.siteId);
|
const existing = siteCountMap.get(row.siteId);
|
||||||
@@ -1603,7 +1517,6 @@ async function collectAccessibleSites(
|
|||||||
siteCountMap.set(row.siteId, {
|
siteCountMap.set(row.siteId, {
|
||||||
siteId: row.siteId,
|
siteId: row.siteId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
niceId: row.niceId,
|
|
||||||
type: row.type,
|
type: row.type,
|
||||||
online: row.online,
|
online: row.online,
|
||||||
itemCount: Number(row.itemCount)
|
itemCount: Number(row.itemCount)
|
||||||
@@ -1627,7 +1540,6 @@ async function collectAccessibleSites(
|
|||||||
.select({
|
.select({
|
||||||
siteId: sites.siteId,
|
siteId: sites.siteId,
|
||||||
name: sites.name,
|
name: sites.name,
|
||||||
niceId: sites.niceId,
|
|
||||||
type: sites.type,
|
type: sites.type,
|
||||||
online: sites.online,
|
online: sites.online,
|
||||||
itemCount: countDistinct(siteResources.siteResourceId)
|
itemCount: countDistinct(siteResources.siteResourceId)
|
||||||
@@ -1639,13 +1551,7 @@ async function collectAccessibleSites(
|
|||||||
)
|
)
|
||||||
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
.innerJoin(sites, eq(siteNetworks.siteId, sites.siteId))
|
||||||
.where(and(...siteConditions))
|
.where(and(...siteConditions))
|
||||||
.groupBy(
|
.groupBy(sites.siteId, sites.name, sites.type, sites.online);
|
||||||
sites.siteId,
|
|
||||||
sites.name,
|
|
||||||
sites.niceId,
|
|
||||||
sites.type,
|
|
||||||
sites.online
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const row of siteRows) {
|
for (const row of siteRows) {
|
||||||
const existing = siteCountMap.get(row.siteId);
|
const existing = siteCountMap.get(row.siteId);
|
||||||
@@ -1655,7 +1561,6 @@ async function collectAccessibleSites(
|
|||||||
siteCountMap.set(row.siteId, {
|
siteCountMap.set(row.siteId, {
|
||||||
siteId: row.siteId,
|
siteId: row.siteId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
niceId: row.niceId,
|
|
||||||
type: row.type,
|
type: row.type,
|
||||||
online: row.online,
|
online: row.online,
|
||||||
itemCount: Number(row.itemCount)
|
itemCount: Number(row.itemCount)
|
||||||
@@ -1770,7 +1675,6 @@ export async function listAccessibleLauncherSitesForUser(
|
|||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
siteId: row.siteId,
|
siteId: row.siteId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
niceId: row.niceId,
|
|
||||||
type: row.type,
|
type: row.type,
|
||||||
online: row.online
|
online: row.online
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ export type LauncherLabel = {
|
|||||||
export type LauncherSiteInfo = {
|
export type LauncherSiteInfo = {
|
||||||
siteId: number;
|
siteId: number;
|
||||||
name: string;
|
name: string;
|
||||||
niceId: string;
|
|
||||||
type: string;
|
type: string;
|
||||||
online?: boolean;
|
online?: boolean;
|
||||||
};
|
};
|
||||||
@@ -52,7 +51,6 @@ export type LauncherResource = {
|
|||||||
mode: string;
|
mode: string;
|
||||||
labels: LauncherLabel[];
|
labels: LauncherLabel[];
|
||||||
site?: LauncherSiteInfo;
|
site?: LauncherSiteInfo;
|
||||||
sites: LauncherSiteInfo[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type LauncherGroup = {
|
export type LauncherGroup = {
|
||||||
@@ -186,7 +184,8 @@ export function parseIdListParam(value: string | undefined): number[] {
|
|||||||
export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
|
export const DEFAULT_LAUNCHER_VIEW_ID = "default" as const;
|
||||||
|
|
||||||
export type LauncherViewSelection =
|
export type LauncherViewSelection =
|
||||||
{ type: "default" } | { type: "saved"; viewId: number };
|
| { type: "default" }
|
||||||
|
| { type: "saved"; viewId: number };
|
||||||
|
|
||||||
export type LauncherScaleCapabilities = {
|
export type LauncherScaleCapabilities = {
|
||||||
allowSiteGrouping: boolean;
|
allowSiteGrouping: boolean;
|
||||||
|
|||||||
@@ -1,16 +1,3 @@
|
|||||||
/*
|
|
||||||
* 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 } from "@server/db";
|
import { db } from "@server/db";
|
||||||
import { MessageHandler } from "@server/routers/ws";
|
import { MessageHandler } from "@server/routers/ws";
|
||||||
import { sites, Newt, orgs, clients, clientSitesAssociationsCache, users } from "@server/db";
|
import { sites, Newt, orgs, clients, clientSitesAssociationsCache, users } from "@server/db";
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
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 { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg";
|
|
||||||
import { db, orgs } from "@server/db";
|
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
|
|
||||||
const adminDeleteOrgSchema = z.strictObject({
|
|
||||||
orgId: z.string()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type AdminDeleteOrgResponse = {};
|
|
||||||
|
|
||||||
registry.registerPath({
|
|
||||||
method: "delete",
|
|
||||||
path: "/admin/org/{orgId}",
|
|
||||||
description: "Delete any organization in the system (server admin).",
|
|
||||||
tags: [OpenAPITags.Org],
|
|
||||||
request: {
|
|
||||||
params: adminDeleteOrgSchema
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Successful response",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: z.object({
|
|
||||||
data: z.record(z.string(), z.any()).nullable(),
|
|
||||||
success: z.boolean(),
|
|
||||||
error: z.boolean(),
|
|
||||||
message: z.string(),
|
|
||||||
status: z.number()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function adminDeleteOrg(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = adminDeleteOrgSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const { orgId } = parsedParams.data;
|
|
||||||
|
|
||||||
const [org] = await db
|
|
||||||
.select()
|
|
||||||
.from(orgs)
|
|
||||||
.where(eq(orgs.orgId, orgId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!org) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.NOT_FOUND,
|
|
||||||
`Organization with ID ${orgId} not found`
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await deleteOrgById(orgId);
|
|
||||||
sendTerminationMessages(result);
|
|
||||||
return response(res, {
|
|
||||||
data: null,
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Organization deleted successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (createHttpError.isHttpError(error)) {
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"An error occurred..."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { db, users } from "@server/db";
|
|
||||||
import { orgs, resources, sites, userOrgs } from "@server/db";
|
|
||||||
import response from "@server/lib/response";
|
|
||||||
import HttpCode from "@server/types/HttpCode";
|
|
||||||
import createHttpError from "http-errors";
|
|
||||||
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
|
|
||||||
import logger from "@server/logger";
|
|
||||||
import { fromError } from "zod-validation-error";
|
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
|
||||||
import { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
|
|
||||||
import type { PaginatedResponse } from "@server/types/Pagination";
|
|
||||||
|
|
||||||
const adminListOrgsSchema = z.strictObject({
|
|
||||||
pageSize: z.coerce
|
|
||||||
.number<string>()
|
|
||||||
.int()
|
|
||||||
.positive()
|
|
||||||
.optional()
|
|
||||||
.catch(20)
|
|
||||||
.default(20)
|
|
||||||
.openapi({
|
|
||||||
type: "integer",
|
|
||||||
default: 20,
|
|
||||||
description: "Number of items per page"
|
|
||||||
}),
|
|
||||||
page: z.coerce
|
|
||||||
.number<string>()
|
|
||||||
.int()
|
|
||||||
.positive()
|
|
||||||
.optional()
|
|
||||||
.catch(1)
|
|
||||||
.default(1)
|
|
||||||
.openapi({
|
|
||||||
type: "integer",
|
|
||||||
default: 1,
|
|
||||||
description: "Page number to retrieve"
|
|
||||||
}),
|
|
||||||
query: z.string().optional(),
|
|
||||||
sort_by: z
|
|
||||||
.enum(["name", "createdAt"])
|
|
||||||
.optional()
|
|
||||||
.catch(undefined)
|
|
||||||
.openapi({
|
|
||||||
type: "string",
|
|
||||||
enum: ["name", "createdAt"],
|
|
||||||
description: "Field to sort by"
|
|
||||||
}),
|
|
||||||
order: z
|
|
||||||
.enum(["asc", "desc"])
|
|
||||||
.optional()
|
|
||||||
.default("asc")
|
|
||||||
.catch("asc")
|
|
||||||
.openapi({
|
|
||||||
type: "string",
|
|
||||||
enum: ["asc", "desc"],
|
|
||||||
default: "asc",
|
|
||||||
description: "Sort order"
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
export type AdminOrgRow = {
|
|
||||||
orgId: string;
|
|
||||||
name: string;
|
|
||||||
subnet: string | null;
|
|
||||||
utilitySubnet: string | null;
|
|
||||||
createdAt: string | null;
|
|
||||||
userCount: number;
|
|
||||||
siteCount: number;
|
|
||||||
resourceCount: number;
|
|
||||||
owner: {
|
|
||||||
userId: string;
|
|
||||||
username: string;
|
|
||||||
} | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AdminListOrgsResponse = PaginatedResponse<{
|
|
||||||
orgs: AdminOrgRow[];
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const AdminListOrgsResponseDataSchema = z.object({
|
|
||||||
orgs: z.array(
|
|
||||||
z.object({
|
|
||||||
orgId: z.string(),
|
|
||||||
name: z.string(),
|
|
||||||
subnet: z.string().nullable(),
|
|
||||||
createdAt: z.string().nullable(),
|
|
||||||
userCount: z.number(),
|
|
||||||
siteCount: z.number(),
|
|
||||||
resourceCount: z.number()
|
|
||||||
})
|
|
||||||
),
|
|
||||||
pagination: z.object({
|
|
||||||
total: z.number(),
|
|
||||||
page: z.number(),
|
|
||||||
pageSize: z.number()
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
registry.registerPath({
|
|
||||||
method: "get",
|
|
||||||
path: "/admin/orgs",
|
|
||||||
description:
|
|
||||||
"List all organizations in the system with usage counts (server admin).",
|
|
||||||
tags: [OpenAPITags.Org],
|
|
||||||
request: {
|
|
||||||
query: adminListOrgsSchema
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Successful response",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: createApiResponseSchema(
|
|
||||||
AdminListOrgsResponseDataSchema
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function adminListOrgs(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedQuery = adminListOrgsSchema.safeParse(req.query);
|
|
||||||
if (!parsedQuery.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedQuery.error)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { pageSize, page, query, sort_by, order } = parsedQuery.data;
|
|
||||||
|
|
||||||
let conditions: (SQL<unknown> | undefined)[] = [];
|
|
||||||
if (query) {
|
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
|
||||||
conditions.push(
|
|
||||||
or(
|
|
||||||
like(sql`LOWER(${orgs.name})`, q),
|
|
||||||
like(sql`LOWER(${orgs.orgId})`, q),
|
|
||||||
like(sql`LOWER(${orgs.subnet})`, q)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortColumns = {
|
|
||||||
name: orgs.name,
|
|
||||||
createdAt: orgs.createdAt
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
const orderBy = sort_by
|
|
||||||
? order === "asc"
|
|
||||||
? asc(sortColumns[sort_by])
|
|
||||||
: desc(sortColumns[sort_by])
|
|
||||||
: asc(orgs.name);
|
|
||||||
|
|
||||||
// Drizzle renders bare column references in the select list without their
|
|
||||||
// table prefix, which would make a correlated subquery compare a column to
|
|
||||||
// itself, so the outer `orgs` side is qualified explicitly.
|
|
||||||
const orgIdRef = sql`${sql.identifier("orgs")}.${sql.identifier("orgId")}`;
|
|
||||||
|
|
||||||
const [countRows, rows] = await Promise.all([
|
|
||||||
db
|
|
||||||
.select({ count: sql<number>`count(*)` })
|
|
||||||
.from(orgs)
|
|
||||||
.where(and(...conditions)),
|
|
||||||
db
|
|
||||||
.selectDistinct({
|
|
||||||
orgId: orgs.orgId,
|
|
||||||
name: orgs.name,
|
|
||||||
subnet: orgs.subnet,
|
|
||||||
utilitySubnet: orgs.utilitySubnet,
|
|
||||||
createdAt: orgs.createdAt,
|
|
||||||
userCount: sql<number>`(
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM ${userOrgs}
|
|
||||||
WHERE ${userOrgs.orgId} = ${orgIdRef}
|
|
||||||
)`.as("userCount"),
|
|
||||||
siteCount: sql<number>`(
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM ${sites}
|
|
||||||
WHERE ${sites.orgId} = ${orgIdRef}
|
|
||||||
)`.as("siteCount"),
|
|
||||||
resourceCount: sql<number>`(
|
|
||||||
SELECT COUNT(*)
|
|
||||||
FROM ${resources}
|
|
||||||
WHERE ${resources.orgId} = ${orgIdRef}
|
|
||||||
)`.as("resourceCount"),
|
|
||||||
owner: {
|
|
||||||
userId: users.userId,
|
|
||||||
username: users.username
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.from(orgs)
|
|
||||||
.where(and(...conditions, eq(userOrgs.isOwner, true)))
|
|
||||||
.leftJoin(userOrgs, eq(userOrgs.orgId, orgs.orgId))
|
|
||||||
.leftJoin(users, eq(userOrgs.userId, users.userId))
|
|
||||||
.limit(pageSize)
|
|
||||||
.offset(pageSize * (page - 1))
|
|
||||||
.orderBy(orderBy)
|
|
||||||
]);
|
|
||||||
|
|
||||||
const totalCount = Number(countRows[0]?.count ?? 0);
|
|
||||||
|
|
||||||
return response<AdminListOrgsResponse>(res, {
|
|
||||||
data: {
|
|
||||||
orgs: rows.map((row) => ({
|
|
||||||
...row,
|
|
||||||
userCount: Number(row.userCount ?? 0),
|
|
||||||
siteCount: Number(row.siteCount ?? 0),
|
|
||||||
resourceCount: Number(row.resourceCount ?? 0)
|
|
||||||
})),
|
|
||||||
pagination: {
|
|
||||||
total: totalCount,
|
|
||||||
page,
|
|
||||||
pageSize
|
|
||||||
}
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: "Organizations retrieved successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.INTERNAL_SERVER_ERROR,
|
|
||||||
"An error occurred..."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,5 +9,3 @@ export * from "./listOrgs";
|
|||||||
export * from "./pickOrgDefaults";
|
export * from "./pickOrgDefaults";
|
||||||
export * from "./checkOrgUserAccess";
|
export * from "./checkOrgUserAccess";
|
||||||
export * from "./resetOrgBandwidth";
|
export * from "./resetOrgBandwidth";
|
||||||
export * from "./adminListOrgs";
|
|
||||||
export * from "./adminDeleteOrg";
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { db, idp, users } from "@server/db";
|
|||||||
import response from "@server/lib/response";
|
import response from "@server/lib/response";
|
||||||
import HttpCode from "@server/types/HttpCode";
|
import HttpCode from "@server/types/HttpCode";
|
||||||
import createHttpError from "http-errors";
|
import createHttpError from "http-errors";
|
||||||
import { and, asc, desc, eq, like, or, sql, type SQL } from "drizzle-orm";
|
import { and, asc, desc, eq, like, or, sql } from "drizzle-orm";
|
||||||
import logger from "@server/logger";
|
import logger from "@server/logger";
|
||||||
import { fromZodError } from "zod-validation-error";
|
import { fromZodError } from "zod-validation-error";
|
||||||
import { OpenAPITags, registry } from "@server/openApi";
|
import { OpenAPITags, registry } from "@server/openApi";
|
||||||
@@ -88,15 +88,6 @@ const listUsersSchema = z.strictObject({
|
|||||||
type: "boolean",
|
type: "boolean",
|
||||||
description:
|
description:
|
||||||
"Filter by 2FA state matching: enabled if twoFactorEnabled or twoFactorSetupRequested"
|
"Filter by 2FA state matching: enabled if twoFactorEnabled or twoFactorSetupRequested"
|
||||||
}),
|
|
||||||
server_admin: z
|
|
||||||
.enum(["true", "false"])
|
|
||||||
.transform((v) => v === "true")
|
|
||||||
.optional()
|
|
||||||
.catch(undefined)
|
|
||||||
.openapi({
|
|
||||||
type: "boolean",
|
|
||||||
description: "Filter by server admin status"
|
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,8 +177,7 @@ export async function adminListUsers(
|
|||||||
sort_by,
|
sort_by,
|
||||||
order,
|
order,
|
||||||
idp_id,
|
idp_id,
|
||||||
two_factor: twoFactorFilter,
|
two_factor: twoFactorFilter
|
||||||
server_admin: serverAdminFilter
|
|
||||||
} = parsedQuery.data;
|
} = parsedQuery.data;
|
||||||
|
|
||||||
if (typeof idp_id === "number") {
|
if (typeof idp_id === "number") {
|
||||||
@@ -206,7 +196,7 @@ export async function adminListUsers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const conditions: Array<SQL<unknown> | undefined> = [];
|
const conditions = [eq(users.serverAdmin, false)];
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
const q = "%" + query.toLowerCase() + "%";
|
const q = "%" + query.toLowerCase() + "%";
|
||||||
@@ -243,10 +233,6 @@ export async function adminListUsers(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof serverAdminFilter === "boolean") {
|
|
||||||
conditions.push(eq(users.serverAdmin, serverAdminFilter));
|
|
||||||
}
|
|
||||||
|
|
||||||
const whereClause = and(...conditions);
|
const whereClause = and(...conditions);
|
||||||
|
|
||||||
const countQuery = db.$count(
|
const countQuery = db.$count(
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
import { Request, Response, NextFunction } from "express";
|
|
||||||
import { z } from "zod";
|
|
||||||
import { db, users } 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 { createApiResponseSchema } from "@server/lib/openapi/createApiResponseSchema";
|
|
||||||
|
|
||||||
const setServerAdminParamsSchema = z.strictObject({
|
|
||||||
userId: z.string()
|
|
||||||
});
|
|
||||||
|
|
||||||
const setServerAdminBodySchema = z.strictObject({
|
|
||||||
serverAdmin: z.boolean()
|
|
||||||
});
|
|
||||||
|
|
||||||
export type AdminSetServerAdminResponse = {
|
|
||||||
userId: string;
|
|
||||||
serverAdmin: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
const AdminSetServerAdminResponseDataSchema = z.object({
|
|
||||||
userId: z.string(),
|
|
||||||
serverAdmin: z.boolean()
|
|
||||||
});
|
|
||||||
|
|
||||||
registry.registerPath({
|
|
||||||
method: "post",
|
|
||||||
path: "/user/{userId}/server-admin",
|
|
||||||
description:
|
|
||||||
"Promote or demote a user's server admin status (server admin).",
|
|
||||||
tags: [OpenAPITags.User],
|
|
||||||
request: {
|
|
||||||
params: setServerAdminParamsSchema,
|
|
||||||
body: {
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: setServerAdminBodySchema
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Successful response",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: createApiResponseSchema(
|
|
||||||
AdminSetServerAdminResponseDataSchema
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function adminSetServerAdmin(
|
|
||||||
req: Request,
|
|
||||||
res: Response,
|
|
||||||
next: NextFunction
|
|
||||||
): Promise<any> {
|
|
||||||
try {
|
|
||||||
const parsedParams = setServerAdminParamsSchema.safeParse(req.params);
|
|
||||||
if (!parsedParams.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedParams.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedBody = setServerAdminBodySchema.safeParse(req.body);
|
|
||||||
if (!parsedBody.success) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
fromError(parsedBody.error).toString()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { userId } = parsedParams.data;
|
|
||||||
const { serverAdmin } = parsedBody.data;
|
|
||||||
|
|
||||||
const [existingUser] = await db
|
|
||||||
.select({
|
|
||||||
userId: users.userId,
|
|
||||||
serverAdmin: users.serverAdmin,
|
|
||||||
type: users.type
|
|
||||||
})
|
|
||||||
.from(users)
|
|
||||||
.where(eq(users.userId, userId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!existingUser) {
|
|
||||||
return next(createHttpError(HttpCode.NOT_FOUND, "User not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingUser.type !== "internal") {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"Server admin status can only be changed for internal users"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!serverAdmin && req.user?.userId === userId) {
|
|
||||||
return next(
|
|
||||||
createHttpError(
|
|
||||||
HttpCode.BAD_REQUEST,
|
|
||||||
"You cannot remove your own server admin status"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (existingUser.serverAdmin !== serverAdmin) {
|
|
||||||
logger.info(
|
|
||||||
`${serverAdmin ? "Promoting" : "Demoting"} user ${userId} ${serverAdmin ? "to" : "from"} server admin (by ${req.user?.userId})`
|
|
||||||
);
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(users)
|
|
||||||
.set({ serverAdmin })
|
|
||||||
.where(eq(users.userId, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
return response<AdminSetServerAdminResponse>(res, {
|
|
||||||
data: {
|
|
||||||
userId: existingUser.userId,
|
|
||||||
serverAdmin
|
|
||||||
},
|
|
||||||
success: true,
|
|
||||||
error: false,
|
|
||||||
message: serverAdmin
|
|
||||||
? "User promoted to server admin successfully"
|
|
||||||
: "User demoted from server admin successfully",
|
|
||||||
status: HttpCode.OK
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(error);
|
|
||||||
return next(
|
|
||||||
createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ export * from "./adminListUsers";
|
|||||||
export * from "./adminRemoveUser";
|
export * from "./adminRemoveUser";
|
||||||
export * from "./adminGetUser";
|
export * from "./adminGetUser";
|
||||||
export * from "./adminGeneratePasswordResetCode";
|
export * from "./adminGeneratePasswordResetCode";
|
||||||
export * from "./adminSetServerAdmin";
|
|
||||||
export * from "./listInvitations";
|
export * from "./listInvitations";
|
||||||
export * from "./removeInvitation";
|
export * from "./removeInvitation";
|
||||||
export * from "./createOrgUser";
|
export * from "./createOrgUser";
|
||||||
|
|||||||
@@ -95,34 +95,22 @@ const listUsersSchema = z.strictObject({
|
|||||||
'Filter by identity provider id, or "internal" for internal users'
|
'Filter by identity provider id, or "internal" for internal users'
|
||||||
}),
|
}),
|
||||||
role_id: z
|
role_id: z
|
||||||
.preprocess(
|
.preprocess((val) => {
|
||||||
(val) => {
|
|
||||||
if (val === undefined || val === null || val === "") {
|
if (val === undefined || val === null || val === "") {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const raw = Array.isArray(val) ? val : [val];
|
const raw = Array.isArray(val) ? val : [val];
|
||||||
const includeOwner = raw.some((v) => v === "owner");
|
|
||||||
const nums = raw
|
const nums = raw
|
||||||
.map((v) =>
|
.map((v) =>
|
||||||
typeof v === "string" ? parseInt(v, 10) : Number(v)
|
typeof v === "string" ? parseInt(v, 10) : Number(v)
|
||||||
)
|
)
|
||||||
.filter((n) => Number.isInteger(n) && n > 0);
|
.filter((n) => Number.isInteger(n) && n > 0);
|
||||||
const unique = [...new Set(nums)];
|
const unique = [...new Set(nums)];
|
||||||
if (!unique.length && !includeOwner) {
|
return unique.length ? unique : undefined;
|
||||||
return undefined;
|
}, z.array(z.number().int().positive()).optional())
|
||||||
}
|
|
||||||
return { roleIds: unique, includeOwner };
|
|
||||||
},
|
|
||||||
z
|
|
||||||
.object({
|
|
||||||
roleIds: z.array(z.number().int().positive()),
|
|
||||||
includeOwner: z.boolean()
|
|
||||||
})
|
|
||||||
.optional()
|
|
||||||
)
|
|
||||||
.openapi({
|
.openapi({
|
||||||
description:
|
description:
|
||||||
'Filter users who have any of these role ids in the organization, or "owner" for organization owners (repeat query param)'
|
"Filter users who have any of these role ids in the organization (repeat query param)"
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -205,8 +193,7 @@ export async function listUsers(
|
|||||||
}
|
}
|
||||||
const { page, pageSize, sort_by, order, query, idp_id, role_id } =
|
const { page, pageSize, sort_by, order, query, idp_id, role_id } =
|
||||||
parsedQuery.data;
|
parsedQuery.data;
|
||||||
const roleIds = role_id?.roleIds ?? [];
|
const roleIds = role_id ?? [];
|
||||||
const includeOwner = role_id?.includeOwner ?? false;
|
|
||||||
|
|
||||||
const parsedParams = listUsersParamsSchema.safeParse(req.params);
|
const parsedParams = listUsersParamsSchema.safeParse(req.params);
|
||||||
if (!parsedParams.success) {
|
if (!parsedParams.success) {
|
||||||
@@ -280,15 +267,8 @@ export async function listUsers(
|
|||||||
conditions.push(eq(users.idpId, idp_id));
|
conditions.push(eq(users.idpId, idp_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roleIds.length > 0 || includeOwner) {
|
|
||||||
const roleFilterParts = [];
|
|
||||||
|
|
||||||
if (includeOwner) {
|
|
||||||
roleFilterParts.push(eq(userOrgs.isOwner, true));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (roleIds.length > 0) {
|
if (roleIds.length > 0) {
|
||||||
roleFilterParts.push(
|
conditions.push(
|
||||||
exists(
|
exists(
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
@@ -304,13 +284,6 @@ export async function listUsers(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (roleFilterParts.length === 1) {
|
|
||||||
conditions.push(roleFilterParts[0]);
|
|
||||||
} else if (roleFilterParts.length > 1) {
|
|
||||||
conditions.push(or(...roleFilterParts));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const countQuery = db.$count(
|
const countQuery = db.$count(
|
||||||
queryUsersBase()
|
queryUsersBase()
|
||||||
.where(and(...conditions))
|
.where(and(...conditions))
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ const migrations = [
|
|||||||
|
|
||||||
await run();
|
await run();
|
||||||
|
|
||||||
|
// The pg Pool is created with allowExitOnIdle: false (see poolConfig.ts) so
|
||||||
|
// its sockets keep the event loop alive even when idle. Without an explicit
|
||||||
|
// exit here, this one-shot script would hang until the pool's
|
||||||
|
// idleTimeoutMillis elapses before the process could terminate.
|
||||||
|
process.exit(0);
|
||||||
|
|
||||||
async function run() {
|
async function run() {
|
||||||
// run the migrations
|
// run the migrations
|
||||||
await runMigrations();
|
await runMigrations();
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import OrgRouteLoading from "@app/components/OrgRouteLoading";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
export default function OrgPageLoading() {
|
export default function OrgPageLoading() {
|
||||||
return <OrgRouteLoading />;
|
return (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-10
@@ -38,7 +38,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export default function GeneralPage() {
|
export default function AccessControlsPage() {
|
||||||
const { orgUser: user, updateOrgUser } = userOrgUserContext();
|
const { orgUser: user, updateOrgUser } = userOrgUserContext();
|
||||||
const { user: sessionUser } = useUserContext();
|
const { user: sessionUser } = useUserContext();
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -57,7 +57,7 @@ export default function GeneralPage() {
|
|||||||
(build === "enterprise" && !isPaid) ||
|
(build === "enterprise" && !isPaid) ||
|
||||||
(build === "oss" && !isPaid));
|
(build === "oss" && !isPaid));
|
||||||
|
|
||||||
const generalFormSchema = z.object({
|
const accessControlsFormSchema = z.object({
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
autoProvisioned: z.boolean(),
|
autoProvisioned: z.boolean(),
|
||||||
roles: z
|
roles: z
|
||||||
@@ -72,7 +72,7 @@ export default function GeneralPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(generalFormSchema),
|
resolver: zodResolver(accessControlsFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
username: user.username!,
|
username: user.username!,
|
||||||
autoProvisioned: user.autoProvisioned || false,
|
autoProvisioned: user.autoProvisioned || false,
|
||||||
@@ -155,7 +155,7 @@ export default function GeneralPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGeneralSubmit(e: React.FormEvent) {
|
async function handleAccessControlsSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const isValid = await form.trigger();
|
const isValid = await form.trigger();
|
||||||
@@ -196,9 +196,11 @@ export default function GeneralPage() {
|
|||||||
|
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>{t("general")}</SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
|
{t("accessControls")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
{t("userGeneralSettingsDescription")}
|
{t("accessControlsDescription")}
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
|
|
||||||
@@ -206,9 +208,11 @@ export default function GeneralPage() {
|
|||||||
<SettingsSectionForm>
|
<SettingsSectionForm>
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form
|
<form
|
||||||
onSubmit={(e) => void handleGeneralSubmit(e)}
|
onSubmit={(e) =>
|
||||||
|
void handleAccessControlsSubmit(e)
|
||||||
|
}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
id="user-general-form"
|
id="access-controls-form"
|
||||||
>
|
>
|
||||||
{user.type !== UserType.Internal &&
|
{user.type !== UserType.Internal &&
|
||||||
user.idpType && (
|
user.idpType && (
|
||||||
@@ -277,9 +281,9 @@ export default function GeneralPage() {
|
|||||||
type="submit"
|
type="submit"
|
||||||
loading={isSaving}
|
loading={isSaving}
|
||||||
disabled={isSaving}
|
disabled={isSaving}
|
||||||
form="user-general-form"
|
form="access-controls-form"
|
||||||
>
|
>
|
||||||
{t("saveSettings")}
|
{t("accessControlsSubmit")}
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsSectionFooter>
|
</SettingsSectionFooter>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
@@ -9,16 +9,15 @@ import { cache } from "react";
|
|||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "User"
|
title: "User"
|
||||||
};
|
};
|
||||||
|
|
||||||
type UserLayoutProps = {
|
interface UserLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: Promise<{ userId: string; orgId: string }>;
|
params: Promise<{ userId: string; orgId: string }>;
|
||||||
};
|
}
|
||||||
|
|
||||||
export default async function UserLayoutProps(props: UserLayoutProps) {
|
export default async function UserLayoutProps(props: UserLayoutProps) {
|
||||||
const params = await props.params;
|
const params = await props.params;
|
||||||
@@ -43,23 +42,15 @@ export default async function UserLayoutProps(props: UserLayoutProps) {
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
title: t("general"),
|
title: t("accessControls"),
|
||||||
href: "/{orgId}/settings/access/users/{userId}/general"
|
href: "/{orgId}/settings/access/users/{userId}/access-controls"
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SettingsSectionTitle
|
<SettingsSectionTitle
|
||||||
title={
|
title={`${user?.email}`}
|
||||||
user
|
|
||||||
? getUserDisplayName({
|
|
||||||
email: user.email,
|
|
||||||
name: user.name,
|
|
||||||
username: user.username
|
|
||||||
})
|
|
||||||
: ""
|
|
||||||
}
|
|
||||||
description={t("userDescription2")}
|
description={t("userDescription2")}
|
||||||
/>
|
/>
|
||||||
<OrgUserProvider orgUser={user}>
|
<OrgUserProvider orgUser={user}>
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ export default async function UserPage(props: {
|
|||||||
params: Promise<{ orgId: string; userId: string }>;
|
params: Promise<{ orgId: string; userId: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { orgId, userId } = await props.params;
|
const { orgId, userId } = await props.params;
|
||||||
redirect(`/${orgId}/settings/access/users/${userId}/general`);
|
redirect(`/${orgId}/settings/access/users/${userId}/access-controls`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
|
||||||
import {
|
import {
|
||||||
Credenza,
|
SettingsContainer,
|
||||||
CredenzaBody,
|
SettingsSection,
|
||||||
CredenzaContent,
|
SettingsSectionBody,
|
||||||
CredenzaDescription,
|
SettingsSectionDescription,
|
||||||
CredenzaFooter,
|
SettingsSectionForm,
|
||||||
CredenzaHeader,
|
SettingsSectionHeader,
|
||||||
CredenzaTitle
|
SettingsSectionTitle
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Settings";
|
||||||
import { StrategySelect } from "@app/components/StrategySelect";
|
import { StrategyOption, StrategySelect } from "@app/components/StrategySelect";
|
||||||
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
import HeaderTitle from "@app/components/SettingsSectionTitle";
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useActionState, useRef, useState, startTransition } from "react";
|
import {
|
||||||
|
useActionState,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
startTransition
|
||||||
|
} from "react";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -38,6 +42,7 @@ import { AxiosResponse } from "axios";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { ListRolesResponse } from "@server/routers/role";
|
import { ListRolesResponse } from "@server/routers/role";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
@@ -50,15 +55,7 @@ import IdpTypeIcon from "@app/components/IdpTypeIcon";
|
|||||||
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
import { usePaidStatus } from "@app/hooks/usePaidStatus";
|
||||||
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
import { tierMatrix } from "@server/lib/billing/tierMatrix";
|
||||||
import OrgRolesTagField from "@app/components/OrgRolesTagField";
|
import OrgRolesTagField from "@app/components/OrgRolesTagField";
|
||||||
import {
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
SettingsContainer,
|
|
||||||
SettingsSection,
|
|
||||||
SettingsSectionBody,
|
|
||||||
SettingsSectionDescription,
|
|
||||||
SettingsSectionForm,
|
|
||||||
SettingsSectionHeader,
|
|
||||||
SettingsSectionTitle
|
|
||||||
} from "@app/components/Settings";
|
|
||||||
|
|
||||||
type UserType = "internal" | "oidc";
|
type UserType = "internal" | "oidc";
|
||||||
|
|
||||||
@@ -99,7 +96,6 @@ export default function Page() {
|
|||||||
"internal"
|
"internal"
|
||||||
);
|
);
|
||||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||||
const [isInviteDialogOpen, setIsInviteDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
const [expiresInDays, setExpiresInDays] = useState(1);
|
const [expiresInDays, setExpiresInDays] = useState(1);
|
||||||
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]);
|
||||||
@@ -250,9 +246,9 @@ export default function Page() {
|
|||||||
build === "saas" || env.app.identityProviderMode === "org";
|
build === "saas" || env.app.identityProviderMode === "org";
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
.get<AxiosResponse<ListIdpsResponse>>(
|
.get<
|
||||||
useOrgIdps ? `/org/${orgId}/idp` : "/idp"
|
AxiosResponse<ListIdpsResponse>
|
||||||
)
|
>(useOrgIdps ? `/org/${orgId}/idp` : "/idp")
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
toast({
|
toast({
|
||||||
@@ -354,13 +350,13 @@ export default function Page() {
|
|||||||
|
|
||||||
if (res && res.status === 200) {
|
if (res && res.status === 200) {
|
||||||
setInviteLink(res.data.data.inviteLink);
|
setInviteLink(res.data.data.inviteLink);
|
||||||
setExpiresInDays(parseInt(values.validForHours) / 24);
|
|
||||||
setIsInviteDialogOpen(true);
|
|
||||||
toast({
|
toast({
|
||||||
variant: "default",
|
variant: "default",
|
||||||
title: t("userInvited"),
|
title: t("userInvited"),
|
||||||
description: t("userInvitedDescription")
|
description: t("userInvitedDescription")
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setExpiresInDays(parseInt(values.validForHours) / 24);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -464,7 +460,6 @@ export default function Page() {
|
|||||||
setSendEmail(env.email.emailEnabled);
|
setSendEmail(env.email.emailEnabled);
|
||||||
internalForm.reset();
|
internalForm.reset();
|
||||||
setInviteLink(null);
|
setInviteLink(null);
|
||||||
setIsInviteDialogOpen(false);
|
|
||||||
setExpiresInDays(1);
|
setExpiresInDays(1);
|
||||||
} else {
|
} else {
|
||||||
googleAzureForm.reset();
|
googleAzureForm.reset();
|
||||||
@@ -491,7 +486,7 @@ export default function Page() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
{userOptions.length > 1 ? (
|
{!inviteLink && userOptions.length > 1 ? (
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
@@ -513,6 +508,8 @@ export default function Page() {
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{selectedOption === "internal" && dataLoaded && (
|
{selectedOption === "internal" && dataLoaded && (
|
||||||
|
<>
|
||||||
|
{!inviteLink ? (
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
@@ -536,7 +533,9 @@ export default function Page() {
|
|||||||
id="create-user-form"
|
id="create-user-form"
|
||||||
>
|
>
|
||||||
<FormField
|
<FormField
|
||||||
control={internalForm.control}
|
control={
|
||||||
|
internalForm.control
|
||||||
|
}
|
||||||
name="email"
|
name="email"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
@@ -544,40 +543,26 @@ export default function Page() {
|
|||||||
{t("email")}
|
{t("email")}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input {...field} />
|
<Input
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{env.email.emailEnabled && (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id="send-email"
|
|
||||||
checked={sendEmail}
|
|
||||||
onCheckedChange={(e) =>
|
|
||||||
setSendEmail(
|
|
||||||
e as boolean
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<label
|
|
||||||
htmlFor="send-email"
|
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
|
||||||
{t("inviteEmailSent")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={internalForm.control}
|
control={
|
||||||
|
internalForm.control
|
||||||
|
}
|
||||||
name="validForHours"
|
name="validForHours"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
{t("inviteValid")}
|
{t(
|
||||||
|
"inviteValid"
|
||||||
|
)}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<Select
|
<Select
|
||||||
onValueChange={
|
onValueChange={
|
||||||
@@ -634,11 +619,66 @@ export default function Page() {
|
|||||||
invitePaywallMessage
|
invitePaywallMessage
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{env.email.emailEnabled && (
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Checkbox
|
||||||
|
id="send-email"
|
||||||
|
checked={
|
||||||
|
sendEmail
|
||||||
|
}
|
||||||
|
onCheckedChange={(
|
||||||
|
e
|
||||||
|
) =>
|
||||||
|
setSendEmail(
|
||||||
|
e as boolean
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor="send-email"
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"inviteEmailSent"
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</SettingsSectionForm>
|
</SettingsSectionForm>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
) : (
|
||||||
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("userInvited")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
<SettingsSectionDescription>
|
||||||
|
{sendEmail
|
||||||
|
? t(
|
||||||
|
"inviteEmailSentDescription"
|
||||||
|
)
|
||||||
|
: t("inviteSentDescription")}
|
||||||
|
</SettingsSectionDescription>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p>
|
||||||
|
{t("inviteExpiresIn", {
|
||||||
|
days: expiresInDays
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<CopyToClipboard
|
||||||
|
text={inviteLink}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{selectedOption &&
|
{selectedOption &&
|
||||||
@@ -858,56 +898,24 @@ export default function Page() {
|
|||||||
<div className="flex justify-end space-x-2 mt-8">
|
<div className="flex justify-end space-x-2 mt-8">
|
||||||
{selectedOption && dataLoaded && (
|
{selectedOption && dataLoaded && (
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type={inviteLink ? "button" : "submit"}
|
||||||
form="create-user-form"
|
form={inviteLink ? undefined : "create-user-form"}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
onClick={
|
||||||
|
inviteLink
|
||||||
|
? () =>
|
||||||
|
router.push(
|
||||||
|
`/${orgId}/settings/access/users`
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t("accessUserCreate")}
|
{inviteLink ? t("done") : t("accessUserCreate")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Credenza
|
|
||||||
open={isInviteDialogOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setIsInviteDialogOpen(open);
|
|
||||||
if (!open) {
|
|
||||||
setInviteLink(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CredenzaContent>
|
|
||||||
<CredenzaHeader>
|
|
||||||
<CredenzaTitle>{t("userInvited")}</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
|
||||||
{sendEmail
|
|
||||||
? t("inviteEmailSentDescription")
|
|
||||||
: t("inviteSentDescription")}
|
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p>
|
|
||||||
{t("inviteExpiresIn", {
|
|
||||||
days: expiresInDays
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
{inviteLink && <CopyTextBox text={inviteLink} />}
|
|
||||||
</div>
|
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<Button
|
|
||||||
onClick={() =>
|
|
||||||
router.push(`/${orgId}/settings/access/users`)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("done")}
|
|
||||||
</Button>
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,13 +78,12 @@ export default async function UsersPage(props: UsersPageProps) {
|
|||||||
rolesRes && rolesRes.status === 200
|
rolesRes && rolesRes.status === 200
|
||||||
? (rolesRes.data.data.roles ?? [])
|
? (rolesRes.data.data.roles ?? [])
|
||||||
: [];
|
: [];
|
||||||
const roleFilterOptions = [
|
const roleFilterOptions = orgRoles.map(
|
||||||
{ value: "owner", label: t("accessRoleOwner") },
|
(r: ListRolesResponse["roles"][number]) => ({
|
||||||
...orgRoles.map((r: ListRolesResponse["roles"][number]) => ({
|
|
||||||
value: String(r.roleId),
|
value: String(r.roleId),
|
||||||
label: r.name
|
label: r.name
|
||||||
}))
|
})
|
||||||
];
|
);
|
||||||
|
|
||||||
const invitationsRes = await internal
|
const invitationsRes = await internal
|
||||||
.get(
|
.get(
|
||||||
@@ -127,7 +126,9 @@ export default async function UsersPage(props: UsersPageProps) {
|
|||||||
idpId: user.idpId,
|
idpId: user.idpId,
|
||||||
idpName: user.idpName || t("idpNameInternal"),
|
idpName: user.idpName || t("idpNameInternal"),
|
||||||
status: t("userConfirmed"),
|
status: t("userConfirmed"),
|
||||||
roleLabels: (() => {
|
roleLabels: user.isOwner
|
||||||
|
? [t("accessRoleOwner")]
|
||||||
|
: (() => {
|
||||||
const names = (user.roles ?? [])
|
const names = (user.roles ?? [])
|
||||||
.map((r) => r.roleName)
|
.map((r) => r.roleName)
|
||||||
.filter((n): n is string => Boolean(n?.length));
|
.filter((n): n is string => Boolean(n?.length));
|
||||||
|
|||||||
@@ -43,18 +43,10 @@ import {
|
|||||||
} from "@app/components/InfoSection";
|
} from "@app/components/InfoSection";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import moment from "moment";
|
import moment from "moment";
|
||||||
|
import CopyCodeBox from "@server/emails/templates/components/CopyCodeBox";
|
||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
|
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
|
||||||
Credenza,
|
|
||||||
CredenzaBody,
|
|
||||||
CredenzaContent,
|
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
|
||||||
CredenzaHeader,
|
|
||||||
CredenzaTitle
|
|
||||||
} from "@app/components/Credenza";
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -66,7 +58,6 @@ export default function Page() {
|
|||||||
const [loadingPage, setLoadingPage] = useState(true);
|
const [loadingPage, setLoadingPage] = useState(true);
|
||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
|
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
|
||||||
const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false);
|
|
||||||
const [selectedPermissions, setSelectedPermissions] = useState<
|
const [selectedPermissions, setSelectedPermissions] = useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({});
|
>({});
|
||||||
@@ -84,6 +75,22 @@ export default function Page() {
|
|||||||
|
|
||||||
type CreateFormValues = z.infer<typeof createFormSchema>;
|
type CreateFormValues = z.infer<typeof createFormSchema>;
|
||||||
|
|
||||||
|
const copiedFormSchema = z
|
||||||
|
.object({
|
||||||
|
copied: z.boolean()
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
return data.copied;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: t("apiKeysConfirmCopy2"),
|
||||||
|
path: ["copied"]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(createFormSchema),
|
resolver: zodResolver(createFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -91,9 +98,12 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function goToApiKeysList() {
|
const copiedForm = useForm({
|
||||||
router.push(`/${orgId}/settings/api-keys`);
|
resolver: zodResolver(copiedFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
copied: true
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function onSubmit(data: CreateFormValues) {
|
async function onSubmit(data: CreateFormValues) {
|
||||||
setCreateLoading(true);
|
setCreateLoading(true);
|
||||||
@@ -103,10 +113,9 @@ export default function Page() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const res = await api
|
const res = await api
|
||||||
.put<AxiosResponse<CreateOrgApiKeyResponse>>(
|
.put<
|
||||||
`/org/${orgId}/api-key/`,
|
AxiosResponse<CreateOrgApiKeyResponse>
|
||||||
payload
|
>(`/org/${orgId}/api-key/`, payload)
|
||||||
)
|
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
toast({
|
toast({
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
@@ -116,10 +125,16 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res && res.status === 201) {
|
if (res && res.status === 201) {
|
||||||
const created = res.data.data;
|
const data = res.data.data;
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
actionIds: Object.keys(selectedPermissions).filter(
|
||||||
|
(key) => selectedPermissions[key]
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
const actionsRes = await api
|
const actionsRes = await api
|
||||||
.post(`/org/${orgId}/api-key/${created.apiKeyId}/actions`, {
|
.post(`/org/${orgId}/api-key/${data.apiKeyId}/actions`, {
|
||||||
actionIds: Object.keys(selectedPermissions).filter(
|
actionIds: Object.keys(selectedPermissions).filter(
|
||||||
(key) => selectedPermissions[key]
|
(key) => selectedPermissions[key]
|
||||||
)
|
)
|
||||||
@@ -134,14 +149,27 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (actionsRes) {
|
if (actionsRes) {
|
||||||
setApiKey(created);
|
setApiKey(data);
|
||||||
setIsApiKeyDialogOpen(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setCreateLoading(false);
|
setCreateLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onCopiedSubmit(data: CopiedFormValues) {
|
||||||
|
if (!data.copied) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/${orgId}/settings/api-keys`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatLabel = (str: string) => {
|
||||||
|
return str
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
||||||
|
.replace(/^./, (char) => char.toUpperCase());
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoadingPage(false);
|
setLoadingPage(false);
|
||||||
@@ -157,7 +185,12 @@ export default function Page() {
|
|||||||
title={t("apiKeysCreate")}
|
title={t("apiKeysCreate")}
|
||||||
description={t("apiKeysCreateDescription")}
|
description={t("apiKeysCreateDescription")}
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" onClick={goToApiKeysList}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/${orgId}/settings/api-keys`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t("apiKeysSeeAll")}
|
{t("apiKeysSeeAll")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,6 +198,8 @@ export default function Page() {
|
|||||||
{!loadingPage && (
|
{!loadingPage && (
|
||||||
<div>
|
<div>
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
|
{!apiKey && (
|
||||||
|
<>
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
@@ -177,7 +212,7 @@ export default function Page() {
|
|||||||
<form
|
<form
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault(); // block default enter refresh
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
@@ -213,60 +248,31 @@ export default function Page() {
|
|||||||
{t("apiKeysGeneralSettings")}
|
{t("apiKeysGeneralSettings")}
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
{t("apiKeysGeneralSettingsDescription")}
|
{t(
|
||||||
|
"apiKeysGeneralSettingsDescription"
|
||||||
|
)}
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<PermissionsSelectBox
|
<PermissionsSelectBox
|
||||||
selectedPermissions={selectedPermissions}
|
selectedPermissions={
|
||||||
|
selectedPermissions
|
||||||
|
}
|
||||||
onChange={setSelectedPermissions}
|
onChange={setSelectedPermissions}
|
||||||
/>
|
/>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</SettingsContainer>
|
</>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2 mt-8">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
disabled={createLoading || apiKey !== null}
|
|
||||||
onClick={goToApiKeysList}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
loading={createLoading}
|
|
||||||
disabled={createLoading || apiKey !== null}
|
|
||||||
onClick={() => {
|
|
||||||
form.handleSubmit(onSubmit)();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("generate")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Credenza
|
|
||||||
open={isApiKeyDialogOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setIsApiKeyDialogOpen(open);
|
|
||||||
if (!open && apiKey) {
|
|
||||||
goToApiKeysList();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CredenzaContent>
|
|
||||||
<CredenzaHeader>
|
|
||||||
<CredenzaTitle>{t("apiKeysList")}</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
|
||||||
{t("apiKeysSaveDescription")}
|
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
{apiKey && (
|
{apiKey && (
|
||||||
<div className="space-y-4">
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("apiKeysList")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
<InfoSections cols={2}>
|
<InfoSections cols={2}>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
@@ -283,9 +289,9 @@ export default function Page() {
|
|||||||
{t("created")}
|
{t("created")}
|
||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{moment(apiKey.createdAt).format(
|
{moment(
|
||||||
"lll"
|
apiKey.createdAt
|
||||||
)}
|
).format("lll")}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
</InfoSections>
|
</InfoSections>
|
||||||
@@ -300,17 +306,98 @@ export default function Page() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
{/* <h4 className="font-semibold"> */}
|
||||||
|
{/* {t('apiKeysInfo')} */}
|
||||||
|
{/* </h4> */}
|
||||||
|
|
||||||
<CopyTextBox
|
<CopyTextBox
|
||||||
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
|
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* <Form {...copiedForm}> */}
|
||||||
|
{/* <form */}
|
||||||
|
{/* className="space-y-4" */}
|
||||||
|
{/* id="copied-form" */}
|
||||||
|
{/* > */}
|
||||||
|
{/* <FormField */}
|
||||||
|
{/* control={copiedForm.control} */}
|
||||||
|
{/* name="copied" */}
|
||||||
|
{/* render={({ field }) => ( */}
|
||||||
|
{/* <FormItem> */}
|
||||||
|
{/* <div className="flex items-center space-x-2"> */}
|
||||||
|
{/* <Checkbox */}
|
||||||
|
{/* id="terms" */}
|
||||||
|
{/* defaultChecked={ */}
|
||||||
|
{/* copiedForm.getValues( */}
|
||||||
|
{/* "copied" */}
|
||||||
|
{/* ) as boolean */}
|
||||||
|
{/* } */}
|
||||||
|
{/* onCheckedChange={( */}
|
||||||
|
{/* e */}
|
||||||
|
{/* ) => { */}
|
||||||
|
{/* copiedForm.setValue( */}
|
||||||
|
{/* "copied", */}
|
||||||
|
{/* e as boolean */}
|
||||||
|
{/* ); */}
|
||||||
|
{/* }} */}
|
||||||
|
{/* /> */}
|
||||||
|
{/* <label */}
|
||||||
|
{/* htmlFor="terms" */}
|
||||||
|
{/* className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" */}
|
||||||
|
{/* > */}
|
||||||
|
{/* {t('apiKeysConfirmCopy')} */}
|
||||||
|
{/* </label> */}
|
||||||
|
{/* </div> */}
|
||||||
|
{/* <FormMessage /> */}
|
||||||
|
{/* </FormItem> */}
|
||||||
|
{/* )} */}
|
||||||
|
{/* /> */}
|
||||||
|
{/* </form> */}
|
||||||
|
{/* </Form> */}
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
</SettingsContainer>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 mt-8">
|
||||||
|
{!apiKey && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={createLoading || apiKey !== null}
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/${orgId}/settings/api-keys`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!apiKey && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={createLoading}
|
||||||
|
disabled={createLoading || apiKey !== null}
|
||||||
|
onClick={() => {
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("generate")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{apiKey && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
copiedForm.handleSubmit(onCopiedSubmit)();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("done")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<Button onClick={goToApiKeysList}>{t("done")}</Button>
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -954,6 +954,7 @@ export const ProxyResourceTargetsForm = forwardRef<
|
|||||||
colSpan={columns.length}
|
colSpan={columns.length}
|
||||||
message={emptyMessage ?? t("targetNoOne")}
|
message={emptyMessage ?? t("targetNoOne")}
|
||||||
action={addTargetButton}
|
action={addTargetButton}
|
||||||
|
compact
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -46,15 +46,6 @@ import moment from "moment";
|
|||||||
import CopyTextBox from "@app/components/CopyTextBox";
|
import CopyTextBox from "@app/components/CopyTextBox";
|
||||||
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
|
import PermissionsSelectBox from "@app/components/PermissionsSelectBox";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import {
|
|
||||||
Credenza,
|
|
||||||
CredenzaBody,
|
|
||||||
CredenzaContent,
|
|
||||||
CredenzaDescription,
|
|
||||||
CredenzaFooter,
|
|
||||||
CredenzaHeader,
|
|
||||||
CredenzaTitle
|
|
||||||
} from "@app/components/Credenza";
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const { env } = useEnvContext();
|
const { env } = useEnvContext();
|
||||||
@@ -65,7 +56,6 @@ export default function Page() {
|
|||||||
const [loadingPage, setLoadingPage] = useState(true);
|
const [loadingPage, setLoadingPage] = useState(true);
|
||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
|
const [apiKey, setApiKey] = useState<CreateOrgApiKeyResponse | null>(null);
|
||||||
const [isApiKeyDialogOpen, setIsApiKeyDialogOpen] = useState(false);
|
|
||||||
const [selectedPermissions, setSelectedPermissions] = useState<
|
const [selectedPermissions, setSelectedPermissions] = useState<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({});
|
>({});
|
||||||
@@ -83,6 +73,22 @@ export default function Page() {
|
|||||||
|
|
||||||
type CreateFormValues = z.infer<typeof createFormSchema>;
|
type CreateFormValues = z.infer<typeof createFormSchema>;
|
||||||
|
|
||||||
|
const copiedFormSchema = z
|
||||||
|
.object({
|
||||||
|
copied: z.boolean()
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
(data) => {
|
||||||
|
return data.copied;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: t("apiKeysConfirmCopy2"),
|
||||||
|
path: ["copied"]
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
type CopiedFormValues = z.infer<typeof copiedFormSchema>;
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm({
|
||||||
resolver: zodResolver(createFormSchema),
|
resolver: zodResolver(createFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -90,9 +96,12 @@ export default function Page() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function goToApiKeysList() {
|
const copiedForm = useForm({
|
||||||
router.push(`/admin/api-keys`);
|
resolver: zodResolver(copiedFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
copied: true
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function onSubmit(data: CreateFormValues) {
|
async function onSubmit(data: CreateFormValues) {
|
||||||
setCreateLoading(true);
|
setCreateLoading(true);
|
||||||
@@ -112,10 +121,16 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (res && res.status === 201) {
|
if (res && res.status === 201) {
|
||||||
const created = res.data.data;
|
const data = res.data.data;
|
||||||
|
|
||||||
|
console.log({
|
||||||
|
actionIds: Object.keys(selectedPermissions).filter(
|
||||||
|
(key) => selectedPermissions[key]
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
const actionsRes = await api
|
const actionsRes = await api
|
||||||
.post(`/api-key/${created.apiKeyId}/actions`, {
|
.post(`/api-key/${data.apiKeyId}/actions`, {
|
||||||
actionIds: Object.keys(selectedPermissions).filter(
|
actionIds: Object.keys(selectedPermissions).filter(
|
||||||
(key) => selectedPermissions[key]
|
(key) => selectedPermissions[key]
|
||||||
)
|
)
|
||||||
@@ -130,14 +145,21 @@ export default function Page() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (actionsRes) {
|
if (actionsRes) {
|
||||||
setApiKey(created);
|
setApiKey(data);
|
||||||
setIsApiKeyDialogOpen(true);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setCreateLoading(false);
|
setCreateLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onCopiedSubmit(data: CopiedFormValues) {
|
||||||
|
if (!data.copied) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push(`/admin/api-keys`);
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoadingPage(false);
|
setLoadingPage(false);
|
||||||
@@ -153,7 +175,12 @@ export default function Page() {
|
|||||||
title={t("apiKeysCreate")}
|
title={t("apiKeysCreate")}
|
||||||
description={t("apiKeysCreateDescription")}
|
description={t("apiKeysCreateDescription")}
|
||||||
/>
|
/>
|
||||||
<Button variant="outline" onClick={goToApiKeysList}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/admin/api-keys`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
{t("apiKeysSeeAll")}
|
{t("apiKeysSeeAll")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,6 +188,8 @@ export default function Page() {
|
|||||||
{!loadingPage && (
|
{!loadingPage && (
|
||||||
<div>
|
<div>
|
||||||
<SettingsContainer>
|
<SettingsContainer>
|
||||||
|
{!apiKey && (
|
||||||
|
<>
|
||||||
<SettingsSection>
|
<SettingsSection>
|
||||||
<SettingsSectionHeader>
|
<SettingsSectionHeader>
|
||||||
<SettingsSectionTitle>
|
<SettingsSectionTitle>
|
||||||
@@ -173,7 +202,7 @@ export default function Page() {
|
|||||||
<form
|
<form
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault(); // block default enter refresh
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
@@ -209,61 +238,32 @@ export default function Page() {
|
|||||||
{t("apiKeysGeneralSettings")}
|
{t("apiKeysGeneralSettings")}
|
||||||
</SettingsSectionTitle>
|
</SettingsSectionTitle>
|
||||||
<SettingsSectionDescription>
|
<SettingsSectionDescription>
|
||||||
{t("apiKeysGeneralSettingsDescription")}
|
{t(
|
||||||
|
"apiKeysGeneralSettingsDescription"
|
||||||
|
)}
|
||||||
</SettingsSectionDescription>
|
</SettingsSectionDescription>
|
||||||
</SettingsSectionHeader>
|
</SettingsSectionHeader>
|
||||||
<SettingsSectionBody>
|
<SettingsSectionBody>
|
||||||
<PermissionsSelectBox
|
<PermissionsSelectBox
|
||||||
root={true}
|
root={true}
|
||||||
selectedPermissions={selectedPermissions}
|
selectedPermissions={
|
||||||
|
selectedPermissions
|
||||||
|
}
|
||||||
onChange={setSelectedPermissions}
|
onChange={setSelectedPermissions}
|
||||||
/>
|
/>
|
||||||
</SettingsSectionBody>
|
</SettingsSectionBody>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
</SettingsContainer>
|
</>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-2 mt-8">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
disabled={createLoading || apiKey !== null}
|
|
||||||
onClick={goToApiKeysList}
|
|
||||||
>
|
|
||||||
{t("cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
loading={createLoading}
|
|
||||||
disabled={createLoading || apiKey !== null}
|
|
||||||
onClick={() => {
|
|
||||||
form.handleSubmit(onSubmit)();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("generate")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Credenza
|
|
||||||
open={isApiKeyDialogOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setIsApiKeyDialogOpen(open);
|
|
||||||
if (!open && apiKey) {
|
|
||||||
goToApiKeysList();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<CredenzaContent>
|
|
||||||
<CredenzaHeader>
|
|
||||||
<CredenzaTitle>{t("apiKeysList")}</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
|
||||||
{t("apiKeysSaveDescription")}
|
|
||||||
</CredenzaDescription>
|
|
||||||
</CredenzaHeader>
|
|
||||||
<CredenzaBody>
|
|
||||||
{apiKey && (
|
{apiKey && (
|
||||||
<div className="space-y-4">
|
<SettingsSection>
|
||||||
|
<SettingsSectionHeader>
|
||||||
|
<SettingsSectionTitle>
|
||||||
|
{t("apiKeysList")}
|
||||||
|
</SettingsSectionTitle>
|
||||||
|
</SettingsSectionHeader>
|
||||||
|
<SettingsSectionBody>
|
||||||
<InfoSections cols={2}>
|
<InfoSections cols={2}>
|
||||||
<InfoSection>
|
<InfoSection>
|
||||||
<InfoSectionTitle>
|
<InfoSectionTitle>
|
||||||
@@ -280,9 +280,9 @@ export default function Page() {
|
|||||||
{t("created")}
|
{t("created")}
|
||||||
</InfoSectionTitle>
|
</InfoSectionTitle>
|
||||||
<InfoSectionContent>
|
<InfoSectionContent>
|
||||||
{moment(apiKey.createdAt).format(
|
{moment(
|
||||||
"lll"
|
apiKey.createdAt
|
||||||
)}
|
).format("lll")}
|
||||||
</InfoSectionContent>
|
</InfoSectionContent>
|
||||||
</InfoSection>
|
</InfoSection>
|
||||||
</InfoSections>
|
</InfoSections>
|
||||||
@@ -297,17 +297,98 @@ export default function Page() {
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
|
{/* <h4 className="font-semibold"> */}
|
||||||
|
{/* {t('apiKeysInfo')} */}
|
||||||
|
{/* </h4> */}
|
||||||
|
|
||||||
<CopyTextBox
|
<CopyTextBox
|
||||||
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
|
text={`${apiKey.apiKeyId}.${apiKey.apiKey}`}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* <Form {...copiedForm}> */}
|
||||||
|
{/* <form */}
|
||||||
|
{/* className="space-y-4" */}
|
||||||
|
{/* id="copied-form" */}
|
||||||
|
{/* > */}
|
||||||
|
{/* <FormField */}
|
||||||
|
{/* control={copiedForm.control} */}
|
||||||
|
{/* name="copied" */}
|
||||||
|
{/* render={({ field }) => ( */}
|
||||||
|
{/* <FormItem> */}
|
||||||
|
{/* <div className="flex items-center space-x-2"> */}
|
||||||
|
{/* <Checkbox */}
|
||||||
|
{/* id="terms" */}
|
||||||
|
{/* defaultChecked={ */}
|
||||||
|
{/* copiedForm.getValues( */}
|
||||||
|
{/* "copied" */}
|
||||||
|
{/* ) as boolean */}
|
||||||
|
{/* } */}
|
||||||
|
{/* onCheckedChange={( */}
|
||||||
|
{/* e */}
|
||||||
|
{/* ) => { */}
|
||||||
|
{/* copiedForm.setValue( */}
|
||||||
|
{/* "copied", */}
|
||||||
|
{/* e as boolean */}
|
||||||
|
{/* ); */}
|
||||||
|
{/* }} */}
|
||||||
|
{/* /> */}
|
||||||
|
{/* <label */}
|
||||||
|
{/* htmlFor="terms" */}
|
||||||
|
{/* className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" */}
|
||||||
|
{/* > */}
|
||||||
|
{/* {t('apiKeysConfirmCopy')} */}
|
||||||
|
{/* </label> */}
|
||||||
|
{/* </div> */}
|
||||||
|
{/* <FormMessage /> */}
|
||||||
|
{/* </FormItem> */}
|
||||||
|
{/* )} */}
|
||||||
|
{/* /> */}
|
||||||
|
{/* </form> */}
|
||||||
|
{/* </Form> */}
|
||||||
|
</SettingsSectionBody>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
</SettingsContainer>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-2 mt-8">
|
||||||
|
{!apiKey && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={createLoading || apiKey !== null}
|
||||||
|
onClick={() => {
|
||||||
|
router.push(`/admin/api-keys`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!apiKey && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
loading={createLoading}
|
||||||
|
disabled={createLoading || apiKey !== null}
|
||||||
|
onClick={() => {
|
||||||
|
form.handleSubmit(onSubmit)();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("generate")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{apiKey && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
copiedForm.handleSubmit(onCopiedSubmit)();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("done")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CredenzaBody>
|
|
||||||
<CredenzaFooter>
|
|
||||||
<Button onClick={goToApiKeysList}>{t("done")}</Button>
|
|
||||||
</CredenzaFooter>
|
|
||||||
</CredenzaContent>
|
|
||||||
</Credenza>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
|
|
||||||
import OrgsTable from "@app/components/OrgsTable";
|
|
||||||
import { internal } from "@app/lib/api";
|
|
||||||
import { authCookieHeader } from "@app/lib/api/cookies";
|
|
||||||
import type { AdminListOrgsResponse } from "@server/routers/org";
|
|
||||||
import type { AxiosResponse } from "axios";
|
|
||||||
import type { Metadata } from "next";
|
|
||||||
import { getTranslations } from "next-intl/server";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: "Organizations"
|
|
||||||
};
|
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
type OrganizationsPageProps = {
|
|
||||||
searchParams: Promise<Record<string, string>>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function OrganizationsPage(props: OrganizationsPageProps) {
|
|
||||||
const searchParams = new URLSearchParams(await props.searchParams);
|
|
||||||
|
|
||||||
let orgs: AdminListOrgsResponse["orgs"] = [];
|
|
||||||
let pagination: AdminListOrgsResponse["pagination"] = {
|
|
||||||
total: 0,
|
|
||||||
page: 1,
|
|
||||||
pageSize: 20
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await internal.get<AxiosResponse<AdminListOrgsResponse>>(
|
|
||||||
`/admin/orgs?${searchParams.toString()}`,
|
|
||||||
await authCookieHeader()
|
|
||||||
);
|
|
||||||
const responseData = res.data.data;
|
|
||||||
orgs = responseData.orgs;
|
|
||||||
pagination = responseData.pagination;
|
|
||||||
} catch (e) {}
|
|
||||||
|
|
||||||
const t = await getTranslations();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<SettingsSectionTitle
|
|
||||||
title={t("orgsManage")}
|
|
||||||
description={t("orgsDescription")}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<OrgsTable
|
|
||||||
orgs={orgs}
|
|
||||||
rowCount={pagination.total}
|
|
||||||
pagination={{
|
|
||||||
pageIndex: pagination.page - 1,
|
|
||||||
pageSize: pagination.pageSize
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+1
-1
@@ -65,7 +65,7 @@
|
|||||||
--chart-3: oklch(0.769 0.188 70.08);
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
--chart-4: oklch(0.627 0.265 303.9);
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
--chart-5: oklch(0.645 0.246 16.439);
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
--sidebar: #0c0c0d;
|
--sidebar: #0C0C0D;
|
||||||
--sidebar-foreground: oklch(0.985 0 0);
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
--sidebar-primary: oklch(0.646 0.222 41.116);
|
--sidebar-primary: oklch(0.646 0.222 41.116);
|
||||||
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
|
--sidebar-primary-foreground: oklch(0.98 0.016 73.684);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
Boxes,
|
Boxes,
|
||||||
Building2,
|
Building2,
|
||||||
Building2Icon,
|
|
||||||
Cable,
|
Cable,
|
||||||
ChartLine,
|
ChartLine,
|
||||||
Coins,
|
Coins,
|
||||||
@@ -377,11 +376,6 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
|
|||||||
href: "/admin/users",
|
href: "/admin/users",
|
||||||
icon: <Users className="size-4 flex-none" />
|
icon: <Users className="size-4 flex-none" />
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: "sidebarOrgs",
|
|
||||||
href: "/admin/organizations",
|
|
||||||
icon: <Building2Icon className="size-4 flex-none" />
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: "sidebarApiKeys",
|
title: "sidebarApiKeys",
|
||||||
href: "/admin/api-keys",
|
href: "/admin/api-keys",
|
||||||
@@ -398,7 +392,7 @@ export const adminNavSections = (env?: Env): SidebarNavSection[] => [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(build === "enterprise"
|
...(build == "enterprise"
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: "sidebarLicense",
|
title: "sidebarLicense",
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ import {
|
|||||||
ArrowRight,
|
ArrowRight,
|
||||||
ArrowUp10Icon,
|
ArrowUp10Icon,
|
||||||
ChevronsUpDownIcon,
|
ChevronsUpDownIcon,
|
||||||
MoreHorizontal,
|
MoreHorizontal
|
||||||
ShieldUserIcon
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
@@ -44,13 +43,6 @@ import {
|
|||||||
CredenzaClose
|
CredenzaClose
|
||||||
} from "@app/components/Credenza";
|
} from "@app/components/Credenza";
|
||||||
import CopyToClipboard from "@app/components/CopyToClipboard";
|
import CopyToClipboard from "@app/components/CopyToClipboard";
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipProvider,
|
|
||||||
TooltipTrigger
|
|
||||||
} from "./ui/tooltip";
|
|
||||||
import { useUserContext } from "@app/hooks/useUserContext";
|
|
||||||
|
|
||||||
export type GlobalUserRow = {
|
export type GlobalUserRow = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -98,11 +90,6 @@ export default function UsersTable({
|
|||||||
const [passwordResetCodeData, setPasswordResetCodeData] =
|
const [passwordResetCodeData, setPasswordResetCodeData] =
|
||||||
useState<AdminGeneratePasswordResetCodeResponse | null>(null);
|
useState<AdminGeneratePasswordResetCodeResponse | null>(null);
|
||||||
const [isGeneratingCode, setIsGeneratingCode] = useState(false);
|
const [isGeneratingCode, setIsGeneratingCode] = useState(false);
|
||||||
const [isPromoteModalOpen, setIsPromoteModalOpen] = useState(false);
|
|
||||||
const [promoting, setPromoting] = useState<GlobalUserRow | null>(null);
|
|
||||||
const [isDemoteModalOpen, setIsDemoteModalOpen] = useState(false);
|
|
||||||
const [demoting, setDemoting] = useState<GlobalUserRow | null>(null);
|
|
||||||
const user = useUserContext();
|
|
||||||
|
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
const [isRefreshing, startTransition] = useTransition();
|
||||||
const {
|
const {
|
||||||
@@ -122,11 +109,6 @@ export default function UsersTable({
|
|||||||
.optional()
|
.optional()
|
||||||
.catch(undefined);
|
.catch(undefined);
|
||||||
|
|
||||||
const serverAdminFilterSchema = z
|
|
||||||
.enum(["true", "false"])
|
|
||||||
.optional()
|
|
||||||
.catch(undefined);
|
|
||||||
|
|
||||||
function handleFilterChange(
|
function handleFilterChange(
|
||||||
column: string,
|
column: string,
|
||||||
value: string | undefined | null
|
value: string | undefined | null
|
||||||
@@ -202,54 +184,6 @@ export default function UsersTable({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const setServerAdmin = async (
|
|
||||||
targetUser: GlobalUserRow,
|
|
||||||
serverAdmin: boolean
|
|
||||||
) => {
|
|
||||||
const successTitleKey = serverAdmin
|
|
||||||
? "promoteServerAdminSuccess"
|
|
||||||
: "demoteServerAdminSuccess";
|
|
||||||
const successDescriptionKey = serverAdmin
|
|
||||||
? "promoteServerAdminSuccessDescription"
|
|
||||||
: "demoteServerAdminSuccessDescription";
|
|
||||||
const errorKey = serverAdmin
|
|
||||||
? "promoteServerAdminError"
|
|
||||||
: "demoteServerAdminError";
|
|
||||||
|
|
||||||
try {
|
|
||||||
await api.post(`/user/${targetUser.id}/server-admin`, {
|
|
||||||
serverAdmin
|
|
||||||
});
|
|
||||||
|
|
||||||
toast({
|
|
||||||
title: t(successTitleKey),
|
|
||||||
description: t(successDescriptionKey, {
|
|
||||||
selectedUser: getUserDisplayName({
|
|
||||||
email: targetUser.email,
|
|
||||||
name: targetUser.name,
|
|
||||||
username: targetUser.username
|
|
||||||
})
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
startTransition(() => {
|
|
||||||
router.refresh();
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
console.error(t(errorKey), e);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t(errorKey),
|
|
||||||
description: formatAxiosError(e, t(errorKey))
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setIsPromoteModalOpen(false);
|
|
||||||
setPromoting(null);
|
|
||||||
setIsDemoteModalOpen(false);
|
|
||||||
setDemoting(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function toggleSort(column: string) {
|
function toggleSort(column: string) {
|
||||||
const newSearch = getNextSortOrder(column, searchParams);
|
const newSearch = getNextSortOrder(column, searchParams);
|
||||||
filter({
|
filter({
|
||||||
@@ -301,32 +235,7 @@ export default function UsersTable({
|
|||||||
<Icon className="ml-2 h-4 w-4" />
|
<Icon className="ml-2 h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="inline-flex gap-1 items-center">
|
|
||||||
{row.original.username}{" "}
|
|
||||||
{row.original.id === user.user.userId && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
·
|
|
||||||
</span>{" "}
|
|
||||||
<span className="text-primary">you</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{row.original.serverAdmin && (
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<ShieldUserIcon className="text-primary size-4 flex-none" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
{t("serverAdmin")}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "email",
|
accessorKey: "email",
|
||||||
@@ -432,37 +341,6 @@ export default function UsersTable({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
accessorKey: "serverAdmin",
|
|
||||||
friendlyName: t("serverAdmin"),
|
|
||||||
header: () => (
|
|
||||||
<ColumnFilterButton
|
|
||||||
options={[
|
|
||||||
{ value: "true", label: t("yes") },
|
|
||||||
{ value: "false", label: t("no") }
|
|
||||||
]}
|
|
||||||
selectedValue={serverAdminFilterSchema.parse(
|
|
||||||
searchParams.get("server_admin") ?? undefined
|
|
||||||
)}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
handleFilterChange("server_admin", value)
|
|
||||||
}
|
|
||||||
searchPlaceholder={t("searchPlaceholder")}
|
|
||||||
emptyMessage={t("emptySearchOptions")}
|
|
||||||
label={t("serverAdmin")}
|
|
||||||
className="p-3"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span>
|
|
||||||
{row.original.serverAdmin ? (
|
|
||||||
<span>{t("yes")}</span>
|
|
||||||
) : (
|
|
||||||
<span>{t("no")}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "actions",
|
id: "actions",
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
@@ -491,34 +369,11 @@ export default function UsersTable({
|
|||||||
{t("generatePasswordResetCode")}
|
{t("generatePasswordResetCode")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
{r.type === "internal" && !r.serverAdmin && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setPromoting(r);
|
|
||||||
setIsPromoteModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("promoteServerAdmin")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
{r.type === "internal" &&
|
|
||||||
r.serverAdmin &&
|
|
||||||
r.id !== user.user.userId && (
|
|
||||||
<DropdownMenuItem
|
|
||||||
onClick={() => {
|
|
||||||
setDemoting(r);
|
|
||||||
setIsDemoteModalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("demoteServerAdmin")}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelected(r);
|
setSelected(r);
|
||||||
setIsDeleteModalOpen(true);
|
setIsDeleteModalOpen(true);
|
||||||
}}
|
}}
|
||||||
className="text-red-400"
|
|
||||||
>
|
>
|
||||||
{t("delete")}
|
{t("delete")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -580,86 +435,6 @@ export default function UsersTable({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{promoting && (
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
open={isPromoteModalOpen}
|
|
||||||
setOpen={(val) => {
|
|
||||||
setIsPromoteModalOpen(val);
|
|
||||||
if (!val) {
|
|
||||||
setPromoting(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
dialog={
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p>
|
|
||||||
{t("promoteServerAdminQuestion", {
|
|
||||||
selectedUser: getUserDisplayName({
|
|
||||||
email: promoting.email,
|
|
||||||
name: promoting.name,
|
|
||||||
username: promoting.username
|
|
||||||
})
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>{t("promoteServerAdminMessage")}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
buttonText={t("promoteServerAdminConfirm")}
|
|
||||||
onConfirm={async () => setServerAdmin(promoting, true)}
|
|
||||||
string={getUserDisplayName({
|
|
||||||
email: promoting.email,
|
|
||||||
name: promoting.name,
|
|
||||||
username: promoting.username
|
|
||||||
})}
|
|
||||||
warningText={t("promoteServerAdminWarning")}
|
|
||||||
title={t("promoteServerAdminTitle")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{demoting && (
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
open={isDemoteModalOpen}
|
|
||||||
setOpen={(val) => {
|
|
||||||
setIsDemoteModalOpen(val);
|
|
||||||
if (!val) {
|
|
||||||
setDemoting(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
dialog={
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p>
|
|
||||||
{t("demoteServerAdminQuestion", {
|
|
||||||
selectedUser: getUserDisplayName({
|
|
||||||
email: demoting.email,
|
|
||||||
name: demoting.name,
|
|
||||||
username: demoting.username
|
|
||||||
})
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
{t("demoteServerAdminMessage", {
|
|
||||||
selectedUser: getUserDisplayName({
|
|
||||||
email: demoting.email,
|
|
||||||
name: demoting.name,
|
|
||||||
username: demoting.username
|
|
||||||
})
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
buttonText={t("demoteServerAdminConfirm")}
|
|
||||||
onConfirm={async () => setServerAdmin(demoting, false)}
|
|
||||||
string={getUserDisplayName({
|
|
||||||
email: demoting.email,
|
|
||||||
name: demoting.name,
|
|
||||||
username: demoting.username
|
|
||||||
})}
|
|
||||||
warningText={t("demoteServerAdminWarning")}
|
|
||||||
title={t("demoteServerAdminTitle")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ControlledDataTable
|
<ControlledDataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={users}
|
rows={users}
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ function ApprovalRequest({ approval, orgId, onSuccess }: ApprovalRequestProps) {
|
|||||||
<div className="inline-flex items-start md:items-center gap-2">
|
<div className="inline-flex items-start md:items-center gap-2">
|
||||||
<span>
|
<span>
|
||||||
<Link
|
<Link
|
||||||
href={`/${orgId}/settings/access/users/${approval.user.userId}/general`}
|
href={`/${orgId}/settings/access/users/${approval.user.userId}/access-controls`}
|
||||||
className="text-primary hover:underline cursor-pointer"
|
className="text-primary hover:underline cursor-pointer"
|
||||||
>
|
>
|
||||||
{getUserDisplayName({
|
{getUserDisplayName({
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Button } from "@app/components/ui/button";
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Card, CardContent } from "@app/components/ui/card";
|
||||||
import {
|
import {
|
||||||
SettingsSection,
|
ShieldCheck,
|
||||||
SettingsSectionBody,
|
Check,
|
||||||
SettingsSectionFooter
|
Ban,
|
||||||
} from "@app/components/Settings";
|
User,
|
||||||
import { ArrowRight, Settings, ShieldCheck, User } from "lucide-react";
|
Settings,
|
||||||
|
ArrowRight
|
||||||
|
} from "lucide-react";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
@@ -18,50 +21,102 @@ export function ApprovalsEmptyState({ orgId }: ApprovalsEmptyStateProps) {
|
|||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection>
|
<div className="flex flex-col gap-6">
|
||||||
<SettingsSectionBody>
|
<Card>
|
||||||
<div className="flex flex-col items-center text-center py-6 md:py-10 px-2">
|
<CardContent className="p-6 md:p-12">
|
||||||
<ShieldCheck className="h-8 w-8 text-primary" />
|
<div className="flex flex-col items-center text-center gap-4 md:gap-6 max-w-2xl mx-auto">
|
||||||
<h2 className="mt-4 text-2xl font-semibold tracking-tight max-w-xl">
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-xl md:text-2xl font-semibold">
|
||||||
{t("approvalsEmptyStateTitle")}
|
{t("approvalsEmptyStateTitle")}
|
||||||
</h2>
|
</h3>
|
||||||
<p className="mt-3 text-sm text-muted-foreground max-w-lg">
|
<p className="text-muted-foreground text-sm md:text-lg">
|
||||||
{t("approvalsEmptyStateDescription")}
|
{t("approvalsEmptyStateDescription")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-8 w-full max-w-lg text-left space-y-3">
|
|
||||||
<p className="text-sm font-medium text-center">
|
|
||||||
{t("approvalsEmptyStateHowToTitle")}
|
|
||||||
</p>
|
|
||||||
<ul className="text-sm text-muted-foreground space-y-2">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<Settings className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
|
||||||
<span>
|
|
||||||
{t("approvalsEmptyStateStep1Description")}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<User className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
|
||||||
<span>
|
|
||||||
{t("approvalsEmptyStateStep2Description")}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="mt-8 text-sm text-muted-foreground max-w-lg">
|
<div className="w-full space-y-3 md:space-y-4 mt-2 md:mt-4">
|
||||||
{t("approvalsEmptyStatePreviewDescription")}
|
<div className="bg-muted/50 rounded-lg p-4 md:p-6 space-y-3 md:space-y-4 border">
|
||||||
|
<div className="flex items-start gap-3 md:gap-4">
|
||||||
|
<div className="rounded-lg bg-background p-2 md:p-3 border shrink-0">
|
||||||
|
<Settings className="w-4 h-4 md:w-5 md:h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left min-w-0">
|
||||||
|
<h4 className="font-semibold mb-1 text-sm md:text-base">
|
||||||
|
{t("approvalsEmptyStateStep1Title")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs md:text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"approvalsEmptyStateStep1Description"
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</SettingsSectionBody>
|
</div>
|
||||||
<SettingsSectionFooter className="justify-center md:justify-center">
|
|
||||||
<Button asChild>
|
<div className="flex items-start gap-3 md:gap-4">
|
||||||
<Link href={`/${orgId}/settings/access/roles`}>
|
<div className="rounded-lg bg-background p-2 md:p-3 border shrink-0">
|
||||||
|
<User className="w-4 h-4 md:w-5 md:h-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 text-left min-w-0">
|
||||||
|
<h4 className="font-semibold mb-1 text-sm md:text-base">
|
||||||
|
{t("approvalsEmptyStateStep2Title")}
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs md:text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"approvalsEmptyStateStep2Description"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Abstract UI Preview - Hidden on mobile */}
|
||||||
|
<div className="hidden md:block bg-muted/50 rounded-lg p-6 border">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 bg-background rounded border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<User className="w-4 h-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-3 w-24 bg-muted-foreground/20 rounded mb-1"></div>
|
||||||
|
<div className="h-2 w-32 bg-muted-foreground/10 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="h-6 w-16 bg-muted-foreground/10 rounded"></div>
|
||||||
|
<div className="h-6 w-16 bg-muted-foreground/10 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between p-3 bg-background rounded border">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<User className="w-4 h-4 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="h-3 w-24 bg-muted-foreground/20 rounded mb-1"></div>
|
||||||
|
<div className="h-2 w-32 bg-muted-foreground/10 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="h-6 w-16 bg-green-500/20 rounded flex items-center justify-center">
|
||||||
|
<Check className="w-3 h-3 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<div className="h-6 w-16 bg-muted-foreground/10 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href={`/${orgId}/settings/access/roles`} className="w-full md:w-auto">
|
||||||
|
<Button className="gap-2 mt-2 w-full md:w-auto">
|
||||||
{t("approvalsEmptyStateButtonText")}
|
{t("approvalsEmptyStateButtonText")}
|
||||||
<ArrowRight className="ml-2 h-4 w-4" />
|
<ArrowRight className="w-4 h-4" />
|
||||||
</Link>
|
|
||||||
</Button>
|
</Button>
|
||||||
</SettingsSectionFooter>
|
</Link>
|
||||||
</SettingsSection>
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,13 +78,16 @@ export default function GenerateLicenseKeysTable({
|
|||||||
);
|
);
|
||||||
toast({
|
toast({
|
||||||
title: t("success"),
|
title: t("success"),
|
||||||
description: "Instance name cleared successfully"
|
description: "Server ID cleared successfully"
|
||||||
});
|
});
|
||||||
await refreshData();
|
await refreshData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast({
|
toast({
|
||||||
title: t("error"),
|
title: t("error"),
|
||||||
description: formatAxiosError(error, "Failed to clear instance name"),
|
description: formatAxiosError(
|
||||||
|
error,
|
||||||
|
"Failed to clear server ID"
|
||||||
|
),
|
||||||
variant: "destructive"
|
variant: "destructive"
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -291,7 +294,7 @@ export default function GenerateLicenseKeysTable({
|
|||||||
clearInstanceName(key.licenseKey)
|
clearInstanceName(key.licenseKey)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
Clear Instance Name
|
{t("clearInstanceName")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { cn } from "@app/lib/cn";
|
|
||||||
|
|
||||||
type LoadingDotsProps = {
|
|
||||||
className?: string;
|
|
||||||
size?: "sm" | "md";
|
|
||||||
};
|
|
||||||
|
|
||||||
const sizeClasses = {
|
|
||||||
sm: {
|
|
||||||
gap: "gap-1.5",
|
|
||||||
dot: "h-1.5 w-1.5"
|
|
||||||
},
|
|
||||||
md: {
|
|
||||||
gap: "gap-2.5",
|
|
||||||
dot: "h-2.5 w-2.5"
|
|
||||||
}
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export default function LoadingDots({
|
|
||||||
className,
|
|
||||||
size = "md"
|
|
||||||
}: LoadingDotsProps) {
|
|
||||||
const classes = sizeClasses[size];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"flex items-center text-muted-foreground",
|
|
||||||
classes.gap,
|
|
||||||
className
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"rounded-full bg-current animate-dot-pulse",
|
|
||||||
classes.dot
|
|
||||||
)}
|
|
||||||
style={{ animationDelay: "0ms" }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"rounded-full bg-current animate-dot-pulse",
|
|
||||||
classes.dot
|
|
||||||
)}
|
|
||||||
style={{ animationDelay: "200ms" }}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"rounded-full bg-current animate-dot-pulse",
|
|
||||||
classes.dot
|
|
||||||
)}
|
|
||||||
style={{ animationDelay: "400ms" }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSub,
|
DropdownMenuTrigger
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger
|
|
||||||
} from "@app/components/ui/dropdown-menu";
|
} from "@app/components/ui/dropdown-menu";
|
||||||
import { Check, Languages } from "lucide-react";
|
import { Button } from "@app/components/ui/button";
|
||||||
|
import { Check, Globe, Languages } from "lucide-react";
|
||||||
|
import clsx from "clsx";
|
||||||
import { useTransition } from "react";
|
import { useTransition } from "react";
|
||||||
import { Locale } from "@/i18n/config";
|
import { Locale } from "@/i18n/config";
|
||||||
import { setUserLocale } from "@/services/locale";
|
import { setUserLocale } from "@/services/locale";
|
||||||
import { createApiClient } from "@app/lib/api";
|
import { createApiClient } from "@app/lib/api";
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { cn } from "@app/lib/cn";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
defaultValue: string;
|
defaultValue: string;
|
||||||
@@ -42,31 +43,36 @@ export default function LocaleSwitcherSelect({
|
|||||||
const selected = items.find((item) => item.value === defaultValue);
|
const selected = items.find((item) => item.value === defaultValue);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuSub>
|
<DropdownMenu>
|
||||||
<DropdownMenuSubTrigger
|
<DropdownMenuTrigger asChild>
|
||||||
className={cn(
|
<Button
|
||||||
"[&_svg:not([class*='text-'])]:text-muted-foreground",
|
variant="ghost"
|
||||||
|
className={clsx(
|
||||||
|
"w-full rounded-sm h-8 gap-2 justify-start font-normal",
|
||||||
isPending && "pointer-events-none"
|
isPending && "pointer-events-none"
|
||||||
)}
|
)}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
<Languages className="mr-2 h-4 w-4" />
|
<Languages className="text-muted-foreground h-4 w-4" />
|
||||||
<span>{selected?.label ?? label}</span>
|
<span className="text-left flex-1">
|
||||||
</DropdownMenuSubTrigger>
|
{selected?.label ?? label}
|
||||||
<DropdownMenuSubContent className="min-w-[8rem]">
|
</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="min-w-[8rem]">
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={item.value}
|
key={item.value}
|
||||||
onClick={() => onChange(item.value)}
|
onClick={() => onChange(item.value)}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<span>{item.label}</span>
|
|
||||||
{item.value === defaultValue && (
|
{item.value === defaultValue && (
|
||||||
<Check className="ml-auto h-4 w-4" />
|
<Check className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
|
<span>{item.label}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
))}
|
))}
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import BrandingLogo from "@app/components/BrandingLogo";
|
|
||||||
import LoadingDots from "@app/components/LoadingDots";
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
|
|
||||||
|
|
||||||
export default function OrgRouteLoading() {
|
|
||||||
const { env } = useEnvContext();
|
|
||||||
const { isUnlocked } = useLicenseStatusContext();
|
|
||||||
|
|
||||||
const logoWidth = isUnlocked()
|
|
||||||
? env.branding.logo?.navbar?.width || 98
|
|
||||||
: 98;
|
|
||||||
const logoHeight = isUnlocked()
|
|
||||||
? env.branding.logo?.navbar?.height || 32
|
|
||||||
: 32;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative h-screen-safe overflow-hidden">
|
|
||||||
<div className="absolute top-0 left-0 right-0 z-50">
|
|
||||||
<div className="px-6 py-2">
|
|
||||||
<div className="container mx-auto max-w-12xl">
|
|
||||||
<div className="flex h-16 items-center">
|
|
||||||
<Link
|
|
||||||
href="/"
|
|
||||||
className="flex shrink-0 items-center"
|
|
||||||
>
|
|
||||||
<BrandingLogo
|
|
||||||
width={logoWidth}
|
|
||||||
height={logoHeight}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<LoadingDots size="md" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -27,7 +27,15 @@ export function OrgSelector({
|
|||||||
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
|
const selectedOrg = orgs?.find((org) => org.orgId === orgId);
|
||||||
|
|
||||||
const picker = (
|
const picker = (
|
||||||
<OrgPicker orgId={orgId} orgs={orgs} contentClassName="w-[320px]">
|
<OrgPicker
|
||||||
|
orgId={orgId}
|
||||||
|
orgs={orgs}
|
||||||
|
contentClassName={
|
||||||
|
isCollapsed
|
||||||
|
? "w-[320px]"
|
||||||
|
: "w-[var(--radix-popover-trigger-width)]"
|
||||||
|
}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
role="combobox"
|
role="combobox"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -1,290 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Button } from "@app/components/ui/button";
|
|
||||||
import {
|
|
||||||
ControlledDataTable,
|
|
||||||
type ExtendedColumnDef
|
|
||||||
} from "@app/components/ui/controlled-data-table";
|
|
||||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
|
||||||
import { toast } from "@app/hooks/useToast";
|
|
||||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
|
||||||
import type { AdminOrgRow } from "@server/routers/org";
|
|
||||||
|
|
||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
|
||||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
|
||||||
import { type PaginationState } from "@tanstack/react-table";
|
|
||||||
import {
|
|
||||||
ArrowDown01Icon,
|
|
||||||
ArrowUp10Icon,
|
|
||||||
ArrowUpRight,
|
|
||||||
ChevronsUpDownIcon
|
|
||||||
} from "lucide-react";
|
|
||||||
import moment from "moment";
|
|
||||||
import { useTranslations } from "next-intl";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useMemo, useState, useTransition } from "react";
|
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
|
||||||
import ConfirmDeleteDialog from "./ConfirmDeleteDialog";
|
|
||||||
import CopyToClipboard from "./CopyToClipboard";
|
|
||||||
|
|
||||||
type OrgTableProps = {
|
|
||||||
orgs: AdminOrgRow[];
|
|
||||||
pagination: PaginationState;
|
|
||||||
rowCount: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function OrgsTable({
|
|
||||||
orgs,
|
|
||||||
pagination,
|
|
||||||
rowCount
|
|
||||||
}: OrgTableProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const t = useTranslations();
|
|
||||||
const {
|
|
||||||
navigate: filter,
|
|
||||||
isNavigating: isFiltering,
|
|
||||||
searchParams
|
|
||||||
} = useNavigationContext();
|
|
||||||
|
|
||||||
const [isRefreshing, startTransition] = useTransition();
|
|
||||||
|
|
||||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
|
||||||
const [selectedOrg, setSelectedOrg] = useState<AdminOrgRow | null>();
|
|
||||||
const api = createApiClient(useEnvContext());
|
|
||||||
|
|
||||||
function refreshData() {
|
|
||||||
startTransition(async () => {
|
|
||||||
try {
|
|
||||||
router.refresh();
|
|
||||||
} catch (error) {
|
|
||||||
toast({
|
|
||||||
title: t("error"),
|
|
||||||
description: t("refreshError"),
|
|
||||||
variant: "destructive"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSort(column: string) {
|
|
||||||
const newSearch = getNextSortOrder(column, searchParams);
|
|
||||||
|
|
||||||
filter({
|
|
||||||
searchParams: newSearch
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortableHeader(column: string, label: string) {
|
|
||||||
const sortOrder = getSortDirection(column, searchParams);
|
|
||||||
const Icon =
|
|
||||||
sortOrder === "asc"
|
|
||||||
? ArrowDown01Icon
|
|
||||||
: sortOrder === "desc"
|
|
||||||
? ArrowUp10Icon
|
|
||||||
: ChevronsUpDownIcon;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="p-3"
|
|
||||||
onClick={() => toggleSort(column)}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
<Icon className="ml-2 h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns = useMemo<ExtendedColumnDef<AdminOrgRow>[]>(() => {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
accessorKey: "name",
|
|
||||||
friendlyName: t("name"),
|
|
||||||
enableHiding: false,
|
|
||||||
header: () => sortableHeader("name", t("name"))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "orgId",
|
|
||||||
friendlyName: t("orgId"),
|
|
||||||
header: () => <span className="p-3">{t("orgId")}</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<CopyToClipboard text={row.original.orgId} isLink={false} />
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "createdAt",
|
|
||||||
friendlyName: t("createdAt"),
|
|
||||||
header: () => sortableHeader("createdAt", t("createdAt")),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const createdAt = row.original.createdAt;
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
{createdAt ? moment(createdAt).format("lll") : "-"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "owner",
|
|
||||||
friendlyName: t("accessRoleOwner"),
|
|
||||||
header: () => (
|
|
||||||
<span className="p-3">{t("accessRoleOwner")}</span>
|
|
||||||
),
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const owner = row.original.owner;
|
|
||||||
return owner ? (
|
|
||||||
<Button
|
|
||||||
className="tabular-nums"
|
|
||||||
asChild
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
>
|
|
||||||
<Link href={`/admin/users/${owner.userId}`}>
|
|
||||||
{owner.username}
|
|
||||||
<ArrowUpRight className="ml-2 h-3 w-3" />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<code>-</code>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "subnet",
|
|
||||||
friendlyName: t("subnet"),
|
|
||||||
header: () => <span className="p-3">{t("subnet")}</span>,
|
|
||||||
cell: ({ row }) => <span>{row.original.subnet || "-"}</span>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "utilitySubnet",
|
|
||||||
friendlyName: t("utilitySubnet"),
|
|
||||||
header: () => <span className="p-3">{t("utilitySubnet")}</span>,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span>{row.original.utilitySubnet || "-"}</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "userCount",
|
|
||||||
friendlyName: t("users"),
|
|
||||||
header: () => <span className="p-3">{t("users")}</span>,
|
|
||||||
cell: ({ row }) => <span>{row.original.userCount}</span>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "siteCount",
|
|
||||||
friendlyName: t("sites"),
|
|
||||||
header: () => <span className="p-3">{t("sites")}</span>,
|
|
||||||
cell: ({ row }) => <span>{row.original.siteCount}</span>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "resourceCount",
|
|
||||||
friendlyName: t("resources"),
|
|
||||||
header: () => <span className="p-3">{t("resources")}</span>,
|
|
||||||
cell: ({ row }) => <span>{row.original.resourceCount}</span>
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
id: "actions",
|
|
||||||
enableHiding: false,
|
|
||||||
header: () => <span className="p-3"></span>,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const orgRow = row.original;
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 justify-end">
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedOrg(orgRow);
|
|
||||||
setIsDeleteModalOpen(true);
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
>
|
|
||||||
{t("delete")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
];
|
|
||||||
}, [t, searchParams]);
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
async function deleteOrg(orgId: string) {
|
|
||||||
try {
|
|
||||||
const res = await api.delete(`/admin/org/${orgId}`);
|
|
||||||
toast({
|
|
||||||
title: t("orgDeleted"),
|
|
||||||
description: t("orgDeletedMessage")
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
title: t("orgErrorDelete"),
|
|
||||||
description: formatAxiosError(err, t("orgErrorDeleteMessage"))
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{selectedOrg && (
|
|
||||||
<ConfirmDeleteDialog
|
|
||||||
open={isDeleteModalOpen}
|
|
||||||
setOpen={(val) => {
|
|
||||||
setIsDeleteModalOpen(val);
|
|
||||||
setSelectedOrg(null);
|
|
||||||
}}
|
|
||||||
dialog={
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p>{t("orgQuestionRemove")}</p>
|
|
||||||
<p>{t("orgMessageRemove")}</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
buttonText={t("orgDeleteConfirm")}
|
|
||||||
onConfirm={async () => {
|
|
||||||
startTransition(() => deleteOrg(selectedOrg.orgId));
|
|
||||||
}}
|
|
||||||
string={selectedOrg.name}
|
|
||||||
title={t("orgDelete")}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<ControlledDataTable
|
|
||||||
columns={columns}
|
|
||||||
rows={orgs}
|
|
||||||
tableId="admin-orgs-table"
|
|
||||||
searchPlaceholder={t("orgSearch")}
|
|
||||||
pagination={pagination}
|
|
||||||
onPaginationChange={handlePaginationChange}
|
|
||||||
searchQuery={searchParams.get("query")?.toString()}
|
|
||||||
onSearch={handleSearchChange}
|
|
||||||
onRefresh={refreshData}
|
|
||||||
isRefreshing={isRefreshing || isFiltering}
|
|
||||||
rowCount={rowCount}
|
|
||||||
columnVisibility={{
|
|
||||||
subnet: false,
|
|
||||||
utilitySubnet: false
|
|
||||||
}}
|
|
||||||
enableColumnVisibility
|
|
||||||
stickyLeftColumn="name"
|
|
||||||
stickyRightColumn="actions"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -9,16 +9,13 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuLabel,
|
DropdownMenuLabel,
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuSub,
|
|
||||||
DropdownMenuSubContent,
|
|
||||||
DropdownMenuSubTrigger,
|
|
||||||
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 { toast } from "@app/hooks/useToast";
|
import { toast } from "@app/hooks/useToast";
|
||||||
import { formatAxiosError } from "@app/lib/api";
|
import { formatAxiosError } from "@app/lib/api";
|
||||||
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
import { getUserDisplayName } from "@app/lib/getUserDisplayName";
|
||||||
import { Check, Laptop, Moon, Sun, Trash2 } from "lucide-react";
|
import { Laptop, LogOut, Moon, Sun, Smartphone, Trash2 } from "lucide-react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
@@ -152,49 +149,42 @@ export default function ProfileIcon() {
|
|||||||
>
|
>
|
||||||
<span>{t("changePassword")}</span>
|
<span>{t("changePassword")}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem onClick={() => setOpenViewDevices(true)}>
|
<DropdownMenuItem onClick={() => setOpenViewDevices(true)}>
|
||||||
|
<Smartphone className="mr-2 h-4 w-4" />
|
||||||
<span>{t("viewDevices") || "View Devices"}</span>
|
<span>{t("viewDevices") || "View Devices"}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel>{t("theme")}</DropdownMenuLabel>
|
<DropdownMenuLabel>{t("theme")}</DropdownMenuLabel>
|
||||||
<DropdownMenuSub>
|
|
||||||
<DropdownMenuSubTrigger className="[&_svg:not([class*='text-'])]:text-muted-foreground">
|
|
||||||
{userTheme === "light" && (
|
|
||||||
<Sun className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{userTheme === "dark" && (
|
|
||||||
<Moon className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{userTheme === "system" && (
|
|
||||||
<Laptop className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span className="capitalize">{t(userTheme)}</span>
|
|
||||||
</DropdownMenuSubTrigger>
|
|
||||||
<DropdownMenuSubContent className="min-w-[8rem]">
|
|
||||||
{(["light", "dark", "system"] as const).map(
|
{(["light", "dark", "system"] as const).map(
|
||||||
(themeOption) => (
|
(themeOption) => (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={themeOption}
|
key={themeOption}
|
||||||
onClick={() =>
|
onClick={() => handleThemeChange(themeOption)}
|
||||||
handleThemeChange(themeOption)
|
|
||||||
}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
>
|
||||||
|
{themeOption === "light" && (
|
||||||
|
<Sun className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{themeOption === "dark" && (
|
||||||
|
<Moon className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{themeOption === "system" && (
|
||||||
|
<Laptop className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
<span className="capitalize">
|
<span className="capitalize">
|
||||||
{t(themeOption)}
|
{t(themeOption)}
|
||||||
</span>
|
</span>
|
||||||
{userTheme === themeOption && (
|
{userTheme === themeOption && (
|
||||||
<Check className="ml-auto h-4 w-4" />
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<span className="h-2 w-2 rounded-full bg-primary"></span>
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</DropdownMenuSubContent>
|
|
||||||
</DropdownMenuSub>
|
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuLabel>{t("language")}</DropdownMenuLabel>
|
|
||||||
<LocaleSwitcher />
|
<LocaleSwitcher />
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
{user?.type === UserType.Internal && !user?.serverAdmin && (
|
{user?.type === UserType.Internal && !user?.serverAdmin && (
|
||||||
|
|||||||
@@ -21,12 +21,14 @@ import { Switch } from "@app/components/ui/switch";
|
|||||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||||
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
import { useNavigationContext } from "@app/hooks/useNavigationContext";
|
||||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||||
|
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 { orgQueries } from "@app/lib/queries";
|
import { orgQueries } from "@app/lib/queries";
|
||||||
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn";
|
||||||
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
import { build } from "@server/build";
|
||||||
import { UpdateResourceResponse } from "@server/routers/resource";
|
import { UpdateResourceResponse } from "@server/routers/resource";
|
||||||
|
import type { GetBatchedCertificateResponse } from "@server/routers/certificates/types";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { PaginationState } from "@tanstack/react-table";
|
import type { PaginationState } from "@tanstack/react-table";
|
||||||
import { AxiosResponse } from "axios";
|
import { AxiosResponse } from "axios";
|
||||||
|
|||||||
@@ -52,11 +52,9 @@ export default function RegenerateInvitationForm({
|
|||||||
}: RegenerateInvitationFormProps) {
|
}: RegenerateInvitationFormProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
const [inviteLink, setInviteLink] = useState<string | null>(null);
|
||||||
const [expiresInDays, setExpiresInDays] = useState(3);
|
const [sendEmail, setSendEmail] = useState(true);
|
||||||
const { env } = useEnvContext();
|
|
||||||
const [sendEmail, setSendEmail] = useState(env.email.emailEnabled);
|
|
||||||
const [validHours, setValidHours] = useState(72);
|
const [validHours, setValidHours] = useState(72);
|
||||||
const api = createApiClient({ env });
|
const api = createApiClient(useEnvContext());
|
||||||
const { org } = useOrgContext();
|
const { org } = useOrgContext();
|
||||||
|
|
||||||
const t = useTranslations();
|
const t = useTranslations();
|
||||||
@@ -73,11 +71,10 @@ export default function RegenerateInvitationForm({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setSendEmail(env.email.emailEnabled);
|
setSendEmail(true);
|
||||||
setValidHours(72);
|
setValidHours(72);
|
||||||
setExpiresInDays(3);
|
|
||||||
}
|
}
|
||||||
}, [open, env.email.emailEnabled]);
|
}, [open]);
|
||||||
|
|
||||||
async function handleRegenerate() {
|
async function handleRegenerate() {
|
||||||
if (!invitation) return;
|
if (!invitation) return;
|
||||||
@@ -99,16 +96,15 @@ export default function RegenerateInvitationForm({
|
|||||||
email: invitation.email,
|
email: invitation.email,
|
||||||
roleIds: invitation.roleIds,
|
roleIds: invitation.roleIds,
|
||||||
validHours,
|
validHours,
|
||||||
sendEmail: env.email.emailEnabled && sendEmail,
|
sendEmail,
|
||||||
regenerate: true
|
regenerate: true
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.status === 200) {
|
if (res.status === 200) {
|
||||||
const link = res.data.data.inviteLink;
|
const link = res.data.data.inviteLink;
|
||||||
setInviteLink(link);
|
setInviteLink(link);
|
||||||
setExpiresInDays(validHours / 24);
|
|
||||||
|
|
||||||
if (sendEmail && env.email.emailEnabled) {
|
if (sendEmail) {
|
||||||
toast({
|
toast({
|
||||||
variant: "default",
|
variant: "default",
|
||||||
title: t("inviteRegenerated"),
|
title: t("inviteRegenerated"),
|
||||||
@@ -131,7 +127,9 @@ export default function RegenerateInvitationForm({
|
|||||||
onRegenerate({
|
onRegenerate({
|
||||||
id: invitation.id,
|
id: invitation.id,
|
||||||
email: invitation.email,
|
email: invitation.email,
|
||||||
expiresAt: new Date(res.data.data.expiresAt).toISOString(),
|
expiresAt: new Date(
|
||||||
|
res.data.data.expiresAt
|
||||||
|
).toISOString(),
|
||||||
roleLabels: invitation.roleLabels,
|
roleLabels: invitation.roleLabels,
|
||||||
roleIds: invitation.roleIds
|
roleIds: invitation.roleIds
|
||||||
});
|
});
|
||||||
@@ -176,29 +174,20 @@ export default function RegenerateInvitationForm({
|
|||||||
>
|
>
|
||||||
<CredenzaContent>
|
<CredenzaContent>
|
||||||
<CredenzaHeader>
|
<CredenzaHeader>
|
||||||
<CredenzaTitle>
|
<CredenzaTitle>{t("inviteRegenerate")}</CredenzaTitle>
|
||||||
{inviteLink
|
|
||||||
? t("inviteRegenerated")
|
|
||||||
: t("inviteRegenerate")}
|
|
||||||
</CredenzaTitle>
|
|
||||||
<CredenzaDescription>
|
<CredenzaDescription>
|
||||||
{inviteLink
|
{t("inviteRegenerateDescription")}
|
||||||
? sendEmail && env.email.emailEnabled
|
|
||||||
? t("inviteEmailSentDescription")
|
|
||||||
: t("inviteSentDescription")
|
|
||||||
: t("inviteRegenerateDescription")}
|
|
||||||
</CredenzaDescription>
|
</CredenzaDescription>
|
||||||
</CredenzaHeader>
|
</CredenzaHeader>
|
||||||
<CredenzaBody>
|
<CredenzaBody>
|
||||||
{!inviteLink ? (
|
{!inviteLink ? (
|
||||||
<div className="space-y-4">
|
<div>
|
||||||
<div className="space-y-2">
|
<p>
|
||||||
<Label>{t("email")}</Label>
|
{t("inviteQuestionRegenerate", {
|
||||||
<p className="text-sm">{invitation?.email}</p>
|
email: invitation?.email || ""
|
||||||
</div>
|
})}
|
||||||
|
</p>
|
||||||
{env.email.emailEnabled && (
|
<div className="flex items-center space-x-2 mt-4">
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="send-email"
|
id="send-email"
|
||||||
checked={sendEmail}
|
checked={sendEmail}
|
||||||
@@ -206,17 +195,12 @@ export default function RegenerateInvitationForm({
|
|||||||
setSendEmail(e as boolean)
|
setSendEmail(e as boolean)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<label
|
<label htmlFor="send-email">
|
||||||
htmlFor="send-email"
|
{t("inviteSentEmail")}
|
||||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
|
||||||
>
|
|
||||||
{t("inviteEmailSent")}
|
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="mt-4 space-y-2">
|
||||||
|
<Label>{t("inviteValidityPeriod")}</Label>
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t("inviteValid")}</Label>
|
|
||||||
<Select
|
<Select
|
||||||
value={validHours.toString()}
|
value={validHours.toString()}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
@@ -225,7 +209,9 @@ export default function RegenerateInvitationForm({
|
|||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger className="w-full">
|
||||||
<SelectValue
|
<SelectValue
|
||||||
placeholder={t("selectDuration")}
|
placeholder={t(
|
||||||
|
"inviteValidityPeriodSelect"
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@@ -242,13 +228,9 @@ export default function RegenerateInvitationForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4 max-w-md">
|
||||||
<p>
|
<p>{t("inviteRegenerateMessage")}</p>
|
||||||
{t("inviteExpiresIn", {
|
<CopyTextBox text={inviteLink} wrapText={false} />
|
||||||
days: expiresInDays
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
{inviteLink && <CopyTextBox text={inviteLink} />}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CredenzaBody>
|
</CredenzaBody>
|
||||||
@@ -267,7 +249,7 @@ export default function RegenerateInvitationForm({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<CredenzaClose asChild>
|
<CredenzaClose asChild>
|
||||||
<Button>{t("done")}</Button>
|
<Button variant="outline">{t("close")}</Button>
|
||||||
</CredenzaClose>
|
</CredenzaClose>
|
||||||
)}
|
)}
|
||||||
</CredenzaFooter>
|
</CredenzaFooter>
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ import {
|
|||||||
} from "./ui/controlled-data-table";
|
} from "./ui/controlled-data-table";
|
||||||
|
|
||||||
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels";
|
||||||
|
import { durationToMs } from "@app/lib/durationToMs";
|
||||||
import { orgQueries, productUpdatesQueries } from "@app/lib/queries";
|
import { orgQueries, productUpdatesQueries } from "@app/lib/queries";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import semver from "semver";
|
import semver from "semver";
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user