Merge branch 'dev' into refactor/domain-picker-default-value

This commit is contained in:
Fred KISSIE
2025-12-17 00:52:32 +01:00
92 changed files with 1714 additions and 575 deletions

View File

@@ -107,7 +107,7 @@ jobs:
- name: Build and push Docker images (Docker Hub) - name: Build and push Docker images (Docker Hub)
run: | run: |
TAG=${{ env.TAG }} TAG=${{ env.TAG }}
make build-release tag=$TAG make -j4 build-release tag=$TAG
echo "Built & pushed to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}" echo "Built & pushed to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}"
shell: bash shell: bash

39
.github/workflows/restart-runners.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Restart Runners
on:
schedule:
- cron: '0 0 */7 * *'
permissions:
id-token: write
contents: read
jobs:
ec2-maintenance-prod:
runs-on: ubuntu-latest
permissions: write-all
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v2
with:
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
role-duration-seconds: 3600
aws-region: ${{ secrets.AWS_REGION }}
- name: Verify AWS identity
run: aws sts get-caller-identity
- name: Start EC2 instance
run: |
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
echo "EC2 instances started"
- name: Wait
run: sleep 600
- name: Stop EC2 instance
run: |
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
echo "EC2 instances stopped"

View File

@@ -12,11 +12,12 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 - name: Install Node
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0
with: with:
node-version: '22' node-version: '22'
@@ -57,8 +58,26 @@ jobs:
echo "App failed to start" echo "App failed to start"
exit 1 exit 1
build-sqlite:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Copy config file
run: cp config/config.example.yml config/config.yml
- name: Build Docker image sqlite - name: Build Docker image sqlite
run: make build-sqlite run: make dev-build-sqlite
build-postgres:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Copy config file
run: cp config/config.example.yml config/config.yml
- name: Build Docker image pg - name: Build Docker image pg
run: make build-pg run: make dev-build-pg

View File

@@ -43,23 +43,25 @@ RUN test -f dist/server.mjs
RUN npm run build:cli RUN npm run build:cli
# Prune dev dependencies and clean up to prepare for copy to runner
RUN npm prune --omit=dev && npm cache clean --force
FROM node:24-alpine AS runner FROM node:24-alpine AS runner
WORKDIR /app WORKDIR /app
# Curl used for the health checks # Only curl and tzdata needed at runtime - no build tools!
# Python and build tools needed for better-sqlite3 native compilation RUN apk add --no-cache curl tzdata
RUN apk add --no-cache curl tzdata python3 make g++
# COPY package.json package-lock.json ./ # Copy pre-built node_modules from builder (already pruned to production only)
COPY package*.json ./ # This includes the compiled native modules like better-sqlite3
COPY --from=builder /app/node_modules ./node_modules
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
COPY --from=builder /app/init ./dist/init COPY --from=builder /app/init ./dist/init
COPY --from=builder /app/package.json ./package.json
COPY ./cli/wrapper.sh /usr/local/bin/pangctl COPY ./cli/wrapper.sh /usr/local/bin/pangctl
RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs

View File

@@ -1,8 +1,13 @@
.PHONY: build build-pg build-release build-arm build-x86 test clean .PHONY: build dev-build-sqlite dev-build-pg build-release build-arm build-x86 test clean
major_tag := $(shell echo $(tag) | cut -d. -f1) major_tag := $(shell echo $(tag) | cut -d. -f1)
minor_tag := $(shell echo $(tag) | cut -d. -f1,2) minor_tag := $(shell echo $(tag) | cut -d. -f1,2)
build-release:
.PHONY: build-release build-sqlite build-postgresql build-ee-sqlite build-ee-postgresql
build-release: build-sqlite build-postgresql build-ee-sqlite build-ee-postgresql
build-sqlite:
@if [ -z "$(tag)" ]; then \ @if [ -z "$(tag)" ]; then \
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \ echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
exit 1; \ exit 1; \
@@ -16,6 +21,12 @@ build-release:
--tag fosrl/pangolin:$(minor_tag) \ --tag fosrl/pangolin:$(minor_tag) \
--tag fosrl/pangolin:$(tag) \ --tag fosrl/pangolin:$(tag) \
--push . --push .
build-postgresql:
@if [ -z "$(tag)" ]; then \
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
exit 1; \
fi
docker buildx build \ docker buildx build \
--build-arg BUILD=oss \ --build-arg BUILD=oss \
--build-arg DATABASE=pg \ --build-arg DATABASE=pg \
@@ -25,6 +36,12 @@ build-release:
--tag fosrl/pangolin:postgresql-$(minor_tag) \ --tag fosrl/pangolin:postgresql-$(minor_tag) \
--tag fosrl/pangolin:postgresql-$(tag) \ --tag fosrl/pangolin:postgresql-$(tag) \
--push . --push .
build-ee-sqlite:
@if [ -z "$(tag)" ]; then \
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
exit 1; \
fi
docker buildx build \ docker buildx build \
--build-arg BUILD=enterprise \ --build-arg BUILD=enterprise \
--build-arg DATABASE=sqlite \ --build-arg DATABASE=sqlite \
@@ -34,6 +51,12 @@ build-release:
--tag fosrl/pangolin:ee-$(minor_tag) \ --tag fosrl/pangolin:ee-$(minor_tag) \
--tag fosrl/pangolin:ee-$(tag) \ --tag fosrl/pangolin:ee-$(tag) \
--push . --push .
build-ee-postgresql:
@if [ -z "$(tag)" ]; then \
echo "Error: tag is required. Usage: make build-release tag=<tag>"; \
exit 1; \
fi
docker buildx build \ docker buildx build \
--build-arg BUILD=enterprise \ --build-arg BUILD=enterprise \
--build-arg DATABASE=pg \ --build-arg DATABASE=pg \
@@ -80,10 +103,10 @@ build-arm:
build-x86: build-x86:
docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest . docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest .
build-sqlite: dev-build-sqlite:
docker build --build-arg DATABASE=sqlite -t fosrl/pangolin:latest . docker build --build-arg DATABASE=sqlite -t fosrl/pangolin:latest .
build-pg: dev-build-pg:
docker build --build-arg DATABASE=pg -t fosrl/pangolin:postgresql-latest . docker build --build-arg DATABASE=pg -t fosrl/pangolin:postgresql-latest .
test: test:

View File

@@ -41,7 +41,7 @@
</strong> </strong>
</p> </p>
Pangolin is a self-hosted tunneled reverse proxy server with identity and context aware access control, designed to easily expose and protect applications running anywhere. Pangolin acts as a central hub and connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports or requiring a VPN. Pangolin is an open-source, identity-based remote access platform built on WireGuard that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources, all with zero-trust security and granular access control.
## Installation ## Installation
@@ -60,14 +60,20 @@ Pangolin is a self-hosted tunneled reverse proxy server with identity and contex
## Key Features ## Key Features
Pangolin packages everything you need for seamless application access and exposure into one cohesive platform.
| <img width=500 /> | <img width=500 /> | | <img width=500 /> | <img width=500 /> |
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------| |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|
| **Manage applications in one place**<br /><br /> Pangolin provides a unified dashboard where you can monitor, configure, and secure all of your services regardless of where they are hosted. | <img src="public/screenshots/hero.png" width=500 /><tr></tr> | | **Connect remote networks with sites**<br /><br />Pangolin's lightweight site connectors create secure tunnels from remote networks without requiring public IP addresses or open ports. Sites make any network anywhere available for authorized access. | <img src="public/screenshots/sites.png" width=500 /><tr></tr> |
| **Reverse proxy across networks anywhere**<br /><br />Route traffic via tunnels to any private network. Pangolin works like a reverse proxy that spans multiple networks and handles routing, load balancing, health checking, and more to the right services on the other end. | <img src="public/screenshots/sites.png" width=500 /><tr></tr> | | **Browser-based reverse proxy access**<br /><br />Expose web applications through identity and context-aware tunneled reverse proxies. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. Users access applications through any web browser with authentication and granular access control. | <img src="public/clip.gif" width=500 /><tr></tr> |
| **Enforce identity and context aware rules**<br /><br />Protect your applications with identity and context aware rules such as SSO, OIDC, PIN, password, temporary share links, geolocation, IP, and more. | <img src="public/auth-diagram1.png" width=500 /><tr></tr> | | **Client-based private resource access**<br /><br />Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. | <img src="public/screenshots/private-resources.png" width=500 /><tr></tr> |
| **Quickly connect Pangolin sites**<br /><br />Pangolin's lightweight [Newt](https://github.com/fosrl/newt) client runs in userspace and can run anywhere. Use it as a site connector to route traffic to backends across all of your environments. | <img src="public/clip.gif" width=500 /><tr></tr> | | **Zero-trust granular access**<br /><br />Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications and services you explicitly define, reducing security risk and attack surface. | <img src="public/screenshots/user-devices.png" width=500 /><tr></tr> |
## Download Clients
Download the Pangolin client for your platform:
- [Mac](https://pangolin.net/downloads/mac)
- [Windows](https://pangolin.net/downloads/windows)
- [Linux](https://pangolin.net/downloads/linux)
## Get Started ## Get Started

View File

@@ -73,7 +73,7 @@ func installDocker() error {
case strings.Contains(osRelease, "ID=ubuntu"): case strings.Contains(osRelease, "ID=ubuntu"):
installCmd = exec.Command("bash", "-c", fmt.Sprintf(` installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
apt-get update && apt-get update &&
apt-get install -y apt-transport-https ca-certificates curl && apt-get install -y apt-transport-https ca-certificates curl gpg &&
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
apt-get update && apt-get update &&
@@ -82,7 +82,7 @@ func installDocker() error {
case strings.Contains(osRelease, "ID=debian"): case strings.Contains(osRelease, "ID=debian"):
installCmd = exec.Command("bash", "-c", fmt.Sprintf(` installCmd = exec.Command("bash", "-c", fmt.Sprintf(`
apt-get update && apt-get update &&
apt-get install -y apt-transport-https ca-certificates curl && apt-get install -y apt-transport-https ca-certificates curl gpg &&
curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg &&
echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list &&
apt-get update && apt-get update &&

View File

@@ -1043,7 +1043,7 @@
"actionDeleteSite": "Standort löschen", "actionDeleteSite": "Standort löschen",
"actionGetSite": "Standort abrufen", "actionGetSite": "Standort abrufen",
"actionListSites": "Standorte auflisten", "actionListSites": "Standorte auflisten",
"actionApplyBlueprint": "Blaupause anwenden", "actionApplyBlueprint": "Blueprint anwenden",
"setupToken": "Setup-Token", "setupToken": "Setup-Token",
"setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.", "setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.",
"setupTokenRequired": "Setup-Token ist erforderlich", "setupTokenRequired": "Setup-Token ist erforderlich",
@@ -1102,7 +1102,7 @@
"actionDeleteIdpOrg": "IDP-Organisationsrichtlinie löschen", "actionDeleteIdpOrg": "IDP-Organisationsrichtlinie löschen",
"actionListIdpOrgs": "IDP-Organisationen auflisten", "actionListIdpOrgs": "IDP-Organisationen auflisten",
"actionUpdateIdpOrg": "IDP-Organisation aktualisieren", "actionUpdateIdpOrg": "IDP-Organisation aktualisieren",
"actionCreateClient": "Endgerät anlegen", "actionCreateClient": "Client erstellen",
"actionDeleteClient": "Client löschen", "actionDeleteClient": "Client löschen",
"actionUpdateClient": "Client aktualisieren", "actionUpdateClient": "Client aktualisieren",
"actionListClients": "Clients auflisten", "actionListClients": "Clients auflisten",
@@ -1201,24 +1201,24 @@
"sidebarLogsAnalytics": "Analytik", "sidebarLogsAnalytics": "Analytik",
"blueprints": "Baupläne", "blueprints": "Baupläne",
"blueprintsDescription": "Deklarative Konfigurationen anwenden und vorherige Abläufe anzeigen", "blueprintsDescription": "Deklarative Konfigurationen anwenden und vorherige Abläufe anzeigen",
"blueprintAdd": "Blaupause hinzufügen", "blueprintAdd": "Blueprint hinzufügen",
"blueprintGoBack": "Alle Blaupausen ansehen", "blueprintGoBack": "Alle Blueprints ansehen",
"blueprintCreate": "Blaupause erstellen", "blueprintCreate": "Blueprint erstellen",
"blueprintCreateDescription2": "Folge den Schritten unten, um eine neue Blaupause zu erstellen und anzuwenden", "blueprintCreateDescription2": "Folge den unten aufgeführten Schritten, um einen neuen Blueprint zu erstellen und anzuwenden",
"blueprintDetails": "Blaupausendetails", "blueprintDetails": "Blueprint Detailinformationen",
"blueprintDetailsDescription": "Siehe das Ergebnis der angewendeten Blaupause und alle aufgetretenen Fehler", "blueprintDetailsDescription": "Siehe das Ergebnis des angewendeten Blueprints und alle aufgetretenen Fehler",
"blueprintInfo": "Blaupauseninformation", "blueprintInfo": "Blueprint Informationen",
"message": "Nachricht", "message": "Nachricht",
"blueprintContentsDescription": "Den YAML-Inhalt definieren, der die Infrastruktur beschreibt", "blueprintContentsDescription": "Den YAML-Inhalt definieren, der die Infrastruktur beschreibt",
"blueprintErrorCreateDescription": "Fehler beim Anwenden der Blaupause", "blueprintErrorCreateDescription": "Fehler beim Anwenden des Blueprints",
"blueprintErrorCreate": "Fehler beim Erstellen der Blaupause", "blueprintErrorCreate": "Fehler beim Erstellen des Blueprints",
"searchBlueprintProgress": "Blaupausen suchen...", "searchBlueprintProgress": "Blueprints suchen...",
"appliedAt": "Angewandt am", "appliedAt": "Angewandt am",
"source": "Quelle", "source": "Quelle",
"contents": "Inhalt", "contents": "Inhalt",
"parsedContents": "Analysierte Inhalte (Nur lesen)", "parsedContents": "Analysierte Inhalte (Nur lesen)",
"enableDockerSocket": "Docker Blaupause aktivieren", "enableDockerSocket": "Docker Blueprint aktivieren",
"enableDockerSocketDescription": "Aktiviere Docker-Socket-Label-Scraping für Blaupausenbeschriftungen. Der Socket-Pfad muss neu angegeben werden.", "enableDockerSocketDescription": "Aktiviere Docker-Socket-Label-Scraping für Blueprintbeschriftungen. Der Socket-Pfad muss neu angegeben werden.",
"enableDockerSocketLink": "Mehr erfahren", "enableDockerSocketLink": "Mehr erfahren",
"viewDockerContainers": "Docker Container anzeigen", "viewDockerContainers": "Docker Container anzeigen",
"containersIn": "Container in {siteName}", "containersIn": "Container in {siteName}",
@@ -1543,7 +1543,7 @@
"healthCheckPathRequired": "Gesundheits-Check-Pfad ist erforderlich", "healthCheckPathRequired": "Gesundheits-Check-Pfad ist erforderlich",
"healthCheckMethodRequired": "HTTP-Methode ist erforderlich", "healthCheckMethodRequired": "HTTP-Methode ist erforderlich",
"healthCheckIntervalMin": "Prüfintervall muss mindestens 5 Sekunden betragen", "healthCheckIntervalMin": "Prüfintervall muss mindestens 5 Sekunden betragen",
"healthCheckTimeoutMin": "Timeout muss mindestens 1 Sekunde betragen", "healthCheckTimeoutMin": "Zeitüberschreitung muss mindestens 1 Sekunde betragen",
"healthCheckRetryMin": "Wiederholungsversuche müssen mindestens 1 betragen", "healthCheckRetryMin": "Wiederholungsversuche müssen mindestens 1 betragen",
"httpMethod": "HTTP-Methode", "httpMethod": "HTTP-Methode",
"selectHttpMethod": "HTTP-Methode auswählen", "selectHttpMethod": "HTTP-Methode auswählen",

View File

@@ -419,7 +419,7 @@
"userErrorExistsDescription": "This user is already a member of the organization.", "userErrorExistsDescription": "This user is already a member of the organization.",
"inviteError": "Failed to invite user", "inviteError": "Failed to invite user",
"inviteErrorDescription": "An error occurred while inviting the user", "inviteErrorDescription": "An error occurred while inviting the user",
"userInvited": "User invited", "userInvited": "User Invited",
"userInvitedDescription": "The user has been successfully invited.", "userInvitedDescription": "The user has been successfully invited.",
"userErrorCreate": "Failed to create user", "userErrorCreate": "Failed to create user",
"userErrorCreateDescription": "An error occurred while creating the user", "userErrorCreateDescription": "An error occurred while creating the user",
@@ -1035,6 +1035,7 @@
"updateOrgUser": "Update Org User", "updateOrgUser": "Update Org User",
"createOrgUser": "Create Org User", "createOrgUser": "Create Org User",
"actionUpdateOrg": "Update Organization", "actionUpdateOrg": "Update Organization",
"actionRemoveInvitation": "Remove Invitation",
"actionUpdateUser": "Update User", "actionUpdateUser": "Update User",
"actionGetUser": "Get User", "actionGetUser": "Get User",
"actionGetOrgUser": "Get Organization User", "actionGetOrgUser": "Get Organization User",
@@ -2067,6 +2068,8 @@
"timestamp": "Timestamp", "timestamp": "Timestamp",
"accessLogs": "Access Logs", "accessLogs": "Access Logs",
"exportCsv": "Export CSV", "exportCsv": "Export CSV",
"exportError": "Unknown error when exporting CSV",
"exportCsvTooltip": "Within Time Range",
"actorId": "Actor ID", "actorId": "Actor ID",
"allowedByRule": "Allowed by Rule", "allowedByRule": "Allowed by Rule",
"allowedNoAuth": "Allowed No Auth", "allowedNoAuth": "Allowed No Auth",
@@ -2270,5 +2273,15 @@
"remoteExitNodeRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the remote exit node. The remote exit node will need to be restarted with the new credentials.", "remoteExitNodeRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the remote exit node. The remote exit node will need to be restarted with the new credentials.",
"remoteExitNodeRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this remote exit node?", "remoteExitNodeRegenerateCredentialsConfirmation": "Are you sure you want to regenerate the credentials for this remote exit node?",
"remoteExitNodeRegenerateCredentialsWarning": "This will regenerate the credentials. The remote exit node will stay connected until you manually restart it and use the new credentials.", "remoteExitNodeRegenerateCredentialsWarning": "This will regenerate the credentials. The remote exit node will stay connected until you manually restart it and use the new credentials.",
"agent": "Agent" "agent": "Agent",
"personalUseOnly": "Personal Use Only",
"loginPageLicenseWatermark": "This instance is licensed for personal use only.",
"instanceIsUnlicensed": "This instance is unlicensed.",
"portRestrictions": "Port Restrictions",
"allPorts": "All",
"custom": "Custom",
"allPortsAllowed": "All Ports Allowed",
"allPortsBlocked": "All Ports Blocked",
"tcpPortsDescription": "Specify which TCP ports are allowed for this resource. Use '*' for all ports, leave empty to block all, or enter a comma-separated list of ports and ranges (e.g., 80,443,8000-9000).",
"udpPortsDescription": "Specify which UDP ports are allowed for this resource. Use '*' for all ports, leave empty to block all, or enter a comma-separated list of ports and ranges (e.g., 53,123,500-600)."
} }

211
package-lock.json generated
View File

@@ -10,7 +10,7 @@
"license": "SEE LICENSE IN LICENSE AND README.md", "license": "SEE LICENSE IN LICENSE AND README.md",
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "8.2.0", "@asteasolutions/zod-to-openapi": "8.2.0",
"@aws-sdk/client-s3": "3.947.0", "@aws-sdk/client-s3": "3.948.0",
"@faker-js/faker": "10.1.0", "@faker-js/faker": "10.1.0",
"@headlessui/react": "2.2.9", "@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2", "@hookform/resolvers": "5.2.2",
@@ -72,32 +72,32 @@
"jmespath": "0.16.0", "jmespath": "0.16.0",
"js-yaml": "4.1.1", "js-yaml": "4.1.1",
"jsonwebtoken": "9.0.3", "jsonwebtoken": "9.0.3",
"lucide-react": "0.556.0", "lucide-react": "0.559.0",
"maxmind": "5.0.1", "maxmind": "5.0.1",
"moment": "2.30.1", "moment": "2.30.1",
"next": "15.5.7", "next": "15.5.9",
"next-intl": "4.5.8", "next-intl": "4.5.8",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"nextjs-toploader": "3.9.17", "nextjs-toploader": "3.9.17",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"nodemailer": "7.0.11", "nodemailer": "7.0.11",
"npm": "11.6.4", "npm": "11.7.0",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"oslo": "1.2.1", "oslo": "1.2.1",
"pg": "8.16.3", "pg": "8.16.3",
"posthog-node": "5.17.2", "posthog-node": "5.17.2",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
"react": "19.2.1", "react": "19.2.3",
"react-day-picker": "9.12.0", "react-day-picker": "9.12.0",
"react-dom": "19.2.1", "react-dom": "19.2.3",
"react-easy-sort": "1.8.0", "react-easy-sort": "1.8.0",
"react-hook-form": "7.68.0", "react-hook-form": "7.68.0",
"react-icons": "5.5.0", "react-icons": "5.5.0",
"rebuild": "0.1.2", "rebuild": "0.1.2",
"recharts": "2.15.4", "recharts": "2.15.4",
"reodotdev": "1.0.0", "reodotdev": "1.0.0",
"resend": "6.5.2", "resend": "6.6.0",
"semver": "7.7.3", "semver": "7.7.3",
"stripe": "20.0.0", "stripe": "20.0.0",
"swagger-ui-express": "5.0.1", "swagger-ui-express": "5.0.1",
@@ -133,7 +133,7 @@
"@types/node": "24.10.2", "@types/node": "24.10.2",
"@types/nodemailer": "7.0.4", "@types/nodemailer": "7.0.4",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@types/pg": "8.15.6", "@types/pg": "8.16.0",
"@types/react": "19.2.7", "@types/react": "19.2.7",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
@@ -147,7 +147,7 @@
"esbuild-node-externals": "1.20.1", "esbuild-node-externals": "1.20.1",
"postcss": "8.5.6", "postcss": "8.5.6",
"prettier": "3.7.4", "prettier": "3.7.4",
"react-email": "5.0.6", "react-email": "5.0.7",
"tailwindcss": "4.1.17", "tailwindcss": "4.1.17",
"tsc-alias": "1.8.16", "tsc-alias": "1.8.16",
"tsx": "4.21.0", "tsx": "4.21.0",
@@ -396,23 +396,23 @@
} }
}, },
"node_modules/@aws-sdk/client-s3": { "node_modules/@aws-sdk/client-s3": {
"version": "3.947.0", "version": "3.948.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.947.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.948.0.tgz",
"integrity": "sha512-ICgnI8D3ccIX9alsLksPFY2bX5CAIbyB+q19sXJgPhzCJ5kWeQ6LQ5xBmRVT5kccmsVGbbJdhnLXHyiN5LZsWg==", "integrity": "sha512-uvEjds8aYA9SzhBS8RKDtsDUhNV9VhqKiHTcmvhM7gJO92q0WTn8/QeFTdNyLc6RxpiDyz+uBxS7PcdNiZzqfA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha1-browser": "5.2.0",
"@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0",
"@aws-crypto/sha256-js": "5.2.0", "@aws-crypto/sha256-js": "5.2.0",
"@aws-sdk/core": "3.947.0", "@aws-sdk/core": "3.947.0",
"@aws-sdk/credential-provider-node": "3.947.0", "@aws-sdk/credential-provider-node": "3.948.0",
"@aws-sdk/middleware-bucket-endpoint": "3.936.0", "@aws-sdk/middleware-bucket-endpoint": "3.936.0",
"@aws-sdk/middleware-expect-continue": "3.936.0", "@aws-sdk/middleware-expect-continue": "3.936.0",
"@aws-sdk/middleware-flexible-checksums": "3.947.0", "@aws-sdk/middleware-flexible-checksums": "3.947.0",
"@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0",
"@aws-sdk/middleware-location-constraint": "3.936.0", "@aws-sdk/middleware-location-constraint": "3.936.0",
"@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0",
"@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.948.0",
"@aws-sdk/middleware-sdk-s3": "3.947.0", "@aws-sdk/middleware-sdk-s3": "3.947.0",
"@aws-sdk/middleware-ssec": "3.936.0", "@aws-sdk/middleware-ssec": "3.936.0",
"@aws-sdk/middleware-user-agent": "3.947.0", "@aws-sdk/middleware-user-agent": "3.947.0",
@@ -462,9 +462,9 @@
} }
}, },
"node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/client-sso": { "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/client-sso": {
"version": "3.947.0", "version": "3.948.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.947.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.948.0.tgz",
"integrity": "sha512-sDwcO8SP290WSErY1S8pz8hTafeghKmmWjNVks86jDK30wx62CfazOTeU70IpWgrUBEygyXk/zPogHsUMbW2Rg==", "integrity": "sha512-iWjchXy8bIAVBUsKnbfKYXRwhLgRg3EqCQ5FTr3JbR+QR75rZm4ZOYXlvHGztVTmtAZ+PQVA1Y4zO7v7N87C0A==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0",
@@ -472,7 +472,7 @@
"@aws-sdk/core": "3.947.0", "@aws-sdk/core": "3.947.0",
"@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0",
"@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0",
"@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.948.0",
"@aws-sdk/middleware-user-agent": "3.947.0", "@aws-sdk/middleware-user-agent": "3.947.0",
"@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/region-config-resolver": "3.936.0",
"@aws-sdk/types": "3.936.0", "@aws-sdk/types": "3.936.0",
@@ -572,19 +572,19 @@
} }
}, },
"node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-ini": { "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.947.0", "version": "3.948.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.947.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.948.0.tgz",
"integrity": "sha512-A2ZUgJUJZERjSzvCi2NR/hBVbVkTXPD0SdKcR/aITb30XwF+n3T963b+pJl90qhOspoy7h0IVYNR7u5Nr9tJdQ==", "integrity": "sha512-Cl//Qh88e8HBL7yYkJNpF5eq76IO6rq8GsatKcfVBm7RFVxCqYEPSSBtkHdbtNwQdRQqAMXc6E/lEB/CZUDxnA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "3.947.0", "@aws-sdk/core": "3.947.0",
"@aws-sdk/credential-provider-env": "3.947.0", "@aws-sdk/credential-provider-env": "3.947.0",
"@aws-sdk/credential-provider-http": "3.947.0", "@aws-sdk/credential-provider-http": "3.947.0",
"@aws-sdk/credential-provider-login": "3.947.0", "@aws-sdk/credential-provider-login": "3.948.0",
"@aws-sdk/credential-provider-process": "3.947.0", "@aws-sdk/credential-provider-process": "3.947.0",
"@aws-sdk/credential-provider-sso": "3.947.0", "@aws-sdk/credential-provider-sso": "3.948.0",
"@aws-sdk/credential-provider-web-identity": "3.947.0", "@aws-sdk/credential-provider-web-identity": "3.948.0",
"@aws-sdk/nested-clients": "3.947.0", "@aws-sdk/nested-clients": "3.948.0",
"@aws-sdk/types": "3.936.0", "@aws-sdk/types": "3.936.0",
"@smithy/credential-provider-imds": "^4.2.5", "@smithy/credential-provider-imds": "^4.2.5",
"@smithy/property-provider": "^4.2.5", "@smithy/property-provider": "^4.2.5",
@@ -597,13 +597,13 @@
} }
}, },
"node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-login": { "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-login": {
"version": "3.947.0", "version": "3.948.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.947.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.948.0.tgz",
"integrity": "sha512-u7M3hazcB7aJiVwosNdJRbIJDzbwQ861NTtl6S0HmvWpixaVb7iyhJZWg8/plyUznboZGBm7JVEdxtxv3u0bTA==", "integrity": "sha512-gcKO2b6eeTuZGp3Vvgr/9OxajMrD3W+FZ2FCyJox363ZgMoYJsyNid1vuZrEuAGkx0jvveLXfwiVS0UXyPkgtw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "3.947.0", "@aws-sdk/core": "3.947.0",
"@aws-sdk/nested-clients": "3.947.0", "@aws-sdk/nested-clients": "3.948.0",
"@aws-sdk/types": "3.936.0", "@aws-sdk/types": "3.936.0",
"@smithy/property-provider": "^4.2.5", "@smithy/property-provider": "^4.2.5",
"@smithy/protocol-http": "^5.3.5", "@smithy/protocol-http": "^5.3.5",
@@ -616,17 +616,17 @@
} }
}, },
"node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-node": { "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-node": {
"version": "3.947.0", "version": "3.948.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.947.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.948.0.tgz",
"integrity": "sha512-S0Zqebr71KyrT6J4uYPhwV65g4V5uDPHnd7dt2W34FcyPu+hVC7Hx4MFmsPyVLeT5cMCkkZvmY3kAoEzgUPJJg==", "integrity": "sha512-ep5vRLnrRdcsP17Ef31sNN4g8Nqk/4JBydcUJuFRbGuyQtrZZrVT81UeH2xhz6d0BK6ejafDB9+ZpBjXuWT5/Q==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/credential-provider-env": "3.947.0", "@aws-sdk/credential-provider-env": "3.947.0",
"@aws-sdk/credential-provider-http": "3.947.0", "@aws-sdk/credential-provider-http": "3.947.0",
"@aws-sdk/credential-provider-ini": "3.947.0", "@aws-sdk/credential-provider-ini": "3.948.0",
"@aws-sdk/credential-provider-process": "3.947.0", "@aws-sdk/credential-provider-process": "3.947.0",
"@aws-sdk/credential-provider-sso": "3.947.0", "@aws-sdk/credential-provider-sso": "3.948.0",
"@aws-sdk/credential-provider-web-identity": "3.947.0", "@aws-sdk/credential-provider-web-identity": "3.948.0",
"@aws-sdk/types": "3.936.0", "@aws-sdk/types": "3.936.0",
"@smithy/credential-provider-imds": "^4.2.5", "@smithy/credential-provider-imds": "^4.2.5",
"@smithy/property-provider": "^4.2.5", "@smithy/property-provider": "^4.2.5",
@@ -656,14 +656,14 @@
} }
}, },
"node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-sso": { "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.947.0", "version": "3.948.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.947.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.948.0.tgz",
"integrity": "sha512-NktnVHTGaUMaozxycYrepvb3yfFquHTQ53lt6hBEVjYBzK3C4tVz0siUpr+5RMGLSiZ5bLBp2UjJPgwx4i4waQ==", "integrity": "sha512-gqLhX1L+zb/ZDnnYbILQqJ46j735StfWV5PbDjxRzBKS7GzsiYoaf6MyHseEopmWrez5zl5l6aWzig7UpzSeQQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-sso": "3.947.0", "@aws-sdk/client-sso": "3.948.0",
"@aws-sdk/core": "3.947.0", "@aws-sdk/core": "3.947.0",
"@aws-sdk/token-providers": "3.947.0", "@aws-sdk/token-providers": "3.948.0",
"@aws-sdk/types": "3.936.0", "@aws-sdk/types": "3.936.0",
"@smithy/property-provider": "^4.2.5", "@smithy/property-provider": "^4.2.5",
"@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/shared-ini-file-loader": "^4.4.0",
@@ -675,13 +675,13 @@
} }
}, },
"node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-web-identity": { "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.947.0", "version": "3.948.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.947.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.948.0.tgz",
"integrity": "sha512-gokm/e/YHiHLrZgLq4j8tNAn8RJDPbIcglFRKgy08q8DmAqHQ8MXAKW3eS0QjAuRXU9mcMmUo1NrX6FRNBCCPw==", "integrity": "sha512-MvYQlXVoJyfF3/SmnNzOVEtANRAiJIObEUYYyjTqKZTmcRIVVky0tPuG26XnB8LmTYgtESwJIZJj/Eyyc9WURQ==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "3.947.0", "@aws-sdk/core": "3.947.0",
"@aws-sdk/nested-clients": "3.947.0", "@aws-sdk/nested-clients": "3.948.0",
"@aws-sdk/types": "3.936.0", "@aws-sdk/types": "3.936.0",
"@smithy/property-provider": "^4.2.5", "@smithy/property-provider": "^4.2.5",
"@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/shared-ini-file-loader": "^4.4.0",
@@ -692,6 +692,22 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-recursion-detection": {
"version": "3.948.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.948.0.tgz",
"integrity": "sha512-Qa8Zj+EAqA0VlAVvxpRnpBpIWJI9KUwaioY1vkeNVwXPlNaz9y9zCKVM9iU9OZ5HXpoUg6TnhATAHXHAE8+QsQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "3.936.0",
"@aws/lambda-invoke-store": "^0.2.2",
"@smithy/protocol-http": "^5.3.5",
"@smithy/types": "^4.9.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-sdk-s3": { "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.947.0", "version": "3.947.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.947.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.947.0.tgz",
@@ -736,9 +752,9 @@
} }
}, },
"node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/nested-clients": { "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/nested-clients": {
"version": "3.947.0", "version": "3.948.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.947.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.948.0.tgz",
"integrity": "sha512-DjRJEYNnHUTu9kGPPQDTSXquwSEd6myKR4ssI4FaYLFhdT3ldWpj73yYt807H3tdmhS7vPmdVqchSJnjurUQAw==", "integrity": "sha512-zcbJfBsB6h254o3NuoEkf0+UY1GpE9ioiQdENWv7odo69s8iaGBEQ4BDpsIMqcuiiUXw1uKIVNxCB1gUGYz8lw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0",
@@ -746,7 +762,7 @@
"@aws-sdk/core": "3.947.0", "@aws-sdk/core": "3.947.0",
"@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-host-header": "3.936.0",
"@aws-sdk/middleware-logger": "3.936.0", "@aws-sdk/middleware-logger": "3.936.0",
"@aws-sdk/middleware-recursion-detection": "3.936.0", "@aws-sdk/middleware-recursion-detection": "3.948.0",
"@aws-sdk/middleware-user-agent": "3.947.0", "@aws-sdk/middleware-user-agent": "3.947.0",
"@aws-sdk/region-config-resolver": "3.936.0", "@aws-sdk/region-config-resolver": "3.936.0",
"@aws-sdk/types": "3.936.0", "@aws-sdk/types": "3.936.0",
@@ -802,13 +818,13 @@
} }
}, },
"node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/token-providers": { "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/token-providers": {
"version": "3.947.0", "version": "3.948.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.947.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.948.0.tgz",
"integrity": "sha512-X/DyB8GuK44rsE89Tn5+s542B3PhGbXQSgV8lvqHDzvicwCt0tWny6790st6CPETrVVV2K3oJMfG5U3/jAmaZA==", "integrity": "sha512-V487/kM4Teq5dcr1t5K6eoUKuqlGr9FRWL3MIMukMERJXHZvio6kox60FZ/YtciRHRI75u14YUqm2Dzddcu3+A==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/core": "3.947.0", "@aws-sdk/core": "3.947.0",
"@aws-sdk/nested-clients": "3.947.0", "@aws-sdk/nested-clients": "3.948.0",
"@aws-sdk/types": "3.936.0", "@aws-sdk/types": "3.936.0",
"@smithy/property-provider": "^4.2.5", "@smithy/property-provider": "^4.2.5",
"@smithy/shared-ini-file-loader": "^4.4.0", "@smithy/shared-ini-file-loader": "^4.4.0",
@@ -1264,6 +1280,7 @@
"version": "3.936.0", "version": "3.936.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.936.0.tgz", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.936.0.tgz",
"integrity": "sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA==", "integrity": "sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@aws-sdk/types": "3.936.0", "@aws-sdk/types": "3.936.0",
@@ -3818,9 +3835,9 @@
} }
}, },
"node_modules/@next/env": { "node_modules/@next/env": {
"version": "15.5.7", "version": "15.5.9",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz",
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==", "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@next/eslint-plugin-next": { "node_modules/@next/eslint-plugin-next": {
@@ -9360,9 +9377,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/pg": { "node_modules/@types/pg": {
"version": "8.15.6", "version": "8.16.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz",
"integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
@@ -15915,9 +15932,9 @@
} }
}, },
"node_modules/lucide-react": { "node_modules/lucide-react": {
"version": "0.556.0", "version": "0.559.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.556.0.tgz", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.559.0.tgz",
"integrity": "sha512-iOb8dRk7kLaYBZhR2VlV1CeJGxChBgUthpSP8wom9jfj79qovgG6qcSdiy6vkoREKPnbUYzJsCn4o4PtG3Iy+A==", "integrity": "sha512-3ymrkBPXWk3U2bwUDg6TdA6hP5iGDMgPEAMLhchEgTQmA+g0Zk24tOtKtXMx35w1PizTmsBC3RhP88QYm+7mHQ==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
@@ -16274,13 +16291,13 @@
} }
}, },
"node_modules/next": { "node_modules/next": {
"version": "15.5.7", "version": "15.5.9",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz",
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@next/env": "15.5.7", "@next/env": "15.5.9",
"@swc/helpers": "0.5.15", "@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579", "caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31", "postcss": "8.4.31",
@@ -16515,9 +16532,9 @@
} }
}, },
"node_modules/npm": { "node_modules/npm": {
"version": "11.6.4", "version": "11.7.0",
"resolved": "https://registry.npmjs.org/npm/-/npm-11.6.4.tgz", "resolved": "https://registry.npmjs.org/npm/-/npm-11.7.0.tgz",
"integrity": "sha512-ERjKtGoFpQrua/9bG0+h3xiv/4nVdGViCjUYA1AmlV24fFvfnSB7B7dIfZnySQ1FDLd0ZVrWPsLLp78dCtJdRQ==", "integrity": "sha512-wiCZpv/41bIobCoJ31NStIWKfAxxYyD1iYnWCtiyns8s5v3+l8y0HCP/sScuH6B5+GhIfda4HQKiqeGZwJWhFw==",
"bundleDependencies": [ "bundleDependencies": [
"@isaacs/string-locale-compare", "@isaacs/string-locale-compare",
"@npmcli/arborist", "@npmcli/arborist",
@@ -16596,8 +16613,8 @@
], ],
"dependencies": { "dependencies": {
"@isaacs/string-locale-compare": "^1.1.0", "@isaacs/string-locale-compare": "^1.1.0",
"@npmcli/arborist": "^9.1.8", "@npmcli/arborist": "^9.1.9",
"@npmcli/config": "^10.4.4", "@npmcli/config": "^10.4.5",
"@npmcli/fs": "^5.0.0", "@npmcli/fs": "^5.0.0",
"@npmcli/map-workspaces": "^5.0.3", "@npmcli/map-workspaces": "^5.0.3",
"@npmcli/metavuln-calculator": "^9.0.3", "@npmcli/metavuln-calculator": "^9.0.3",
@@ -16622,11 +16639,11 @@
"is-cidr": "^6.0.1", "is-cidr": "^6.0.1",
"json-parse-even-better-errors": "^5.0.0", "json-parse-even-better-errors": "^5.0.0",
"libnpmaccess": "^10.0.3", "libnpmaccess": "^10.0.3",
"libnpmdiff": "^8.0.11", "libnpmdiff": "^8.0.12",
"libnpmexec": "^10.1.10", "libnpmexec": "^10.1.11",
"libnpmfund": "^7.0.11", "libnpmfund": "^7.0.12",
"libnpmorg": "^8.0.1", "libnpmorg": "^8.0.1",
"libnpmpack": "^9.0.11", "libnpmpack": "^9.0.12",
"libnpmpublish": "^11.1.3", "libnpmpublish": "^11.1.3",
"libnpmsearch": "^9.0.1", "libnpmsearch": "^9.0.1",
"libnpmteam": "^8.0.2", "libnpmteam": "^8.0.2",
@@ -16734,7 +16751,7 @@
} }
}, },
"node_modules/npm/node_modules/@npmcli/arborist": { "node_modules/npm/node_modules/@npmcli/arborist": {
"version": "9.1.8", "version": "9.1.9",
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@@ -16780,7 +16797,7 @@
} }
}, },
"node_modules/npm/node_modules/@npmcli/config": { "node_modules/npm/node_modules/@npmcli/config": {
"version": "10.4.4", "version": "10.4.5",
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
@@ -17518,11 +17535,11 @@
} }
}, },
"node_modules/npm/node_modules/libnpmdiff": { "node_modules/npm/node_modules/libnpmdiff": {
"version": "8.0.11", "version": "8.0.12",
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@npmcli/arborist": "^9.1.8", "@npmcli/arborist": "^9.1.9",
"@npmcli/installed-package-contents": "^4.0.0", "@npmcli/installed-package-contents": "^4.0.0",
"binary-extensions": "^3.0.0", "binary-extensions": "^3.0.0",
"diff": "^8.0.2", "diff": "^8.0.2",
@@ -17536,11 +17553,11 @@
} }
}, },
"node_modules/npm/node_modules/libnpmexec": { "node_modules/npm/node_modules/libnpmexec": {
"version": "10.1.10", "version": "10.1.11",
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@npmcli/arborist": "^9.1.8", "@npmcli/arborist": "^9.1.9",
"@npmcli/package-json": "^7.0.0", "@npmcli/package-json": "^7.0.0",
"@npmcli/run-script": "^10.0.0", "@npmcli/run-script": "^10.0.0",
"ci-info": "^4.0.0", "ci-info": "^4.0.0",
@@ -17558,11 +17575,11 @@
} }
}, },
"node_modules/npm/node_modules/libnpmfund": { "node_modules/npm/node_modules/libnpmfund": {
"version": "7.0.11", "version": "7.0.12",
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@npmcli/arborist": "^9.1.8" "@npmcli/arborist": "^9.1.9"
}, },
"engines": { "engines": {
"node": "^20.17.0 || >=22.9.0" "node": "^20.17.0 || >=22.9.0"
@@ -17581,11 +17598,11 @@
} }
}, },
"node_modules/npm/node_modules/libnpmpack": { "node_modules/npm/node_modules/libnpmpack": {
"version": "9.0.11", "version": "9.0.12",
"inBundle": true, "inBundle": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@npmcli/arborist": "^9.1.8", "@npmcli/arborist": "^9.1.9",
"@npmcli/run-script": "^10.0.0", "@npmcli/run-script": "^10.0.0",
"npm-package-arg": "^13.0.0", "npm-package-arg": "^13.0.0",
"pacote": "^21.0.2" "pacote": "^21.0.2"
@@ -19720,9 +19737,9 @@
} }
}, },
"node_modules/react": { "node_modules/react": {
"version": "19.2.1", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"engines": { "engines": {
@@ -19751,16 +19768,16 @@
} }
}, },
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.2.1", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^19.2.1" "react": "^19.2.3"
} }
}, },
"node_modules/react-easy-sort": { "node_modules/react-easy-sort": {
@@ -19780,9 +19797,9 @@
} }
}, },
"node_modules/react-email": { "node_modules/react-email": {
"version": "5.0.6", "version": "5.0.7",
"resolved": "https://registry.npmjs.org/react-email/-/react-email-5.0.6.tgz", "resolved": "https://registry.npmjs.org/react-email/-/react-email-5.0.7.tgz",
"integrity": "sha512-DEGzWpEiC3CquPEaaEJuipNT3WZ9mK58rbkpOe4Slbgyf60PLa1wONnt5a3afbBBRbNdW2aYhIvVI41yS6UIRA==", "integrity": "sha512-JsWzxl3O82Gw9HRRNJm8VjQLB8c7R5TGbP89Ffj+/Qdb2H2N4J0XRXkhqiucMvmucuqNqe9mNndZkh3jh638xA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -20871,9 +20888,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/resend": { "node_modules/resend": {
"version": "6.5.2", "version": "6.6.0",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.5.2.tgz", "resolved": "https://registry.npmjs.org/resend/-/resend-6.6.0.tgz",
"integrity": "sha512-Yl83UvS8sYsjgmF8dVbNPzlfpmb3DkLUk3VwsAbkaEFo9UMswpNuPGryHBXGk+Ta4uYMv5HmjVk3j9jmNkcEDg==", "integrity": "sha512-d1WoOqSxj5x76JtQMrieNAG1kZkh4NU4f+Je1yq4++JsDpLddhEwnJlNfvkCzvUuZy9ZquWmMMAm2mENd2JvRw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"svix": "1.76.1" "svix": "1.76.1"

View File

@@ -34,7 +34,7 @@
}, },
"dependencies": { "dependencies": {
"@asteasolutions/zod-to-openapi": "8.2.0", "@asteasolutions/zod-to-openapi": "8.2.0",
"@aws-sdk/client-s3": "3.947.0", "@aws-sdk/client-s3": "3.948.0",
"@faker-js/faker": "10.1.0", "@faker-js/faker": "10.1.0",
"@headlessui/react": "2.2.9", "@headlessui/react": "2.2.9",
"@hookform/resolvers": "5.2.2", "@hookform/resolvers": "5.2.2",
@@ -96,32 +96,32 @@
"jmespath": "0.16.0", "jmespath": "0.16.0",
"js-yaml": "4.1.1", "js-yaml": "4.1.1",
"jsonwebtoken": "9.0.3", "jsonwebtoken": "9.0.3",
"lucide-react": "0.556.0", "lucide-react": "0.559.0",
"maxmind": "5.0.1", "maxmind": "5.0.1",
"moment": "2.30.1", "moment": "2.30.1",
"next": "15.5.7", "next": "15.5.9",
"next-intl": "4.5.8", "next-intl": "4.5.8",
"next-themes": "0.4.6", "next-themes": "0.4.6",
"nextjs-toploader": "3.9.17", "nextjs-toploader": "3.9.17",
"node-cache": "5.1.2", "node-cache": "5.1.2",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
"nodemailer": "7.0.11", "nodemailer": "7.0.11",
"npm": "11.6.4", "npm": "11.7.0",
"nprogress": "0.2.0", "nprogress": "0.2.0",
"oslo": "1.2.1", "oslo": "1.2.1",
"pg": "8.16.3", "pg": "8.16.3",
"posthog-node": "5.17.2", "posthog-node": "5.17.2",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
"react": "19.2.1", "react": "19.2.3",
"react-day-picker": "9.12.0", "react-day-picker": "9.12.0",
"react-dom": "19.2.1", "react-dom": "19.2.3",
"react-easy-sort": "1.8.0", "react-easy-sort": "1.8.0",
"react-hook-form": "7.68.0", "react-hook-form": "7.68.0",
"react-icons": "5.5.0", "react-icons": "5.5.0",
"rebuild": "0.1.2", "rebuild": "0.1.2",
"recharts": "2.15.4", "recharts": "2.15.4",
"reodotdev": "1.0.0", "reodotdev": "1.0.0",
"resend": "6.5.2", "resend": "6.6.0",
"semver": "7.7.3", "semver": "7.7.3",
"stripe": "20.0.0", "stripe": "20.0.0",
"swagger-ui-express": "5.0.1", "swagger-ui-express": "5.0.1",
@@ -156,7 +156,7 @@
"@types/node": "24.10.2", "@types/node": "24.10.2",
"@types/nodemailer": "7.0.4", "@types/nodemailer": "7.0.4",
"@types/nprogress": "0.2.3", "@types/nprogress": "0.2.3",
"@types/pg": "8.15.6", "@types/pg": "8.16.0",
"@types/react": "19.2.7", "@types/react": "19.2.7",
"@types/react-dom": "19.2.3", "@types/react-dom": "19.2.3",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
@@ -171,7 +171,7 @@
"esbuild-node-externals": "1.20.1", "esbuild-node-externals": "1.20.1",
"postcss": "8.5.6", "postcss": "8.5.6",
"prettier": "3.7.4", "prettier": "3.7.4",
"react-email": "5.0.6", "react-email": "5.0.7",
"tailwindcss": "4.1.17", "tailwindcss": "4.1.17",
"tsc-alias": "1.8.16", "tsc-alias": "1.8.16",
"tsx": "4.21.0", "tsx": "4.21.0",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 713 KiB

After

Width:  |  Height:  |  Size: 493 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 636 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 713 KiB

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 421 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 713 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 KiB

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

View File

@@ -6,28 +6,28 @@ import { withReplicas } from "drizzle-orm/pg-core";
function createDb() { function createDb() {
const config = readConfigFile(); const config = readConfigFile();
if (!config.postgres) { // check the environment variables for postgres config first before the config file
// check the environment variables for postgres config if (process.env.POSTGRES_CONNECTION_STRING) {
if (process.env.POSTGRES_CONNECTION_STRING) { config.postgres = {
config.postgres = { connection_string: process.env.POSTGRES_CONNECTION_STRING
connection_string: process.env.POSTGRES_CONNECTION_STRING };
}; if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) {
if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) { const replicas =
const replicas = process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(",").map(
process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split( (conn) => ({
","
).map((conn) => ({
connection_string: conn.trim() connection_string: conn.trim()
})); })
config.postgres.replicas = replicas; );
} config.postgres.replicas = replicas;
} else {
throw new Error(
"Postgres configuration is missing in the configuration file."
);
} }
} }
if (!config.postgres) {
throw new Error(
"Postgres configuration is missing in the configuration file."
);
}
const connectionString = config.postgres?.connection_string; const connectionString = config.postgres?.connection_string;
const replicaConnections = config.postgres?.replicas || []; const replicaConnections = config.postgres?.replicas || [];

View File

@@ -213,7 +213,10 @@ export const siteResources = pgTable("siteResources", {
destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode
enabled: boolean("enabled").notNull().default(true), enabled: boolean("enabled").notNull().default(true),
alias: varchar("alias"), alias: varchar("alias"),
aliasAddress: varchar("aliasAddress") aliasAddress: varchar("aliasAddress"),
tcpPortRangeString: varchar("tcpPortRangeString"),
udpPortRangeString: varchar("udpPortRangeString"),
disableIcmp: boolean("disableIcmp").notNull().default(false)
}); });
export const clientSiteResources = pgTable("clientSiteResources", { export const clientSiteResources = pgTable("clientSiteResources", {

View File

@@ -234,7 +234,10 @@ export const siteResources = sqliteTable("siteResources", {
destination: text("destination").notNull(), // ip, cidr, hostname destination: text("destination").notNull(), // ip, cidr, hostname
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
alias: text("alias"), alias: text("alias"),
aliasAddress: text("aliasAddress") aliasAddress: text("aliasAddress"),
tcpPortRangeString: text("tcpPortRangeString"),
udpPortRangeString: text("udpPortRangeString"),
disableIcmp: integer("disableIcmp", { mode: "boolean" })
}); });
export const clientSiteResources = sqliteTable("clientSiteResources", { export const clientSiteResources = sqliteTable("clientSiteResources", {

View File

@@ -10,6 +10,7 @@ export async function sendEmail(
from: string | undefined; from: string | undefined;
to: string | undefined; to: string | undefined;
subject: string; subject: string;
replyTo?: string;
} }
) { ) {
if (!emailClient) { if (!emailClient) {
@@ -32,6 +33,7 @@ export async function sendEmail(
address: opts.from address: opts.from
}, },
to: opts.to, to: opts.to,
replyTo: opts.replyTo,
subject: opts.subject, subject: opts.subject,
html: emailHtml html: emailHtml
}); });

View File

@@ -2,7 +2,7 @@ import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
// This is a placeholder value replaced by the build process // This is a placeholder value replaced by the build process
export const APP_VERSION = "1.13.0-rc.0"; export const APP_VERSION = "1.13.1";
export const __FILENAME = fileURLToPath(import.meta.url); export const __FILENAME = fileURLToPath(import.meta.url);
export const __DIRNAME = path.dirname(__FILENAME); export const __DIRNAME = path.dirname(__FILENAME);

View File

@@ -1,10 +1,4 @@
import { import { db, SiteResource, siteResources, Transaction } from "@server/db";
clientSitesAssociationsCache,
db,
SiteResource,
siteResources,
Transaction
} from "@server/db";
import { clients, orgs, sites } from "@server/db"; import { clients, orgs, sites } from "@server/db";
import { and, eq, isNotNull } from "drizzle-orm"; import { and, eq, isNotNull } from "drizzle-orm";
import config from "@server/lib/config"; import config from "@server/lib/config";
@@ -124,7 +118,9 @@ function bigIntToIp(num: bigint, version: IPVersion): string {
* @param endpoint The endpoint string to parse (e.g., "192.168.1.1:8080" or "[::1]:8080" or "2607:fea8::1:8080") * @param endpoint The endpoint string to parse (e.g., "192.168.1.1:8080" or "[::1]:8080" or "2607:fea8::1:8080")
* @returns An object with ip and port, or null if parsing fails * @returns An object with ip and port, or null if parsing fails
*/ */
export function parseEndpoint(endpoint: string): { ip: string; port: number } | null { export function parseEndpoint(
endpoint: string
): { ip: string; port: number } | null {
if (!endpoint) return null; if (!endpoint) return null;
// Check for bracketed IPv6 format: [ip]:port // Check for bracketed IPv6 format: [ip]:port
@@ -430,7 +426,12 @@ export function generateRemoteSubnets(
): string[] { ): string[] {
const remoteSubnets = allSiteResources const remoteSubnets = allSiteResources
.filter((sr) => { .filter((sr) => {
if (sr.mode === "cidr") return true; if (sr.mode === "cidr") {
// check if its a valid CIDR using zod
const cidrSchema = z.union([z.cidrv4(), z.cidrv6()]);
const parseResult = cidrSchema.safeParse(sr.destination);
return parseResult.success;
}
if (sr.mode === "host") { if (sr.mode === "host") {
// check if its a valid IP using zod // check if its a valid IP using zod
const ipSchema = z.union([z.ipv4(), z.ipv6()]); const ipSchema = z.union([z.ipv4(), z.ipv6()]);
@@ -454,22 +455,23 @@ export function generateRemoteSubnets(
export type Alias = { alias: string | null; aliasAddress: string | null }; export type Alias = { alias: string | null; aliasAddress: string | null };
export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] {
let aliasConfigs = allSiteResources return allSiteResources
.filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host") .filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host")
.map((sr) => ({ .map((sr) => ({
alias: sr.alias, alias: sr.alias,
aliasAddress: sr.aliasAddress aliasAddress: sr.aliasAddress
})); }));
return aliasConfigs;
} }
export type SubnetProxyTarget = { export type SubnetProxyTarget = {
sourcePrefix: string; // must be a cidr sourcePrefix: string; // must be a cidr
destPrefix: string; // must be a cidr destPrefix: string; // must be a cidr
disableIcmp?: boolean;
rewriteTo?: string; // must be a cidr rewriteTo?: string; // must be a cidr
portRange?: { portRange?: {
min: number; min: number;
max: number; max: number;
protocol: "tcp" | "udp";
}[]; }[];
}; };
@@ -499,6 +501,11 @@ export function generateSubnetProxyTargets(
} }
const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`; const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`;
const portRange = [
...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"),
...parsePortRangeString(siteResource.udpPortRangeString, "udp")
];
const disableIcmp = siteResource.disableIcmp ?? false;
if (siteResource.mode == "host") { if (siteResource.mode == "host") {
let destination = siteResource.destination; let destination = siteResource.destination;
@@ -509,7 +516,9 @@ export function generateSubnetProxyTargets(
targets.push({ targets.push({
sourcePrefix: clientPrefix, sourcePrefix: clientPrefix,
destPrefix: destination destPrefix: destination,
portRange,
disableIcmp
}); });
} }
@@ -518,13 +527,17 @@ export function generateSubnetProxyTargets(
targets.push({ targets.push({
sourcePrefix: clientPrefix, sourcePrefix: clientPrefix,
destPrefix: `${siteResource.aliasAddress}/32`, destPrefix: `${siteResource.aliasAddress}/32`,
rewriteTo: destination rewriteTo: destination,
portRange,
disableIcmp
}); });
} }
} else if (siteResource.mode == "cidr") { } else if (siteResource.mode == "cidr") {
targets.push({ targets.push({
sourcePrefix: clientPrefix, sourcePrefix: clientPrefix,
destPrefix: siteResource.destination destPrefix: siteResource.destination,
portRange,
disableIcmp
}); });
} }
} }
@@ -536,3 +549,117 @@ export function generateSubnetProxyTargets(
return targets; return targets;
} }
// Custom schema for validating port range strings
// Format: "80,443,8000-9000" or "*" for all ports, or empty string
export const portRangeStringSchema = z
.string()
.optional()
.refine(
(val) => {
if (!val || val.trim() === "" || val.trim() === "*") {
return true;
}
// Split by comma and validate each part
const parts = val.split(",").map((p) => p.trim());
for (const part of parts) {
if (part === "") {
return false; // empty parts not allowed
}
// Check if it's a range (contains dash)
if (part.includes("-")) {
const [start, end] = part.split("-").map((p) => p.trim());
// Both parts must be present
if (!start || !end) {
return false;
}
const startPort = parseInt(start, 10);
const endPort = parseInt(end, 10);
// Must be valid numbers
if (isNaN(startPort) || isNaN(endPort)) {
return false;
}
// Must be valid port range (1-65535)
if (
startPort < 1 ||
startPort > 65535 ||
endPort < 1 ||
endPort > 65535
) {
return false;
}
// Start must be <= end
if (startPort > endPort) {
return false;
}
} else {
// Single port
const port = parseInt(part, 10);
// Must be a valid number
if (isNaN(port)) {
return false;
}
// Must be valid port range (1-65535)
if (port < 1 || port > 65535) {
return false;
}
}
}
return true;
},
{
message:
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535, and ranges must have start <= end.'
}
);
/**
* Parses a port range string into an array of port range objects
* @param portRangeStr - Port range string (e.g., "80,443,8000-9000", "*", or "")
* @param protocol - Protocol to use for all ranges (default: "tcp")
* @returns Array of port range objects with min, max, and protocol fields
*/
export function parsePortRangeString(
portRangeStr: string | undefined | null,
protocol: "tcp" | "udp" = "tcp"
): { min: number; max: number; protocol: "tcp" | "udp" }[] {
// Handle undefined or empty string - insert dummy value with port 0
if (!portRangeStr || portRangeStr.trim() === "") {
return [{ min: 0, max: 0, protocol }];
}
// Handle wildcard - return empty array (all ports allowed)
if (portRangeStr.trim() === "*") {
return [];
}
const result: { min: number; max: number; protocol: "tcp" | "udp" }[] = [];
const parts = portRangeStr.split(",").map((p) => p.trim());
for (const part of parts) {
if (part.includes("-")) {
// Range
const [start, end] = part.split("-").map((p) => p.trim());
const startPort = parseInt(start, 10);
const endPort = parseInt(end, 10);
result.push({ min: startPort, max: endPort, protocol });
} else {
// Single port
const port = parseInt(part, 10);
result.push({ min: port, max: port, protocol });
}
}
return result;
}

View File

@@ -955,28 +955,8 @@ export async function rebuildClientAssociationsFromClient(
/////////// Send messages /////////// /////////// Send messages ///////////
// Get the olm for this client
const [olm] = await trx
.select({ olmId: olms.olmId })
.from(olms)
.where(eq(olms.clientId, client.clientId))
.limit(1);
if (!olm) {
logger.warn(
`Olm not found for client ${client.clientId}, skipping peer updates`
);
return;
}
// Handle messages for sites being added // Handle messages for sites being added
await handleMessagesForClientSites( await handleMessagesForClientSites(client, sitesToAdd, sitesToRemove, trx);
client,
olm.olmId,
sitesToAdd,
sitesToRemove,
trx
);
// Handle subnet proxy target updates for resources // Handle subnet proxy target updates for resources
await handleMessagesForClientResources( await handleMessagesForClientResources(
@@ -996,11 +976,26 @@ async function handleMessagesForClientSites(
userId: string | null; userId: string | null;
orgId: string; orgId: string;
}, },
olmId: string,
sitesToAdd: number[], sitesToAdd: number[],
sitesToRemove: number[], sitesToRemove: number[],
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
// Get the olm for this client
const [olm] = await trx
.select({ olmId: olms.olmId })
.from(olms)
.where(eq(olms.clientId, client.clientId))
.limit(1);
if (!olm) {
logger.warn(
`Olm not found for client ${client.clientId}, skipping peer updates`
);
return;
}
const olmId = olm.olmId;
if (!client.subnet || !client.pubKey) { if (!client.subnet || !client.pubKey) {
logger.warn( logger.warn(
`Client ${client.clientId} missing subnet or pubKey, skipping peer updates` `Client ${client.clientId} missing subnet or pubKey, skipping peer updates`
@@ -1021,9 +1016,9 @@ async function handleMessagesForClientSites(
.leftJoin(newts, eq(sites.siteId, newts.siteId)) .leftJoin(newts, eq(sites.siteId, newts.siteId))
.where(inArray(sites.siteId, allSiteIds)); .where(inArray(sites.siteId, allSiteIds));
let newtJobs: Promise<any>[] = []; const newtJobs: Promise<any>[] = [];
let olmJobs: Promise<any>[] = []; const olmJobs: Promise<any>[] = [];
let exitNodeJobs: Promise<any>[] = []; const exitNodeJobs: Promise<any>[] = [];
for (const siteData of sitesData) { for (const siteData of sitesData) {
const site = siteData.sites; const site = siteData.sites;
@@ -1130,18 +1125,8 @@ async function handleMessagesForClientResources(
resourcesToRemove: number[], resourcesToRemove: number[],
trx: Transaction | typeof db = db trx: Transaction | typeof db = db
): Promise<void> { ): Promise<void> {
// Group resources by site const proxyJobs: Promise<any>[] = [];
const resourcesBySite = new Map<number, SiteResource[]>(); const olmJobs: Promise<any>[] = [];
for (const resource of allNewResources) {
if (!resourcesBySite.has(resource.siteId)) {
resourcesBySite.set(resource.siteId, []);
}
resourcesBySite.get(resource.siteId)!.push(resource);
}
let proxyJobs: Promise<any>[] = [];
let olmJobs: Promise<any>[] = [];
// Handle additions // Handle additions
if (resourcesToAdd.length > 0) { if (resourcesToAdd.length > 0) {

View File

@@ -2,9 +2,9 @@ import { PostHog } from "posthog-node";
import config from "./config"; import config from "./config";
import { getHostMeta } from "./hostMeta"; import { getHostMeta } from "./hostMeta";
import logger from "@server/logger"; import logger from "@server/logger";
import { apiKeys, db, roles } from "@server/db"; import { apiKeys, db, roles, siteResources } from "@server/db";
import { sites, users, orgs, resources, clients, idp } from "@server/db"; import { sites, users, orgs, resources, clients, idp } from "@server/db";
import { eq, count, notInArray, and } from "drizzle-orm"; import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm";
import { APP_VERSION } from "./consts"; import { APP_VERSION } from "./consts";
import crypto from "crypto"; import crypto from "crypto";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
@@ -25,7 +25,7 @@ class TelemetryClient {
return; return;
} }
if (build !== "oss") { if (build === "saas") {
return; return;
} }
@@ -41,14 +41,18 @@ class TelemetryClient {
this.client?.shutdown(); this.client?.shutdown();
}); });
this.sendStartupEvents().catch((err) => { this.sendStartupEvents()
logger.error("Failed to send startup telemetry:", err); .catch((err) => {
}); logger.error("Failed to send startup telemetry:", err);
})
.then(() => {
logger.debug("Successfully sent startup telemetry data");
});
this.startAnalyticsInterval(); this.startAnalyticsInterval();
logger.info( logger.info(
"Pangolin now gathers anonymous usage data to help us better understand how the software is used and guide future improvements and feature development. You can find more details, including instructions for opting out of this anonymous data collection, at: https://docs.pangolin.net/telemetry" "Pangolin gathers anonymous usage data to help us better understand how the software is used and guide future improvements and feature development. You can find more details, including instructions for opting out of this anonymous data collection, at: https://docs.pangolin.net/telemetry"
); );
} else if (!this.enabled) { } else if (!this.enabled) {
logger.info( logger.info(
@@ -60,9 +64,13 @@ class TelemetryClient {
private startAnalyticsInterval() { private startAnalyticsInterval() {
this.intervalId = setInterval( this.intervalId = setInterval(
() => { () => {
this.collectAndSendAnalytics().catch((err) => { this.collectAndSendAnalytics()
logger.error("Failed to collect analytics:", err); .catch((err) => {
}); logger.error("Failed to collect analytics:", err);
})
.then(() => {
logger.debug("Successfully sent analytics data");
});
}, },
48 * 60 * 60 * 1000 48 * 60 * 60 * 1000
); );
@@ -99,9 +107,14 @@ class TelemetryClient {
const [resourcesCount] = await db const [resourcesCount] = await db
.select({ count: count() }) .select({ count: count() })
.from(resources); .from(resources);
const [clientsCount] = await db const [userDevicesCount] = await db
.select({ count: count() }) .select({ count: count() })
.from(clients); .from(clients)
.where(isNotNull(clients.userId));
const [machineClients] = await db
.select({ count: count() })
.from(clients)
.where(isNull(clients.userId));
const [idpCount] = await db.select({ count: count() }).from(idp); const [idpCount] = await db.select({ count: count() }).from(idp);
const [onlineSitesCount] = await db const [onlineSitesCount] = await db
.select({ count: count() }) .select({ count: count() })
@@ -146,6 +159,24 @@ class TelemetryClient {
const supporterKey = config.getSupporterData(); const supporterKey = config.getSupporterData();
const allPrivateResources = await db.select().from(siteResources);
const numPrivResources = allPrivateResources.length;
let numPrivResourceAliases = 0;
let numPrivResourceHosts = 0;
let numPrivResourceCidr = 0;
for (const res of allPrivateResources) {
if (res.mode === "host") {
numPrivResourceHosts += 1;
} else if (res.mode === "cidr") {
numPrivResourceCidr += 1;
}
if (res.alias) {
numPrivResourceAliases += 1;
}
}
return { return {
numSites: sitesCount.count, numSites: sitesCount.count,
numUsers: usersCount.count, numUsers: usersCount.count,
@@ -153,7 +184,11 @@ class TelemetryClient {
numUsersOidc: usersOidcCount.count, numUsersOidc: usersOidcCount.count,
numOrganizations: orgsCount.count, numOrganizations: orgsCount.count,
numResources: resourcesCount.count, numResources: resourcesCount.count,
numClients: clientsCount.count, numPrivateResources: numPrivResources,
numPrivateResourceAliases: numPrivResourceAliases,
numPrivateResourceHosts: numPrivResourceHosts,
numUserDevices: userDevicesCount.count,
numMachineClients: machineClients.count,
numIdentityProviders: idpCount.count, numIdentityProviders: idpCount.count,
numSitesOnline: onlineSitesCount.count, numSitesOnline: onlineSitesCount.count,
resources: resourceDetails, resources: resourceDetails,
@@ -196,7 +231,7 @@ class TelemetryClient {
logger.debug("Sending enterprise startup telemetry payload:", { logger.debug("Sending enterprise startup telemetry payload:", {
payload payload
}); });
// this.client.capture(payload); this.client.capture(payload);
} }
if (build === "oss") { if (build === "oss") {
@@ -246,7 +281,12 @@ class TelemetryClient {
num_users_oidc: stats.numUsersOidc, num_users_oidc: stats.numUsersOidc,
num_organizations: stats.numOrganizations, num_organizations: stats.numOrganizations,
num_resources: stats.numResources, num_resources: stats.numResources,
num_clients: stats.numClients, num_private_resources: stats.numPrivateResources,
num_private_resource_aliases:
stats.numPrivateResourceAliases,
num_private_resource_hosts: stats.numPrivateResourceHosts,
num_user_devices: stats.numUserDevices,
num_machine_clients: stats.numMachineClients,
num_identity_providers: stats.numIdentityProviders, num_identity_providers: stats.numIdentityProviders,
num_sites_online: stats.numSitesOnline, num_sites_online: stats.numSitesOnline,
num_resources_sso_enabled: stats.resources.filter( num_resources_sso_enabled: stats.resources.filter(

View File

@@ -823,7 +823,7 @@ export async function getTraefikConfig(
(cert) => cert.queriedDomain === lp.fullDomain (cert) => cert.queriedDomain === lp.fullDomain
); );
if (!matchingCert) { if (!matchingCert) {
logger.warn( logger.debug(
`No matching certificate found for login page domain: ${lp.fullDomain}` `No matching certificate found for login page domain: ${lp.fullDomain}`
); );
continue; continue;

View File

@@ -84,14 +84,11 @@ LQIDAQAB
-----END PUBLIC KEY-----`; -----END PUBLIC KEY-----`;
constructor(private hostMeta: HostMeta) { constructor(private hostMeta: HostMeta) {
setInterval( setInterval(async () => {
async () => { this.doRecheck = true;
this.doRecheck = true; await this.check();
await this.check(); this.doRecheck = false;
this.doRecheck = false; }, 1000 * this.phoneHomeInterval);
},
1000 * this.phoneHomeInterval
);
} }
public listKeys(): LicenseKeyCache[] { public listKeys(): LicenseKeyCache[] {
@@ -242,7 +239,9 @@ LQIDAQAB
// First failure: fail silently // First failure: fail silently
logger.error("Error communicating with license server:"); logger.error("Error communicating with license server:");
logger.error(e); logger.error(e);
logger.error(`Allowing failure. Will retry one more time at next run interval.`); logger.error(
`Allowing failure. Will retry one more time at next run interval.`
);
// return last known good status // return last known good status
return this.statusCache.get( return this.statusCache.get(
this.statusKey this.statusKey

View File

@@ -22,9 +22,11 @@ import logger from "@server/logger";
import { import {
queryAccessAuditLogsParams, queryAccessAuditLogsParams,
queryAccessAuditLogsQuery, queryAccessAuditLogsQuery,
queryAccess queryAccess,
countAccessQuery
} from "./queryAccessAuditLog"; } from "./queryAccessAuditLog";
import { generateCSV } from "@server/routers/auditLogs/generateCSV"; import { generateCSV } from "@server/routers/auditLogs/generateCSV";
import { MAX_EXPORT_LIMIT } from "@server/routers/auditLogs";
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
@@ -65,6 +67,15 @@ export async function exportAccessAuditLogs(
} }
const data = { ...parsedQuery.data, ...parsedParams.data }; const data = { ...parsedQuery.data, ...parsedParams.data };
const [{ count }] = await countAccessQuery(data);
if (count > MAX_EXPORT_LIMIT) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Export limit exceeded. Your selection contains ${count} rows, but the maximum is ${MAX_EXPORT_LIMIT} rows. Please select a shorter time range to reduce the data.`
)
);
}
const baseQuery = queryAccess(data); const baseQuery = queryAccess(data);

View File

@@ -22,9 +22,11 @@ import logger from "@server/logger";
import { import {
queryActionAuditLogsParams, queryActionAuditLogsParams,
queryActionAuditLogsQuery, queryActionAuditLogsQuery,
queryAction queryAction,
countActionQuery
} from "./queryActionAuditLog"; } from "./queryActionAuditLog";
import { generateCSV } from "@server/routers/auditLogs/generateCSV"; import { generateCSV } from "@server/routers/auditLogs/generateCSV";
import { MAX_EXPORT_LIMIT } from "@server/routers/auditLogs";
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
@@ -65,6 +67,15 @@ export async function exportActionAuditLogs(
} }
const data = { ...parsedQuery.data, ...parsedParams.data }; const data = { ...parsedQuery.data, ...parsedParams.data };
const [{ count }] = await countActionQuery(data);
if (count > MAX_EXPORT_LIMIT) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Export limit exceeded. Your selection contains ${count} rows, but the maximum is ${MAX_EXPORT_LIMIT} rows. Please select a shorter time range to reduce the data.`
)
);
}
const baseQuery = queryAction(data); const baseQuery = queryAction(data);

View File

@@ -24,6 +24,7 @@ import { fromError } from "zod-validation-error";
import { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types"; import { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
export const queryAccessAuditLogsQuery = z.object({ export const queryAccessAuditLogsQuery = z.object({
// iso string just validate its a parseable date // iso string just validate its a parseable date
@@ -32,7 +33,14 @@ export const queryAccessAuditLogsQuery = z.object({
.refine((val) => !isNaN(Date.parse(val)), { .refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string" error: "timeStart must be a valid ISO date string"
}) })
.transform((val) => Math.floor(new Date(val).getTime() / 1000)), .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => getSevenDaysAgo().toISOString())
.openapi({
type: "string",
format: "date-time",
description:
"Start time as ISO date string (defaults to 7 days ago)"
}),
timeEnd: z timeEnd: z
.string() .string()
.refine((val) => !isNaN(Date.parse(val)), { .refine((val) => !isNaN(Date.parse(val)), {

View File

@@ -24,6 +24,7 @@ import { fromError } from "zod-validation-error";
import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types"; import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
export const queryActionAuditLogsQuery = z.object({ export const queryActionAuditLogsQuery = z.object({
// iso string just validate its a parseable date // iso string just validate its a parseable date
@@ -32,7 +33,14 @@ export const queryActionAuditLogsQuery = z.object({
.refine((val) => !isNaN(Date.parse(val)), { .refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string" error: "timeStart must be a valid ISO date string"
}) })
.transform((val) => Math.floor(new Date(val).getTime() / 1000)), .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => getSevenDaysAgo().toISOString())
.openapi({
type: "string",
format: "date-time",
description:
"Start time as ISO date string (defaults to 7 days ago)"
}),
timeEnd: z timeEnd: z
.string() .string()
.refine((val) => !isNaN(Date.parse(val)), { .refine((val) => !isNaN(Date.parse(val)), {

View File

@@ -66,6 +66,7 @@ export async function sendSupportEmail(
{ {
name: req.user?.email || "Support User", name: req.user?.email || "Support User",
to: "support@pangolin.net", to: "support@pangolin.net",
replyTo: req.user?.email || undefined,
from: config.getNoReplyEmail(), from: config.getNoReplyEmail(),
subject: `Support Request: ${subject}` subject: `Support Request: ${subject}`
} }

View File

@@ -9,17 +9,23 @@ import logger from "@server/logger";
import { import {
queryAccessAuditLogsQuery, queryAccessAuditLogsQuery,
queryRequestAuditLogsParams, queryRequestAuditLogsParams,
queryRequest queryRequest,
countRequestQuery
} from "./queryRequestAuditLog"; } from "./queryRequestAuditLog";
import { generateCSV } from "./generateCSV"; import { generateCSV } from "./generateCSV";
export const MAX_EXPORT_LIMIT = 50_000;
registry.registerPath({ registry.registerPath({
method: "get", method: "get",
path: "/org/{orgId}/logs/request", path: "/org/{orgId}/logs/request",
description: "Query the request audit log for an organization", description: "Query the request audit log for an organization",
tags: [OpenAPITags.Org], tags: [OpenAPITags.Org],
request: { request: {
query: queryAccessAuditLogsQuery, query: queryAccessAuditLogsQuery.omit({
limit: true,
offset: true
}),
params: queryRequestAuditLogsParams params: queryRequestAuditLogsParams
}, },
responses: {} responses: {}
@@ -53,9 +59,19 @@ export async function exportRequestAuditLogs(
const data = { ...parsedQuery.data, ...parsedParams.data }; const data = { ...parsedQuery.data, ...parsedParams.data };
const [{ count }] = await countRequestQuery(data);
if (count > MAX_EXPORT_LIMIT) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
`Export limit exceeded. Your selection contains ${count} rows, but the maximum is ${MAX_EXPORT_LIMIT} rows. Please select a shorter time range to reduce the data.`
)
);
}
const baseQuery = queryRequest(data); const baseQuery = queryRequest(data);
const log = await baseQuery.limit(data.limit).offset(data.offset); const log = await baseQuery.limit(MAX_EXPORT_LIMIT);
const csvData = generateCSV(log); const csvData = generateCSV(log);

View File

@@ -2,7 +2,7 @@ import { db, requestAuditLog, driver } from "@server/db";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { NextFunction } from "express"; import { NextFunction } from "express";
import { Request, Response } from "express"; import { Request, Response } from "express";
import { eq, gt, lt, and, count, sql, desc, not, isNull } from "drizzle-orm"; import { eq, gte, lte, and, count, sql, desc, not, isNull } from "drizzle-orm";
import { OpenAPITags } from "@server/openApi"; import { OpenAPITags } from "@server/openApi";
import { z } from "zod"; import { z } from "zod";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
@@ -10,6 +10,7 @@ import HttpCode from "@server/types/HttpCode";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
const queryAccessAuditLogsQuery = z.object({ const queryAccessAuditLogsQuery = z.object({
// iso string just validate its a parseable date // iso string just validate its a parseable date
@@ -19,7 +20,14 @@ const queryAccessAuditLogsQuery = z.object({
error: "timeStart must be a valid ISO date string" error: "timeStart must be a valid ISO date string"
}) })
.transform((val) => Math.floor(new Date(val).getTime() / 1000)) .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.optional(), .optional()
.prefault(() => getSevenDaysAgo().toISOString())
.openapi({
type: "string",
format: "date-time",
description:
"Start time as ISO date string (defaults to 7 days ago)"
}),
timeEnd: z timeEnd: z
.string() .string()
.refine((val) => !isNaN(Date.parse(val)), { .refine((val) => !isNaN(Date.parse(val)), {
@@ -55,15 +63,10 @@ type Q = z.infer<typeof queryRequestAuditLogsCombined>;
async function query(query: Q) { async function query(query: Q) {
let baseConditions = and( let baseConditions = and(
eq(requestAuditLog.orgId, query.orgId), eq(requestAuditLog.orgId, query.orgId),
lt(requestAuditLog.timestamp, query.timeEnd) gte(requestAuditLog.timestamp, query.timeStart),
lte(requestAuditLog.timestamp, query.timeEnd)
); );
if (query.timeStart) {
baseConditions = and(
baseConditions,
gt(requestAuditLog.timestamp, query.timeStart)
);
}
if (query.resourceId) { if (query.resourceId) {
baseConditions = and( baseConditions = and(
baseConditions, baseConditions,

View File

@@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error";
import { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; import { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
export const queryAccessAuditLogsQuery = z.object({ export const queryAccessAuditLogsQuery = z.object({
// iso string just validate its a parseable date // iso string just validate its a parseable date
@@ -19,7 +20,14 @@ export const queryAccessAuditLogsQuery = z.object({
.refine((val) => !isNaN(Date.parse(val)), { .refine((val) => !isNaN(Date.parse(val)), {
error: "timeStart must be a valid ISO date string" error: "timeStart must be a valid ISO date string"
}) })
.transform((val) => Math.floor(new Date(val).getTime() / 1000)), .transform((val) => Math.floor(new Date(val).getTime() / 1000))
.prefault(() => getSevenDaysAgo().toISOString())
.openapi({
type: "string",
format: "date-time",
description:
"Start time as ISO date string (defaults to 7 days ago)"
}),
timeEnd: z timeEnd: z
.string() .string()
.refine((val) => !isNaN(Date.parse(val)), { .refine((val) => !isNaN(Date.parse(val)), {

View File

@@ -148,7 +148,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) {
} }
} }
export function logRequestAudit( export async function logRequestAudit(
data: { data: {
action: boolean; action: boolean;
reason: number; reason: number;
@@ -174,14 +174,13 @@ export function logRequestAudit(
} }
) { ) {
try { try {
// Quick synchronous check - if org has 0 retention, skip immediately // Check retention before buffering any logs
if (data.orgId) { if (data.orgId) {
const cached = cache.get<number>(`org_${data.orgId}_retentionDays`); const retentionDays = await getRetentionDays(data.orgId);
if (cached === 0) { if (retentionDays === 0) {
// do not log // do not log
return; return;
} }
// If not cached or > 0, we'll log it (async retention check happens in background)
} }
let actorType: string | undefined; let actorType: string | undefined;
@@ -261,16 +260,6 @@ export function logRequestAudit(
} else { } else {
scheduleFlush(); scheduleFlush();
} }
// Async retention check in background (don't await)
if (
data.orgId &&
cache.get<number>(`org_${data.orgId}_retentionDays`) === undefined
) {
getRetentionDays(data.orgId).catch((err) =>
logger.error("Error checking retention days:", err)
);
}
} catch (error) { } catch (error) {
logger.error(error); logger.error(error);
} }

View File

@@ -4,21 +4,48 @@ import { Alias, SubnetProxyTarget } from "@server/lib/ip";
import logger from "@server/logger"; import logger from "@server/logger";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
const BATCH_SIZE = 50;
const BATCH_DELAY_MS = 50;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function chunkArray<T>(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}
export async function addTargets(newtId: string, targets: SubnetProxyTarget[]) { export async function addTargets(newtId: string, targets: SubnetProxyTarget[]) {
await sendToClient(newtId, { const batches = chunkArray(targets, BATCH_SIZE);
type: `newt/wg/targets/add`, for (let i = 0; i < batches.length; i++) {
data: targets if (i > 0) {
}); await sleep(BATCH_DELAY_MS);
}
await sendToClient(newtId, {
type: `newt/wg/targets/add`,
data: batches[i]
});
}
} }
export async function removeTargets( export async function removeTargets(
newtId: string, newtId: string,
targets: SubnetProxyTarget[] targets: SubnetProxyTarget[]
) { ) {
await sendToClient(newtId, { const batches = chunkArray(targets, BATCH_SIZE);
type: `newt/wg/targets/remove`, for (let i = 0; i < batches.length; i++) {
data: targets if (i > 0) {
}); await sleep(BATCH_DELAY_MS);
}
await sendToClient(newtId, {
type: `newt/wg/targets/remove`,
data: batches[i]
});
}
} }
export async function updateTargets( export async function updateTargets(
@@ -28,12 +55,24 @@ export async function updateTargets(
newTargets: SubnetProxyTarget[]; newTargets: SubnetProxyTarget[];
} }
) { ) {
await sendToClient(newtId, { const oldBatches = chunkArray(targets.oldTargets, BATCH_SIZE);
type: `newt/wg/targets/update`, const newBatches = chunkArray(targets.newTargets, BATCH_SIZE);
data: targets const maxBatches = Math.max(oldBatches.length, newBatches.length);
}).catch((error) => {
logger.warn(`Error sending message:`, error); for (let i = 0; i < maxBatches; i++) {
}); if (i > 0) {
await sleep(BATCH_DELAY_MS);
}
await sendToClient(newtId, {
type: `newt/wg/targets/update`,
data: {
oldTargets: oldBatches[i] || [],
newTargets: newBatches[i] || []
}
}).catch((error) => {
logger.warn(`Error sending message:`, error);
});
}
} }
export async function addPeerData( export async function addPeerData(

View File

@@ -51,7 +51,10 @@ export async function getConfig(
); );
} }
const exitNode = await createExitNode(publicKey, reachableAt); // clean up the public key - keep only valid base64 characters (A-Z, a-z, 0-9, +, /, =)
const cleanedPublicKey = publicKey.replace(/[^A-Za-z0-9+/=]/g, '');
const exitNode = await createExitNode(cleanedPublicKey, reachableAt);
if (!exitNode) { if (!exitNode) {
return next( return next(

View File

@@ -352,6 +352,14 @@ authenticated.post(
user.inviteUser user.inviteUser
); );
authenticated.delete(
"/org/:orgId/invitations/:inviteId",
verifyApiKeyOrgAccess,
verifyApiKeyHasAction(ActionsEnum.removeInvitation),
logActionAudit(ActionsEnum.removeInvitation),
user.removeInvitation
);
authenticated.get( authenticated.get(
"/resource/:resourceId/roles", "/resource/:resourceId/roles",
verifyApiKeyResourceAccess, verifyApiKeyResourceAccess,

View File

@@ -346,6 +346,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => {
type: "newt/wg/connect", type: "newt/wg/connect",
data: { data: {
endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`, endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`,
relayPort: config.getRawConfig().gerbil.clients_start_port,
publicKey: exitNode.publicKey, publicKey: exitNode.publicKey,
serverIP: exitNode.address.split("/")[0], serverIP: exitNode.address.split("/")[0],
tunnelIP: siteSubnet.split("/")[0], tunnelIP: siteSubnet.split("/")[0],

View File

@@ -197,6 +197,7 @@ export async function getOlmToken(
const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => { const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => {
return { return {
publicKey: exitNode.publicKey, publicKey: exitNode.publicKey,
relayPort: config.getRawConfig().gerbil.clients_start_port,
endpoint: exitNode.endpoint endpoint: exitNode.endpoint
}; };
}); });

View File

@@ -4,6 +4,7 @@ import { clients, clientSitesAssociationsCache, Olm } from "@server/db";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { updatePeer as newtUpdatePeer } from "../newt/peers"; import { updatePeer as newtUpdatePeer } from "../newt/peers";
import logger from "@server/logger"; import logger from "@server/logger";
import config from "@server/lib/config";
export const handleOlmRelayMessage: MessageHandler = async (context) => { export const handleOlmRelayMessage: MessageHandler = async (context) => {
const { message, client: c, sendToClient } = context; const { message, client: c, sendToClient } = context;
@@ -88,7 +89,8 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => {
type: "olm/wg/peer/relay", type: "olm/wg/peer/relay",
data: { data: {
siteId: siteId, siteId: siteId,
relayEndpoint: exitNode.endpoint relayEndpoint: exitNode.endpoint,
relayPort: config.getRawConfig().gerbil.clients_start_port
} }
}, },
broadcast: false, broadcast: false,

View File

@@ -1,5 +1,6 @@
import { sendToClient } from "#dynamic/routers/ws"; import { sendToClient } from "#dynamic/routers/ws";
import { db, olms } from "@server/db"; import { db, olms } from "@server/db";
import config from "@server/lib/config";
import logger from "@server/logger"; import logger from "@server/logger";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { Alias } from "yaml"; import { Alias } from "yaml";
@@ -156,6 +157,7 @@ export async function initPeerAddHandshake(
siteId: peer.siteId, siteId: peer.siteId,
exitNode: { exitNode: {
publicKey: peer.exitNode.publicKey, publicKey: peer.exitNode.publicKey,
relayPort: config.getRawConfig().gerbil.clients_start_port,
endpoint: peer.exitNode.endpoint endpoint: peer.exitNode.endpoint
} }
} }

View File

@@ -31,7 +31,12 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor
const createOrgSchema = z.strictObject({ const createOrgSchema = z.strictObject({
orgId: z.string(), orgId: z.string(),
name: z.string().min(1).max(255), name: z.string().min(1).max(255),
subnet: z.string() subnet: z
// .union([z.cidrv4(), z.cidrv6()])
.union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
.refine((val) => isValidCIDR(val), {
message: "Invalid subnet CIDR"
})
}); });
registry.registerPath({ registry.registerPath({
@@ -81,15 +86,6 @@ export async function createOrg(
const { orgId, name, subnet } = parsedBody.data; const { orgId, name, subnet } = parsedBody.data;
if (!isValidCIDR(subnet)) {
return next(
createHttpError(
HttpCode.BAD_REQUEST,
"Invalid subnet format. Please provide a valid CIDR notation."
)
);
}
// TODO: for now we are making all of the orgs the same subnet // TODO: for now we are making all of the orgs the same subnet
// make sure the subnet is unique // make sure the subnet is unique
// const subnetExists = await db // const subnetExists = await db

View File

@@ -10,7 +10,7 @@ import {
userSiteResources userSiteResources
} from "@server/db"; } from "@server/db";
import { getUniqueSiteResourceName } from "@server/db/names"; import { getUniqueSiteResourceName } from "@server/db/names";
import { getNextAvailableAliasAddress } from "@server/lib/ip"; import { getNextAvailableAliasAddress, portRangeStringSchema } from "@server/lib/ip";
import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations";
import response from "@server/lib/response"; import response from "@server/lib/response";
import logger from "@server/logger"; import logger from "@server/logger";
@@ -45,7 +45,10 @@ const createSiteResourceSchema = z
.optional(), .optional(),
userIds: z.array(z.string()), userIds: z.array(z.string()),
roleIds: z.array(z.int()), roleIds: z.array(z.int()),
clientIds: z.array(z.int()) clientIds: z.array(z.int()),
tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional()
}) })
.strict() .strict()
.refine( .refine(
@@ -53,7 +56,8 @@ const createSiteResourceSchema = z
if (data.mode === "host") { if (data.mode === "host") {
// Check if it's a valid IP address using zod (v4 or v6) // Check if it's a valid IP address using zod (v4 or v6)
const isValidIP = z const isValidIP = z
.union([z.ipv4(), z.ipv6()]) // .union([z.ipv4(), z.ipv6()])
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
.safeParse(data.destination).success; .safeParse(data.destination).success;
if (isValidIP) { if (isValidIP) {
@@ -80,7 +84,8 @@ const createSiteResourceSchema = z
if (data.mode === "cidr") { if (data.mode === "cidr") {
// Check if it's a valid CIDR (v4 or v6) // Check if it's a valid CIDR (v4 or v6)
const isValidCIDR = z const isValidCIDR = z
.union([z.cidrv4(), z.cidrv6()]) // .union([z.cidrv4(), z.cidrv6()])
.union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
.safeParse(data.destination).success; .safeParse(data.destination).success;
return isValidCIDR; return isValidCIDR;
} }
@@ -152,7 +157,10 @@ export async function createSiteResource(
alias, alias,
userIds, userIds,
roleIds, roleIds,
clientIds clientIds,
tcpPortRangeString,
udpPortRangeString,
disableIcmp
} = parsedBody.data; } = parsedBody.data;
// Verify the site exists and belongs to the org // Verify the site exists and belongs to the org
@@ -237,7 +245,10 @@ export async function createSiteResource(
destination, destination,
enabled, enabled,
alias, alias,
aliasAddress aliasAddress,
tcpPortRangeString,
udpPortRangeString,
disableIcmp
}) })
.returning(); .returning();

View File

@@ -97,6 +97,9 @@ export async function listAllSiteResourcesByOrg(
destination: siteResources.destination, destination: siteResources.destination,
enabled: siteResources.enabled, enabled: siteResources.enabled,
alias: siteResources.alias, alias: siteResources.alias,
tcpPortRangeString: siteResources.tcpPortRangeString,
udpPortRangeString: siteResources.udpPortRangeString,
disableIcmp: siteResources.disableIcmp,
siteName: sites.name, siteName: sites.name,
siteNiceId: sites.niceId, siteNiceId: sites.niceId,
siteAddress: sites.address siteAddress: sites.address

View File

@@ -23,7 +23,8 @@ import { updatePeerData, updateTargets } from "@server/routers/client/targets";
import { import {
generateAliasConfig, generateAliasConfig,
generateRemoteSubnets, generateRemoteSubnets,
generateSubnetProxyTargets generateSubnetProxyTargets,
portRangeStringSchema
} from "@server/lib/ip"; } from "@server/lib/ip";
import { import {
getClientSiteResourceAccess, getClientSiteResourceAccess,
@@ -55,14 +56,18 @@ const updateSiteResourceSchema = z
.nullish(), .nullish(),
userIds: z.array(z.string()), userIds: z.array(z.string()),
roleIds: z.array(z.int()), roleIds: z.array(z.int()),
clientIds: z.array(z.int()) clientIds: z.array(z.int()),
tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional()
}) })
.strict() .strict()
.refine( .refine(
(data) => { (data) => {
if (data.mode === "host" && data.destination) { if (data.mode === "host" && data.destination) {
const isValidIP = z const isValidIP = z
.union([z.ipv4(), z.ipv6()]) // .union([z.ipv4(), z.ipv6()])
.union([z.ipv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
.safeParse(data.destination).success; .safeParse(data.destination).success;
if (isValidIP) { if (isValidIP) {
@@ -89,7 +94,8 @@ const updateSiteResourceSchema = z
if (data.mode === "cidr" && data.destination) { if (data.mode === "cidr" && data.destination) {
// Check if it's a valid CIDR (v4 or v6) // Check if it's a valid CIDR (v4 or v6)
const isValidCIDR = z const isValidCIDR = z
.union([z.cidrv4(), z.cidrv6()]) // .union([z.cidrv4(), z.cidrv6()])
.union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere
.safeParse(data.destination).success; .safeParse(data.destination).success;
return isValidCIDR; return isValidCIDR;
} }
@@ -158,7 +164,10 @@ export async function updateSiteResource(
enabled, enabled,
userIds, userIds,
roleIds, roleIds,
clientIds clientIds,
tcpPortRangeString,
udpPortRangeString,
disableIcmp
} = parsedBody.data; } = parsedBody.data;
const [site] = await db const [site] = await db
@@ -224,7 +233,10 @@ export async function updateSiteResource(
mode: mode, mode: mode,
destination: destination, destination: destination,
enabled: enabled, enabled: enabled,
alias: alias && alias.trim() ? alias : null alias: alias && alias.trim() ? alias : null,
tcpPortRangeString: tcpPortRangeString,
udpPortRangeString: udpPortRangeString,
disableIcmp: disableIcmp
}) })
.where( .where(
and( and(
@@ -346,10 +358,18 @@ export async function handleMessagingForUpdatedSiteResource(
const aliasChanged = const aliasChanged =
existingSiteResource && existingSiteResource &&
existingSiteResource.alias !== updatedSiteResource.alias; existingSiteResource.alias !== updatedSiteResource.alias;
const portRangesChanged =
existingSiteResource &&
(existingSiteResource.tcpPortRangeString !==
updatedSiteResource.tcpPortRangeString ||
existingSiteResource.udpPortRangeString !==
updatedSiteResource.udpPortRangeString ||
existingSiteResource.disableIcmp !==
updatedSiteResource.disableIcmp);
// if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all // if the existingSiteResource is undefined (new resource) we don't need to do anything here, the rebuild above handled it all
if (destinationChanged || aliasChanged) { if (destinationChanged || aliasChanged || portRangesChanged) {
const [newt] = await trx const [newt] = await trx
.select() .select()
.from(newts) .from(newts)
@@ -363,7 +383,7 @@ export async function handleMessagingForUpdatedSiteResource(
} }
// Only update targets on newt if destination changed // Only update targets on newt if destination changed
if (destinationChanged) { if (destinationChanged || portRangesChanged) {
const oldTargets = generateSubnetProxyTargets( const oldTargets = generateSubnetProxyTargets(
existingSiteResource, existingSiteResource,
mergedAllClients mergedAllClients

View File

@@ -8,12 +8,24 @@ import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors"; import createHttpError from "http-errors";
import logger from "@server/logger"; import logger from "@server/logger";
import { fromError } from "zod-validation-error"; import { fromError } from "zod-validation-error";
import { OpenAPITags, registry } from "@server/openApi";
const removeInvitationParamsSchema = z.strictObject({ const removeInvitationParamsSchema = z.strictObject({
orgId: z.string(), orgId: z.string(),
inviteId: z.string() inviteId: z.string()
}); });
registry.registerPath({
method: "delete",
path: "/org/{orgId}/invitations/{inviteId}",
description: "Remove an open invitation from an organization",
tags: [OpenAPITags.Org],
request: {
params: removeInvitationParamsSchema
},
responses: {}
});
export async function removeInvitation( export async function removeInvitation(
req: Request, req: Request,
res: Response, res: Response,

View File

@@ -16,11 +16,23 @@ function generateToken(): string {
return generateRandomString(random, alphabet, 32); return generateRandomString(random, alphabet, 32);
} }
function validateToken(token: string): boolean {
const tokenRegex = /^[a-z0-9]{32}$/;
return tokenRegex.test(token);
}
function generateId(length: number): string { function generateId(length: number): string {
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
return generateRandomString(random, alphabet, length); return generateRandomString(random, alphabet, length);
} }
function showSetupToken(token: string, source: string): void {
console.log(`=== SETUP TOKEN ${source} ===`);
console.log("Token:", token);
console.log("Use this token on the initial setup page");
console.log("================================");
}
export async function ensureSetupToken() { export async function ensureSetupToken() {
try { try {
// Check if a server admin already exists // Check if a server admin already exists
@@ -38,17 +50,48 @@ export async function ensureSetupToken() {
} }
// Check if a setup token already exists // Check if a setup token already exists
const existingTokens = await db const [existingToken] = await db
.select() .select()
.from(setupTokens) .from(setupTokens)
.where(eq(setupTokens.used, false)); .where(eq(setupTokens.used, false));
const envSetupToken = process.env.PANGOLIN_SETUP_TOKEN;
console.debug("PANGOLIN_SETUP_TOKEN:", envSetupToken);
if (envSetupToken) {
if (!validateToken(envSetupToken)) {
throw new Error(
"invalid token format for PANGOLIN_SETUP_TOKEN"
);
}
if (existingToken?.token !== envSetupToken) {
console.warn(
"Overwriting existing token in DB since PANGOLIN_SETUP_TOKEN is set"
);
await db
.update(setupTokens)
.set({ token: envSetupToken })
.where(eq(setupTokens.tokenId, existingToken.tokenId));
} else {
const tokenId = generateId(15);
await db.insert(setupTokens).values({
tokenId: tokenId,
token: envSetupToken,
used: false,
dateCreated: moment().toISOString(),
dateUsed: null
});
}
showSetupToken(envSetupToken, "FROM ENVIRONMENT");
return;
}
// If unused token exists, display it instead of creating a new one // If unused token exists, display it instead of creating a new one
if (existingTokens.length > 0) { if (existingToken) {
console.log("=== SETUP TOKEN EXISTS ==="); showSetupToken(existingToken.token, "EXISTS");
console.log("Token:", existingTokens[0].token);
console.log("Use this token on the initial setup page");
console.log("================================");
return; return;
} }
@@ -64,10 +107,7 @@ export async function ensureSetupToken() {
dateUsed: null dateUsed: null
}); });
console.log("=== SETUP TOKEN GENERATED ==="); showSetupToken(token, "GENERATED");
console.log("Token:", token);
console.log("Use this token on the initial setup page");
console.log("================================");
} catch (error) { } catch (error) {
console.error("Failed to ensure setup token:", error); console.error("Failed to ensure setup token:", error);
throw error; throw error;

View File

@@ -304,7 +304,7 @@ export default function ExitNodesTable({
setSelectedNode(null); setSelectedNode(null);
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p>{t("remoteExitNodeQuestionRemove")}</p> <p>{t("remoteExitNodeQuestionRemove")}</p>
<p>{t("remoteExitNodeMessageRemove")}</p> <p>{t("remoteExitNodeMessageRemove")}</p>

View File

@@ -289,7 +289,7 @@ export default function GeneralPage() {
setIsDeleteModalOpen(val); setIsDeleteModalOpen(val);
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p>{t("orgQuestionRemove")}</p> <p>{t("orgQuestionRemove")}</p>
<p>{t("orgMessageRemove")}</p> <p>{t("orgMessageRemove")}</p>
</div> </div>
@@ -303,7 +303,7 @@ export default function GeneralPage() {
open={isSecurityPolicyConfirmOpen} open={isSecurityPolicyConfirmOpen}
setOpen={setIsSecurityPolicyConfirmOpen} setOpen={setIsSecurityPolicyConfirmOpen}
dialog={ dialog={
<div> <div className="space-y-2">
<p>{t("securityPolicyChangeDescription")}</p> <p>{t("securityPolicyChangeDescription")}</p>
</div> </div>
} }

View File

@@ -1,16 +1,12 @@
"use client"; "use client";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { toast } from "@app/hooks/useToast"; import { toast } from "@app/hooks/useToast";
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect, useTransition } from "react";
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 { useParams, useRouter, useSearchParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { import { LogDataTable } from "@app/components/LogDataTable";
getStoredPageSize,
LogDataTable,
setStoredPageSize
} from "@app/components/LogDataTable";
import { ColumnDef } from "@tanstack/react-table"; import { ColumnDef } from "@tanstack/react-table";
import { DateTimeValue } from "@app/components/DateTimePicker"; import { DateTimeValue } from "@app/components/DateTimePicker";
import { ArrowUpRight, Key, User } from "lucide-react"; import { ArrowUpRight, Key, User } from "lucide-react";
@@ -21,21 +17,22 @@ import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusCo
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { build } from "@server/build"; import { build } from "@server/build";
import { Alert, AlertDescription } from "@app/components/ui/alert"; import { Alert, AlertDescription } from "@app/components/ui/alert";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import axios from "axios";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
export default function GeneralPage() { export default function GeneralPage() {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext();
const { orgId } = useParams(); const { orgId } = useParams();
const subscription = useSubscriptionStatusContext(); const subscription = useSubscriptionStatusContext();
const { isUnlocked } = useLicenseStatusContext(); const { isUnlocked } = useLicenseStatusContext();
const [rows, setRows] = useState<any[]>([]); const [rows, setRows] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [isExporting, setIsExporting] = useState(false); const [isExporting, startTransition] = useTransition();
const [filterAttributes, setFilterAttributes] = useState<{ const [filterAttributes, setFilterAttributes] = useState<{
actors: string[]; actors: string[];
resources: { resources: {
@@ -70,9 +67,7 @@ export default function GeneralPage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// Initialize page size from storage or default // Initialize page size from storage or default
const [pageSize, setPageSize] = useState<number>(() => { const [pageSize, setPageSize] = useStoredPageSize("access-audit-logs", 20);
return getStoredPageSize("access-audit-logs", 20);
});
// Set default date range to last 24 hours // Set default date range to last 24 hours
const getDefaultDateRange = () => { const getDefaultDateRange = () => {
@@ -91,11 +86,11 @@ export default function GeneralPage() {
} }
const now = new Date(); const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); const lastWeek = getSevenDaysAgo();
return { return {
startDate: { startDate: {
date: yesterday date: lastWeek
}, },
endDate: { endDate: {
date: now date: now
@@ -148,7 +143,6 @@ export default function GeneralPage() {
// Handle page size changes // Handle page size changes
const handlePageSizeChange = (newPageSize: number) => { const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize); setPageSize(newPageSize);
setStoredPageSize(newPageSize, "access-audit-logs");
setCurrentPage(0); // Reset to first page when changing page size setCurrentPage(0); // Reset to first page when changing page size
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
}; };
@@ -309,8 +303,6 @@ export default function GeneralPage() {
const exportData = async () => { const exportData = async () => {
try { try {
setIsExporting(true);
// Prepare query params for export // Prepare query params for export
const params: any = { const params: any = {
timeStart: dateRange.startDate?.date timeStart: dateRange.startDate?.date
@@ -339,11 +331,21 @@ export default function GeneralPage() {
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
link.parentNode?.removeChild(link); link.parentNode?.removeChild(link);
setIsExporting(false);
} catch (error) { } catch (error) {
let apiErrorMessage: string | null = null;
if (axios.isAxiosError(error) && error.response) {
const data = error.response.data;
if (data instanceof Blob && data.type === "application/json") {
// Parse the Blob as JSON
const text = await data.text();
const errorData = JSON.parse(text);
apiErrorMessage = errorData.message;
}
}
toast({ toast({
title: t("error"), title: t("error"),
description: t("exportError"), description: apiErrorMessage ?? t("exportError"),
variant: "destructive" variant: "destructive"
}); });
} }
@@ -631,7 +633,7 @@ export default function GeneralPage() {
title={t("accessLogs")} title={t("accessLogs")}
onRefresh={refreshData} onRefresh={refreshData}
isRefreshing={isRefreshing} isRefreshing={isRefreshing}
onExport={exportData} onExport={() => startTransition(exportData)}
isExporting={isExporting} isExporting={isExporting}
onDateRangeChange={handleDateRangeChange} onDateRangeChange={handleDateRangeChange}
dateRange={{ dateRange={{

View File

@@ -1,32 +1,28 @@
"use client"; "use client";
import { Button } from "@app/components/ui/button";
import { toast } from "@app/hooks/useToast";
import { useState, useRef, useEffect } from "react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import {
getStoredPageSize,
LogDataTable,
setStoredPageSize
} from "@app/components/LogDataTable";
import { ColumnDef } from "@tanstack/react-table";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { Key, User } from "lucide-react";
import { ColumnFilter } from "@app/components/ColumnFilter"; import { ColumnFilter } from "@app/components/ColumnFilter";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { build } from "@server/build";
import { Alert, AlertDescription } from "@app/components/ui/alert"; import { Alert, AlertDescription } from "@app/components/ui/alert";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { build } from "@server/build";
import { ColumnDef } from "@tanstack/react-table";
import axios from "axios";
import { Key, User } from "lucide-react";
import { useTranslations } from "next-intl";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
export default function GeneralPage() { export default function GeneralPage() {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const router = useRouter(); const router = useRouter();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext();
const { orgId } = useParams(); const { orgId } = useParams();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const subscription = useSubscriptionStatusContext(); const subscription = useSubscriptionStatusContext();
@@ -34,7 +30,7 @@ export default function GeneralPage() {
const [rows, setRows] = useState<any[]>([]); const [rows, setRows] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [isExporting, setIsExporting] = useState(false); const [isExporting, startTransition] = useTransition();
const [filterAttributes, setFilterAttributes] = useState<{ const [filterAttributes, setFilterAttributes] = useState<{
actors: string[]; actors: string[];
actions: string[]; actions: string[];
@@ -58,9 +54,7 @@ export default function GeneralPage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// Initialize page size from storage or default // Initialize page size from storage or default
const [pageSize, setPageSize] = useState<number>(() => { const [pageSize, setPageSize] = useStoredPageSize("action-audit-logs", 20);
return getStoredPageSize("action-audit-logs", 20);
});
// Set default date range to last 24 hours // Set default date range to last 24 hours
const getDefaultDateRange = () => { const getDefaultDateRange = () => {
@@ -79,11 +73,11 @@ export default function GeneralPage() {
} }
const now = new Date(); const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); const lastWeek = getSevenDaysAgo();
return { return {
startDate: { startDate: {
date: yesterday date: lastWeek
}, },
endDate: { endDate: {
date: now date: now
@@ -136,7 +130,6 @@ export default function GeneralPage() {
// Handle page size changes // Handle page size changes
const handlePageSizeChange = (newPageSize: number) => { const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize); setPageSize(newPageSize);
setStoredPageSize(newPageSize, "action-audit-logs");
setCurrentPage(0); // Reset to first page when changing page size setCurrentPage(0); // Reset to first page when changing page size
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
}; };
@@ -293,8 +286,6 @@ export default function GeneralPage() {
const exportData = async () => { const exportData = async () => {
try { try {
setIsExporting(true);
// Prepare query params for export // Prepare query params for export
const params: any = { const params: any = {
timeStart: dateRange.startDate?.date timeStart: dateRange.startDate?.date
@@ -323,11 +314,21 @@ export default function GeneralPage() {
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
link.parentNode?.removeChild(link); link.parentNode?.removeChild(link);
setIsExporting(false);
} catch (error) { } catch (error) {
let apiErrorMessage: string | null = null;
if (axios.isAxiosError(error) && error.response) {
const data = error.response.data;
if (data instanceof Blob && data.type === "application/json") {
// Parse the Blob as JSON
const text = await data.text();
const errorData = JSON.parse(text);
apiErrorMessage = errorData.message;
}
}
toast({ toast({
title: t("error"), title: t("error"),
description: t("exportError"), description: apiErrorMessage ?? t("exportError"),
variant: "destructive" variant: "destructive"
}); });
} }
@@ -484,7 +485,7 @@ export default function GeneralPage() {
searchColumn="action" searchColumn="action"
onRefresh={refreshData} onRefresh={refreshData}
isRefreshing={isRefreshing} isRefreshing={isRefreshing}
onExport={exportData} onExport={() => startTransition(exportData)}
isExporting={isExporting} isExporting={isExporting}
onDateRangeChange={handleDateRangeChange} onDateRangeChange={handleDateRangeChange}
dateRange={{ dateRange={{

View File

@@ -1,34 +1,32 @@
"use client"; "use client";
import { Button } from "@app/components/ui/button";
import { toast } from "@app/hooks/useToast";
import { useState, useRef, useEffect } from "react";
import { createApiClient } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import {
getStoredPageSize,
LogDataTable,
setStoredPageSize
} from "@app/components/LogDataTable";
import { ColumnDef } from "@tanstack/react-table";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { Key, RouteOff, User, Lock, Unlock, ArrowUpRight } from "lucide-react";
import Link from "next/link";
import { ColumnFilter } from "@app/components/ColumnFilter"; import { ColumnFilter } from "@app/components/ColumnFilter";
import { DateTimeValue } from "@app/components/DateTimePicker";
import { LogDataTable } from "@app/components/LogDataTable";
import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle";
import { Button } from "@app/components/ui/button";
import { useEnvContext } from "@app/hooks/useEnvContext";
import { toast } from "@app/hooks/useToast";
import { createApiClient } from "@app/lib/api";
import { useTranslations } from "next-intl";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
import { ColumnDef } from "@tanstack/react-table";
import axios from "axios";
import { ArrowUpRight, Key, Lock, Unlock, User } from "lucide-react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState, useTransition } from "react";
import { useStoredPageSize } from "@app/hooks/useStoredPageSize";
export default function GeneralPage() { export default function GeneralPage() {
const router = useRouter(); const router = useRouter();
const api = createApiClient(useEnvContext()); const api = createApiClient(useEnvContext());
const t = useTranslations(); const t = useTranslations();
const { env } = useEnvContext();
const { orgId } = useParams(); const { orgId } = useParams();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [rows, setRows] = useState<any[]>([]); const [rows, setRows] = useState<any[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false);
const [isExporting, setIsExporting] = useState(false); const [isExporting, startTransition] = useTransition();
// Pagination state // Pagination state
const [totalCount, setTotalCount] = useState<number>(0); const [totalCount, setTotalCount] = useState<number>(0);
@@ -36,9 +34,7 @@ export default function GeneralPage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// Initialize page size from storage or default // Initialize page size from storage or default
const [pageSize, setPageSize] = useState<number>(() => { const [pageSize, setPageSize] = useStoredPageSize("request-audit-logs", 20);
return getStoredPageSize("request-audit-logs", 20);
});
const [filterAttributes, setFilterAttributes] = useState<{ const [filterAttributes, setFilterAttributes] = useState<{
actors: string[]; actors: string[];
@@ -95,11 +91,11 @@ export default function GeneralPage() {
} }
const now = new Date(); const now = new Date();
const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); const lastWeek = getSevenDaysAgo();
return { return {
startDate: { startDate: {
date: yesterday date: lastWeek
}, },
endDate: { endDate: {
date: now date: now
@@ -152,7 +148,6 @@ export default function GeneralPage() {
// Handle page size changes // Handle page size changes
const handlePageSizeChange = (newPageSize: number) => { const handlePageSizeChange = (newPageSize: number) => {
setPageSize(newPageSize); setPageSize(newPageSize);
setStoredPageSize(newPageSize, "request-audit-logs");
setCurrentPage(0); // Reset to first page when changing page size setCurrentPage(0); // Reset to first page when changing page size
queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize);
}; };
@@ -302,8 +297,6 @@ export default function GeneralPage() {
const exportData = async () => { const exportData = async () => {
try { try {
setIsExporting(true);
// Prepare query params for export // Prepare query params for export
const params: any = { const params: any = {
timeStart: dateRange.startDate?.date timeStart: dateRange.startDate?.date
@@ -335,11 +328,21 @@ export default function GeneralPage() {
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
link.parentNode?.removeChild(link); link.parentNode?.removeChild(link);
setIsExporting(false);
} catch (error) { } catch (error) {
let apiErrorMessage: string | null = null;
if (axios.isAxiosError(error) && error.response) {
const data = error.response.data;
if (data instanceof Blob && data.type === "application/json") {
// Parse the Blob as JSON
const text = await data.text();
const errorData = JSON.parse(text);
apiErrorMessage = errorData.message;
}
}
toast({ toast({
title: t("error"), title: t("error"),
description: t("exportError"), description: apiErrorMessage ?? t("exportError"),
variant: "destructive" variant: "destructive"
}); });
} }
@@ -773,7 +776,7 @@ export default function GeneralPage() {
searchColumn="host" searchColumn="host"
onRefresh={refreshData} onRefresh={refreshData}
isRefreshing={isRefreshing} isRefreshing={isRefreshing}
onExport={exportData} onExport={() => startTransition(exportData)}
isExporting={isExporting} isExporting={isExporting}
onDateRangeChange={handleDateRangeChange} onDateRangeChange={handleDateRangeChange}
dateRange={{ dateRange={{

View File

@@ -67,7 +67,10 @@ export default async function ClientResourcesPage(
// destinationPort: siteResource.destinationPort, // destinationPort: siteResource.destinationPort,
alias: siteResource.alias || null, alias: siteResource.alias || null,
siteNiceId: siteResource.siteNiceId, siteNiceId: siteResource.siteNiceId,
niceId: siteResource.niceId niceId: siteResource.niceId,
tcpPortRangeString: siteResource.tcpPortRangeString || null,
udpPortRangeString: siteResource.udpPortRangeString || null,
disableIcmp: siteResource.disableIcmp || false,
}; };
} }
); );

View File

@@ -225,7 +225,7 @@ export default function GeneralForm() {
name: data.name, name: data.name,
niceId: data.niceId, niceId: data.niceId,
subdomain: data.subdomain, subdomain: data.subdomain,
fullDomain: resource.fullDomain, fullDomain: updated.fullDomain,
proxyPort: data.proxyPort proxyPort: data.proxyPort
// ...(!resource.http && { // ...(!resource.http && {
// enableProxy: data.enableProxy // enableProxy: data.enableProxy

View File

@@ -449,15 +449,16 @@ export default function ResourceRules(props: {
type="number" type="number"
onClick={(e) => e.currentTarget.focus()} onClick={(e) => e.currentTarget.focus()}
onBlur={(e) => { onBlur={(e) => {
const parsed = z const parsed = z.coerce
.number()
.int() .int()
.optional() .optional()
.safeParse(e.target.value); .safeParse(e.target.value);
if (!parsed.data) { if (!parsed.success) {
toast({ toast({
variant: "destructive", variant: "destructive",
title: t("rulesErrorInvalidIpAddress"), // correct priority or IP? title: t("rulesErrorInvalidPriority"), // correct priority or IP?
description: t( description: t(
"rulesErrorInvalidPriorityDescription" "rulesErrorInvalidPriorityDescription"
) )

View File

@@ -315,7 +315,7 @@ export default function LicensePage() {
setSelectedLicenseKey(null); setSelectedLicenseKey(null);
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p>{t("licenseQuestionRemove")}</p> <p>{t("licenseQuestionRemove")}</p>
<p> <p>
<b>{t("licenseMessageRemove")}</b> <b>{t("licenseMessageRemove")}</b>
@@ -360,7 +360,8 @@ export default function LicensePage() {
<div className="space-y-2 text-green-500"> <div className="space-y-2 text-green-500">
<div className="text-2xl flex items-center gap-2"> <div className="text-2xl flex items-center gap-2">
<Check /> <Check />
{t("licensed")} {t("licensed") +
`${licenseStatus?.tier === "personal" ? ` (${t("personalUseOnly")})` : ""}`}
</div> </div>
</div> </div>
) : ( ) : (

View File

@@ -243,7 +243,7 @@ export default function UsersTable({ users }: Props) {
setSelected(null); setSelected(null);
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p>{t("userQuestionRemove")}</p> <p>{t("userQuestionRemove")}</p>
<p>{t("userMessageRemove")}</p> <p>{t("userMessageRemove")}</p>

View File

@@ -23,6 +23,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
const t = await getTranslations(); const t = await getTranslations();
let hideFooter = false; let hideFooter = false;
let licenseStatus: GetLicenseStatusResponse | null = null;
if (build == "enterprise") { if (build == "enterprise") {
const licenseStatusRes = await cache( const licenseStatusRes = await cache(
async () => async () =>
@@ -30,10 +31,12 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
"/license/status" "/license/status"
) )
)(); )();
licenseStatus = licenseStatusRes.data.data;
if ( if (
env.branding.hideAuthLayoutFooter && env.branding.hideAuthLayoutFooter &&
licenseStatusRes.data.data.isHostLicensed && licenseStatusRes.data.data.isHostLicensed &&
licenseStatusRes.data.data.isLicenseValid licenseStatusRes.data.data.isLicenseValid &&
licenseStatusRes.data.data.tier !== "personal"
) { ) {
hideFooter = true; hideFooter = true;
} }
@@ -83,6 +86,23 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
? t("enterpriseEdition") ? t("enterpriseEdition")
: t("pangolinCloud")} : t("pangolinCloud")}
</span> </span>
{build === "enterprise" &&
licenseStatus?.isHostLicensed &&
licenseStatus?.isLicenseValid &&
licenseStatus?.tier === "personal" ? (
<>
<Separator orientation="vertical" />
<span>{t("personalUseOnly")}</span>
</>
) : null}
{build === "enterprise" &&
(!licenseStatus?.isHostLicensed ||
!licenseStatus?.isLicenseValid) ? (
<>
<Separator orientation="vertical" />
<span>{t("unlicensed")}</span>
</>
) : null}
{build === "saas" && ( {build === "saas" && (
<> <>
<Separator orientation="vertical" /> <Separator orientation="vertical" />

View File

@@ -196,7 +196,7 @@ export default function IdpTable({ idps }: Props) {
setSelectedIdp(null); setSelectedIdp(null);
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p> <p>
{t("idpQuestionRemove", { {t("idpQuestionRemove", {
name: selectedIdp.name name: selectedIdp.name

View File

@@ -269,7 +269,7 @@ export default function UsersTable({ users }: Props) {
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{r.type !== "internal" && ( {r.type === "internal" && (
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
generatePasswordResetCode(r.id); generatePasswordResetCode(r.id);
@@ -313,7 +313,7 @@ export default function UsersTable({ users }: Props) {
setSelected(null); setSelected(null);
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p> <p>
{t("userQuestionRemove", { {t("userQuestionRemove", {
selectedUser: selectedUser:

View File

@@ -182,7 +182,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
setSelected(null); setSelected(null);
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p>{t("apiKeysQuestionRemove")}</p> <p>{t("apiKeysQuestionRemove")}</p>
<p>{t("apiKeysMessageRemove")}</p> <p>{t("apiKeysMessageRemove")}</p>

View File

@@ -41,6 +41,9 @@ export type InternalResourceRow = {
// destinationPort: number | null; // destinationPort: number | null;
alias: string | null; alias: string | null;
niceId: string; niceId: string;
tcpPortRangeString: string | null;
udpPortRangeString: string | null;
disableIcmp: boolean;
}; };
type ClientResourcesTableProps = { type ClientResourcesTableProps = {
@@ -284,7 +287,7 @@ export default function ClientResourcesTable({
setSelectedInternalResource(null); setSelectedInternalResource(null);
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p>{t("resourceQuestionRemove")}</p> <p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p> <p>{t("resourceMessageRemove")}</p>
</div> </div>

View File

@@ -42,15 +42,14 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { Switch } from "@app/components/ui/switch";
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 { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { cn } from "@app/lib/cn"; import { cn } from "@app/lib/cn";
import { orgQueries } from "@app/lib/queries"; import { orgQueries } from "@app/lib/queries";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { ListClientsResponse } from "@server/routers/client/listClients";
import { ListSitesResponse } from "@server/routers/site"; import { ListSitesResponse } from "@server/routers/site";
import { ListUsersResponse } from "@server/routers/user";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { AxiosResponse } from "axios"; import { AxiosResponse } from "axios";
@@ -59,6 +58,82 @@ import { useTranslations } from "next-intl";
import { useEffect, useState } from "react"; 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";
// import { InfoPopup } from "@app/components/ui/info-popup";
// Helper to validate port range string format
const isValidPortRangeString = (val: string | undefined | null): boolean => {
if (!val || val.trim() === "" || val.trim() === "*") {
return true;
}
const parts = val.split(",").map((p) => p.trim());
for (const part of parts) {
if (part === "") {
return false;
}
if (part.includes("-")) {
const [start, end] = part.split("-").map((p) => p.trim());
if (!start || !end) {
return false;
}
const startPort = parseInt(start, 10);
const endPort = parseInt(end, 10);
if (isNaN(startPort) || isNaN(endPort)) {
return false;
}
if (startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535) {
return false;
}
if (startPort > endPort) {
return false;
}
} else {
const port = parseInt(part, 10);
if (isNaN(port)) {
return false;
}
if (port < 1 || port > 65535) {
return false;
}
}
}
return true;
};
// Port range string schema for client-side validation
const portRangeStringSchema = z
.string()
.optional()
.nullable()
.refine(
(val) => isValidPortRangeString(val),
{
message:
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.'
}
);
// Helper to determine the port mode from a port range string
type PortMode = "all" | "blocked" | "custom";
const getPortModeFromString = (val: string | undefined | null): PortMode => {
if (val === "*") return "all";
if (!val || val.trim() === "") return "blocked";
return "custom";
};
// Helper to get the port string for API from mode and custom value
const getPortStringFromMode = (mode: PortMode, customValue: string): string | undefined => {
if (mode === "all") return "*";
if (mode === "blocked") return "";
return customValue;
};
type Site = ListSitesResponse["sites"][0]; type Site = ListSitesResponse["sites"][0];
@@ -103,6 +178,9 @@ export default function CreateInternalResourceDialog({
// .max(65535, t("createInternalResourceDialogDestinationPortMax")) // .max(65535, t("createInternalResourceDialogDestinationPortMax"))
// .nullish(), // .nullish(),
alias: z.string().nullish(), alias: z.string().nullish(),
tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional(),
roles: z roles: z
.array( .array(
z.object({ z.object({
@@ -209,6 +287,12 @@ export default function CreateInternalResourceDialog({
number | null number | null
>(null); >(null);
// Port restriction UI state - default to "all" (*) for new resources
const [tcpPortMode, setTcpPortMode] = useState<PortMode>("all");
const [udpPortMode, setUdpPortMode] = useState<PortMode>("all");
const [tcpCustomPorts, setTcpCustomPorts] = useState<string>("");
const [udpCustomPorts, setUdpCustomPorts] = useState<string>("");
const availableSites = sites.filter( const availableSites = sites.filter(
(site) => site.type === "newt" && site.subnet (site) => site.type === "newt" && site.subnet
); );
@@ -224,6 +308,9 @@ export default function CreateInternalResourceDialog({
destination: "", destination: "",
// destinationPort: undefined, // destinationPort: undefined,
alias: "", alias: "",
tcpPortRangeString: "*",
udpPortRangeString: "*",
disableIcmp: false,
roles: [], roles: [],
users: [], users: [],
clients: [] clients: []
@@ -232,6 +319,17 @@ export default function CreateInternalResourceDialog({
const mode = form.watch("mode"); const mode = form.watch("mode");
// Update form values when port mode or custom ports change
useEffect(() => {
const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts);
form.setValue("tcpPortRangeString", tcpValue);
}, [tcpPortMode, tcpCustomPorts, form]);
useEffect(() => {
const udpValue = getPortStringFromMode(udpPortMode, udpCustomPorts);
form.setValue("udpPortRangeString", udpValue);
}, [udpPortMode, udpCustomPorts, form]);
// Helper function to check if destination contains letters (hostname vs IP) // Helper function to check if destination contains letters (hostname vs IP)
const isHostname = (destination: string): boolean => { const isHostname = (destination: string): boolean => {
return /[a-zA-Z]/.test(destination); return /[a-zA-Z]/.test(destination);
@@ -258,10 +356,18 @@ export default function CreateInternalResourceDialog({
destination: "", destination: "",
// destinationPort: undefined, // destinationPort: undefined,
alias: "", alias: "",
tcpPortRangeString: "*",
udpPortRangeString: "*",
disableIcmp: false,
roles: [], roles: [],
users: [], users: [],
clients: [] clients: []
}); });
// Reset port mode state
setTcpPortMode("all");
setUdpPortMode("all");
setTcpCustomPorts("");
setUdpCustomPorts("");
} }
}, [open]); }, [open]);
@@ -304,6 +410,9 @@ export default function CreateInternalResourceDialog({
data.alias.trim() data.alias.trim()
? data.alias ? data.alias
: undefined, : undefined,
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false,
roleIds: data.roles roleIds: data.roles
? data.roles.map((r) => parseInt(r.id)) ? data.roles.map((r) => parseInt(r.id))
: [], : [],
@@ -727,6 +836,163 @@ export default function CreateInternalResourceDialog({
</div> </div>
)} )}
{/* Port Restrictions Section */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("portRestrictions")}
</h3>
<div className="space-y-4">
{/* TCP Ports */}
<FormField
control={form.control}
name="tcpPortRangeString"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
TCP
</FormLabel>
{/*<InfoPopup
info={t("tcpPortsDescription")}
/>*/}
<Select
value={tcpPortMode}
onValueChange={(value: PortMode) => {
setTcpPortMode(value);
}}
>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("allPorts")}
</SelectItem>
<SelectItem value="blocked">
{t("blocked")}
</SelectItem>
<SelectItem value="custom">
{t("custom")}
</SelectItem>
</SelectContent>
</Select>
{tcpPortMode === "custom" ? (
<FormControl>
<Input
placeholder="80,443,8000-9000"
value={tcpCustomPorts}
onChange={(e) =>
setTcpCustomPorts(e.target.value)
}
className="flex-1"
/>
</FormControl>
) : (
<Input
disabled
placeholder={
tcpPortMode === "all"
? t("allPortsAllowed")
: t("allPortsBlocked")
}
className="flex-1"
/>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
{/* UDP Ports */}
<FormField
control={form.control}
name="udpPortRangeString"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
UDP
</FormLabel>
{/*<InfoPopup
info={t("udpPortsDescription")}
/>*/}
<Select
value={udpPortMode}
onValueChange={(value: PortMode) => {
setUdpPortMode(value);
}}
>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("allPorts")}
</SelectItem>
<SelectItem value="blocked">
{t("blocked")}
</SelectItem>
<SelectItem value="custom">
{t("custom")}
</SelectItem>
</SelectContent>
</Select>
{udpPortMode === "custom" ? (
<FormControl>
<Input
placeholder="53,123,500-600"
value={udpCustomPorts}
onChange={(e) =>
setUdpCustomPorts(e.target.value)
}
className="flex-1"
/>
</FormControl>
) : (
<Input
disabled
placeholder={
udpPortMode === "all"
? t("allPortsAllowed")
: t("allPortsBlocked")
}
className="flex-1"
/>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
{/* ICMP Toggle */}
<FormField
control={form.control}
name="disableIcmp"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
ICMP
</FormLabel>
<FormControl>
<Switch
checked={!field.value}
onCheckedChange={(checked) => field.onChange(!checked)}
/>
</FormControl>
<span className="text-sm text-muted-foreground">
{field.value ? t("blocked") : t("allowed")}
</span>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Access Control Section */} {/* Access Control Section */}
<div> <div>
<h3 className="text-lg font-semibold mb-4"> <h3 className="text-lg font-semibold mb-4">

View File

@@ -131,7 +131,7 @@ const CredenzaHeader = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaHeader = isDesktop ? DialogHeader : SheetHeader; const CredenzaHeader = isDesktop ? DialogHeader : SheetHeader;
return ( return (
<CredenzaHeader className={cn("-mx-6 px-6 pb-6 border-b border-border", className)} {...props}> <CredenzaHeader className={cn("-mx-6 px-6", className)} {...props}>
{children} {children}
</CredenzaHeader> </CredenzaHeader>
); );
@@ -177,7 +177,13 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter; const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter;
return ( return (
<CredenzaFooter className={cn("mt-8 md:mt-0 -mx-6 px-6 pt-6 border-t border-border", className)} {...props}> <CredenzaFooter
className={cn(
"mt-8 md:mt-0 -mx-6 px-6 pt-6 border-t border-border",
className
)}
{...props}
>
{children} {children}
</CredenzaFooter> </CredenzaFooter>
); );

View File

@@ -24,6 +24,8 @@ interface DataTablePaginationProps<TData> {
isServerPagination?: boolean; isServerPagination?: boolean;
isLoading?: boolean; isLoading?: boolean;
disabled?: boolean; disabled?: boolean;
pageSize?: number;
pageIndex?: number;
} }
export function DataTablePagination<TData>({ export function DataTablePagination<TData>({
@@ -33,10 +35,26 @@ export function DataTablePagination<TData>({
totalCount, totalCount,
isServerPagination = false, isServerPagination = false,
isLoading = false, isLoading = false,
disabled = false disabled = false,
pageSize: controlledPageSize,
pageIndex: controlledPageIndex
}: DataTablePaginationProps<TData>) { }: DataTablePaginationProps<TData>) {
const t = useTranslations(); const t = useTranslations();
// Use controlled values if provided, otherwise fall back to table state
const pageSize = controlledPageSize ?? table.getState().pagination.pageSize;
const pageIndex =
controlledPageIndex ?? table.getState().pagination.pageIndex;
// Calculate page boundaries based on controlled state
// For server-side pagination, use totalCount if available for accurate page count
const pageCount =
isServerPagination && totalCount !== undefined
? Math.ceil(totalCount / pageSize)
: table.getPageCount();
const canNextPage = pageIndex < pageCount - 1;
const canPreviousPage = pageIndex > 0;
const handlePageSizeChange = (value: string) => { const handlePageSizeChange = (value: string) => {
const newPageSize = Number(value); const newPageSize = Number(value);
table.setPageSize(newPageSize); table.setPageSize(newPageSize);
@@ -51,7 +69,7 @@ export function DataTablePagination<TData>({
action: "first" | "previous" | "next" | "last" action: "first" | "previous" | "next" | "last"
) => { ) => {
if (isServerPagination && onPageChange) { if (isServerPagination && onPageChange) {
const currentPage = table.getState().pagination.pageIndex; const currentPage = pageIndex;
const pageCount = table.getPageCount(); const pageCount = table.getPageCount();
let newPage: number; let newPage: number;
@@ -77,18 +95,24 @@ export function DataTablePagination<TData>({
} }
} else { } else {
// Use table's built-in navigation for client-side pagination // Use table's built-in navigation for client-side pagination
// But add bounds checking to prevent going beyond page boundaries
const pageCount = table.getPageCount();
switch (action) { switch (action) {
case "first": case "first":
table.setPageIndex(0); table.setPageIndex(0);
break; break;
case "previous": case "previous":
table.previousPage(); if (pageIndex > 0) {
table.previousPage();
}
break; break;
case "next": case "next":
table.nextPage(); if (pageIndex < pageCount - 1) {
table.nextPage();
}
break; break;
case "last": case "last":
table.setPageIndex(table.getPageCount() - 1); table.setPageIndex(Math.max(0, pageCount - 1));
break; break;
} }
} }
@@ -98,14 +122,12 @@ export function DataTablePagination<TData>({
<div className="flex items-center justify-between text-muted-foreground"> <div className="flex items-center justify-between text-muted-foreground">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Select <Select
value={`${table.getState().pagination.pageSize}`} value={`${pageSize}`}
onValueChange={handlePageSizeChange} onValueChange={handlePageSizeChange}
disabled={disabled} disabled={disabled}
> >
<SelectTrigger className="h-8 w-[73px]" disabled={disabled}> <SelectTrigger className="h-8 w-[73px]" disabled={disabled}>
<SelectValue <SelectValue placeholder={pageSize} />
placeholder={table.getState().pagination.pageSize}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent side="bottom"> <SelectContent side="bottom">
{[10, 20, 30, 40, 50, 100].map((pageSize) => ( {[10, 20, 30, 40, 50, 100].map((pageSize) => (
@@ -121,16 +143,11 @@ export function DataTablePagination<TData>({
<div className="flex items-center justify-center text-sm font-medium"> <div className="flex items-center justify-center text-sm font-medium">
{isServerPagination && totalCount !== undefined {isServerPagination && totalCount !== undefined
? t("paginator", { ? t("paginator", {
current: current: pageIndex + 1,
table.getState().pagination.pageIndex + 1, last: Math.ceil(totalCount / pageSize)
last: Math.ceil(
totalCount /
table.getState().pagination.pageSize
)
}) })
: t("paginator", { : t("paginator", {
current: current: pageIndex + 1,
table.getState().pagination.pageIndex + 1,
last: table.getPageCount() last: table.getPageCount()
})} })}
</div> </div>
@@ -139,9 +156,7 @@ export function DataTablePagination<TData>({
variant="outline" variant="outline"
className="hidden h-8 w-8 p-0 lg:flex" className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => handlePageNavigation("first")} onClick={() => handlePageNavigation("first")}
disabled={ disabled={!canPreviousPage || isLoading || disabled}
!table.getCanPreviousPage() || isLoading || disabled
}
> >
<span className="sr-only">{t("paginatorToFirst")}</span> <span className="sr-only">{t("paginatorToFirst")}</span>
<DoubleArrowLeftIcon className="h-4 w-4" /> <DoubleArrowLeftIcon className="h-4 w-4" />
@@ -150,9 +165,7 @@ export function DataTablePagination<TData>({
variant="outline" variant="outline"
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
onClick={() => handlePageNavigation("previous")} onClick={() => handlePageNavigation("previous")}
disabled={ disabled={!canPreviousPage || isLoading || disabled}
!table.getCanPreviousPage() || isLoading || disabled
}
> >
<span className="sr-only"> <span className="sr-only">
{t("paginatorToPrevious")} {t("paginatorToPrevious")}
@@ -163,9 +176,7 @@ export function DataTablePagination<TData>({
variant="outline" variant="outline"
className="h-8 w-8 p-0" className="h-8 w-8 p-0"
onClick={() => handlePageNavigation("next")} onClick={() => handlePageNavigation("next")}
disabled={ disabled={!canNextPage || isLoading || disabled}
!table.getCanNextPage() || isLoading || disabled
}
> >
<span className="sr-only">{t("paginatorToNext")}</span> <span className="sr-only">{t("paginatorToNext")}</span>
<ChevronRightIcon className="h-4 w-4" /> <ChevronRightIcon className="h-4 w-4" />
@@ -174,9 +185,7 @@ export function DataTablePagination<TData>({
variant="outline" variant="outline"
className="hidden h-8 w-8 p-0 lg:flex" className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => handlePageNavigation("last")} onClick={() => handlePageNavigation("last")}
disabled={ disabled={!canNextPage || isLoading || disabled}
!table.getCanNextPage() || isLoading || disabled
}
> >
<span className="sr-only">{t("paginatorToLast")}</span> <span className="sr-only">{t("paginatorToLast")}</span>
<DoubleArrowRightIcon className="h-4 w-4" /> <DoubleArrowRightIcon className="h-4 w-4" />

View File

@@ -304,7 +304,7 @@ export default function DomainsTable({ domains, orgId }: Props) {
setSelectedDomain(null); setSelectedDomain(null);
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p>{t("domainQuestionRemove")}</p> <p>{t("domainQuestionRemove")}</p>
<p>{t("domainMessageRemove")}</p> <p>{t("domainMessageRemove")}</p>
</div> </div>

View File

@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Button } from "@app/components/ui/button"; import { Button } from "@app/components/ui/button";
import { Input } from "@app/components/ui/input"; import { Input } from "@app/components/ui/input";
import { import {
@@ -10,6 +10,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "@app/components/ui/select"; } from "@app/components/ui/select";
import { Switch } from "@app/components/ui/switch";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
@@ -36,17 +37,86 @@ import { toast } from "@app/hooks/useToast";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { createApiClient, formatAxiosError } from "@app/lib/api"; import { createApiClient, formatAxiosError } from "@app/lib/api";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { useEnvContext } from "@app/hooks/useEnvContext";
import { ListRolesResponse } from "@server/routers/role";
import { ListUsersResponse } from "@server/routers/user";
import { ListSiteResourceRolesResponse } from "@server/routers/siteResource/listSiteResourceRoles";
import { ListSiteResourceUsersResponse } from "@server/routers/siteResource/listSiteResourceUsers";
import { ListSiteResourceClientsResponse } from "@server/routers/siteResource/listSiteResourceClients";
import { ListClientsResponse } from "@server/routers/client/listClients";
import { Tag, TagInput } from "@app/components/tags/tag-input"; import { Tag, TagInput } from "@app/components/tags/tag-input";
import { AxiosResponse } from "axios";
import { UserType } from "@server/types/UserTypes"; import { UserType } from "@server/types/UserTypes";
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query"; import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
import { orgQueries, resourceQueries } from "@app/lib/queries"; import { orgQueries, resourceQueries } from "@app/lib/queries";
// import { InfoPopup } from "@app/components/ui/info-popup";
// Helper to validate port range string format
const isValidPortRangeString = (val: string | undefined | null): boolean => {
if (!val || val.trim() === "" || val.trim() === "*") {
return true;
}
const parts = val.split(",").map((p) => p.trim());
for (const part of parts) {
if (part === "") {
return false;
}
if (part.includes("-")) {
const [start, end] = part.split("-").map((p) => p.trim());
if (!start || !end) {
return false;
}
const startPort = parseInt(start, 10);
const endPort = parseInt(end, 10);
if (isNaN(startPort) || isNaN(endPort)) {
return false;
}
if (startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535) {
return false;
}
if (startPort > endPort) {
return false;
}
} else {
const port = parseInt(part, 10);
if (isNaN(port)) {
return false;
}
if (port < 1 || port > 65535) {
return false;
}
}
}
return true;
};
// Port range string schema for client-side validation
const portRangeStringSchema = z
.string()
.optional()
.nullable()
.refine(
(val) => isValidPortRangeString(val),
{
message:
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.'
}
);
// Helper to determine the port mode from a port range string
type PortMode = "all" | "blocked" | "custom";
const getPortModeFromString = (val: string | undefined | null): PortMode => {
if (val === "*") return "all";
if (!val || val.trim() === "") return "blocked";
return "custom";
};
// Helper to get the port string for API from mode and custom value
const getPortStringFromMode = (mode: PortMode, customValue: string): string | undefined => {
if (mode === "all") return "*";
if (mode === "blocked") return "";
return customValue;
};
type InternalResourceData = { type InternalResourceData = {
id: number; id: number;
@@ -61,6 +131,9 @@ type InternalResourceData = {
destination: string; destination: string;
// destinationPort?: number | null; // destinationPort?: number | null;
alias?: string | null; alias?: string | null;
tcpPortRangeString?: string | null;
udpPortRangeString?: string | null;
disableIcmp?: boolean;
}; };
type EditInternalResourceDialogProps = { type EditInternalResourceDialogProps = {
@@ -94,6 +167,9 @@ export default function EditInternalResourceDialog({
destination: z.string().min(1), destination: z.string().min(1),
// destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(), // destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
alias: z.string().nullish(), alias: z.string().nullish(),
tcpPortRangeString: portRangeStringSchema,
udpPortRangeString: portRangeStringSchema,
disableIcmp: z.boolean().optional(),
roles: z roles: z
.array( .array(
z.object({ z.object({
@@ -255,6 +331,24 @@ export default function EditInternalResourceDialog({
number | null number | null
>(null); >(null);
// Port restriction UI state
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(
getPortModeFromString(resource.tcpPortRangeString)
);
const [udpPortMode, setUdpPortMode] = useState<PortMode>(
getPortModeFromString(resource.udpPortRangeString)
);
const [tcpCustomPorts, setTcpCustomPorts] = useState<string>(
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
? resource.tcpPortRangeString
: ""
);
const [udpCustomPorts, setUdpCustomPorts] = useState<string>(
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
? resource.udpPortRangeString
: ""
);
const form = useForm<FormData>({ const form = useForm<FormData>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
@@ -265,6 +359,9 @@ export default function EditInternalResourceDialog({
destination: resource.destination || "", destination: resource.destination || "",
// destinationPort: resource.destinationPort ?? undefined, // destinationPort: resource.destinationPort ?? undefined,
alias: resource.alias ?? null, alias: resource.alias ?? null,
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
udpPortRangeString: resource.udpPortRangeString ?? "*",
disableIcmp: resource.disableIcmp ?? false,
roles: [], roles: [],
users: [], users: [],
clients: [] clients: []
@@ -273,6 +370,17 @@ export default function EditInternalResourceDialog({
const mode = form.watch("mode"); const mode = form.watch("mode");
// Update form values when port mode or custom ports change
useEffect(() => {
const tcpValue = getPortStringFromMode(tcpPortMode, tcpCustomPorts);
form.setValue("tcpPortRangeString", tcpValue);
}, [tcpPortMode, tcpCustomPorts, form]);
useEffect(() => {
const udpValue = getPortStringFromMode(udpPortMode, udpCustomPorts);
form.setValue("udpPortRangeString", udpValue);
}, [udpPortMode, udpCustomPorts, form]);
// Helper function to check if destination contains letters (hostname vs IP) // Helper function to check if destination contains letters (hostname vs IP)
const isHostname = (destination: string): boolean => { const isHostname = (destination: string): boolean => {
return /[a-zA-Z]/.test(destination); return /[a-zA-Z]/.test(destination);
@@ -327,6 +435,9 @@ export default function EditInternalResourceDialog({
data.alias.trim() data.alias.trim()
? data.alias ? data.alias
: null, : null,
tcpPortRangeString: data.tcpPortRangeString,
udpPortRangeString: data.udpPortRangeString,
disableIcmp: data.disableIcmp ?? false,
roleIds: (data.roles || []).map((r) => parseInt(r.id)), roleIds: (data.roles || []).map((r) => parseInt(r.id)),
userIds: (data.users || []).map((u) => u.id), userIds: (data.users || []).map((u) => u.id),
clientIds: (data.clients || []).map((c) => parseInt(c.id)) clientIds: (data.clients || []).map((c) => parseInt(c.id))
@@ -396,10 +507,26 @@ export default function EditInternalResourceDialog({
mode: resource.mode || "host", mode: resource.mode || "host",
destination: resource.destination || "", destination: resource.destination || "",
alias: resource.alias ?? null, alias: resource.alias ?? null,
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
udpPortRangeString: resource.udpPortRangeString ?? "*",
disableIcmp: resource.disableIcmp ?? false,
roles: [], roles: [],
users: [], users: [],
clients: [] clients: []
}); });
// Reset port mode state
setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString));
setUdpPortMode(getPortModeFromString(resource.udpPortRangeString));
setTcpCustomPorts(
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
? resource.tcpPortRangeString
: ""
);
setUdpCustomPorts(
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
? resource.udpPortRangeString
: ""
);
previousResourceId.current = resource.id; previousResourceId.current = resource.id;
} }
@@ -438,10 +565,26 @@ export default function EditInternalResourceDialog({
destination: resource.destination || "", destination: resource.destination || "",
// destinationPort: resource.destinationPort ?? undefined, // destinationPort: resource.destinationPort ?? undefined,
alias: resource.alias ?? null, alias: resource.alias ?? null,
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
udpPortRangeString: resource.udpPortRangeString ?? "*",
disableIcmp: resource.disableIcmp ?? false,
roles: [], roles: [],
users: [], users: [],
clients: [] clients: []
}); });
// Reset port mode state
setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString));
setUdpPortMode(getPortModeFromString(resource.udpPortRangeString));
setTcpCustomPorts(
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
? resource.tcpPortRangeString
: ""
);
setUdpCustomPorts(
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
? resource.udpPortRangeString
: ""
);
// Reset previous resource ID to ensure clean state on next open // Reset previous resource ID to ensure clean state on next open
previousResourceId.current = null; previousResourceId.current = null;
} }
@@ -674,6 +817,163 @@ export default function EditInternalResourceDialog({
</div> </div>
)} )}
{/* Port Restrictions Section */}
<div>
<h3 className="text-lg font-semibold mb-4">
{t("portRestrictions")}
</h3>
<div className="space-y-4">
{/* TCP Ports */}
<FormField
control={form.control}
name="tcpPortRangeString"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
TCP
</FormLabel>
{/*<InfoPopup
info={t("tcpPortsDescription")}
/>*/}
<Select
value={tcpPortMode}
onValueChange={(value: PortMode) => {
setTcpPortMode(value);
}}
>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("allPorts")}
</SelectItem>
<SelectItem value="blocked">
{t("blocked")}
</SelectItem>
<SelectItem value="custom">
{t("custom")}
</SelectItem>
</SelectContent>
</Select>
{tcpPortMode === "custom" ? (
<FormControl>
<Input
placeholder="80,443,8000-9000"
value={tcpCustomPorts}
onChange={(e) =>
setTcpCustomPorts(e.target.value)
}
className="flex-1"
/>
</FormControl>
) : (
<Input
disabled
placeholder={
tcpPortMode === "all"
? t("allPortsAllowed")
: t("allPortsBlocked")
}
className="flex-1"
/>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
{/* UDP Ports */}
<FormField
control={form.control}
name="udpPortRangeString"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
UDP
</FormLabel>
{/*<InfoPopup
info={t("udpPortsDescription")}
/>*/}
<Select
value={udpPortMode}
onValueChange={(value: PortMode) => {
setUdpPortMode(value);
}}
>
<SelectTrigger className="w-[110px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("allPorts")}
</SelectItem>
<SelectItem value="blocked">
{t("blocked")}
</SelectItem>
<SelectItem value="custom">
{t("custom")}
</SelectItem>
</SelectContent>
</Select>
{udpPortMode === "custom" ? (
<FormControl>
<Input
placeholder="53,123,500-600"
value={udpCustomPorts}
onChange={(e) =>
setUdpCustomPorts(e.target.value)
}
className="flex-1"
/>
</FormControl>
) : (
<Input
disabled
placeholder={
udpPortMode === "all"
? t("allPortsAllowed")
: t("allPortsBlocked")
}
className="flex-1"
/>
)}
</div>
<FormMessage />
</FormItem>
)}
/>
{/* ICMP Toggle */}
<FormField
control={form.control}
name="disableIcmp"
render={({ field }) => (
<FormItem>
<div className="flex items-center gap-2">
<FormLabel className="min-w-10">
ICMP
</FormLabel>
<FormControl>
<Switch
checked={!field.value}
onCheckedChange={(checked) => field.onChange(!checked)}
/>
</FormControl>
<span className="text-sm text-muted-foreground">
{field.value ? t("blocked") : t("allowed")}
</span>
</div>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
{/* Access Control Section */} {/* Access Control Section */}
<div> <div>
<h3 className="text-lg font-semibold mb-4"> <h3 className="text-lg font-semibold mb-4">

View File

@@ -182,7 +182,7 @@ export default function InvitationsTable({
setSelectedInvitation(null); setSelectedInvitation(null);
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p>{t("inviteQuestionRemove")}</p> <p>{t("inviteQuestionRemove")}</p>
<p>{t("inviteMessageRemove")}</p> <p>{t("inviteMessageRemove")}</p>
</div> </div>

View File

@@ -25,6 +25,7 @@ import { useEffect, useState } from "react";
import { FaGithub } from "react-icons/fa"; import { FaGithub } from "react-icons/fa";
import SidebarLicenseButton from "./SidebarLicenseButton"; import SidebarLicenseButton from "./SidebarLicenseButton";
import { SidebarSupportButton } from "./SidebarSupportButton"; import { SidebarSupportButton } from "./SidebarSupportButton";
import { is } from "drizzle-orm";
const ProductUpdates = dynamic(() => import("./ProductUpdates"), { const ProductUpdates = dynamic(() => import("./ProductUpdates"), {
ssr: false ssr: false
@@ -52,7 +53,7 @@ export function LayoutSidebar({
const pathname = usePathname(); const pathname = usePathname();
const isAdminPage = pathname?.startsWith("/admin"); const isAdminPage = pathname?.startsWith("/admin");
const { user } = useUserContext(); const { user } = useUserContext();
const { isUnlocked } = useLicenseStatusContext(); const { isUnlocked, licenseStatus } = useLicenseStatusContext();
const { env } = useEnvContext(); const { env } = useEnvContext();
const t = useTranslations(); const t = useTranslations();
@@ -226,6 +227,18 @@ export function LayoutSidebar({
<FaGithub size={12} /> <FaGithub size={12} />
</Link> </Link>
</div> </div>
{build === "enterprise" &&
isUnlocked() &&
licenseStatus?.tier === "personal" ? (
<div className="text-xs text-muted-foreground text-center">
{t("personalUseOnly")}
</div>
) : null}
{build === "enterprise" && !isUnlocked() ? (
<div className="text-xs text-muted-foreground text-center">
{t("unlicensed")}
</div>
) : null}
{env?.app?.version && ( {env?.app?.version && (
<div className="text-xs text-muted-foreground text-center"> <div className="text-xs text-muted-foreground text-center">
<Link <Link

View File

@@ -1,22 +1,27 @@
"use client"; "use client";
import { useEnvContext } from "@app/hooks/useEnvContext"; import { cn } from "@app/lib/cn";
import { createApiClient } from "@app/lib/api";
import { import {
logAnalyticsFiltersSchema, logAnalyticsFiltersSchema,
logQueries, logQueries,
resourceQueries resourceQueries
} from "@app/lib/queries"; } from "@app/lib/queries";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useState } from "react";
import { Card, CardContent, CardHeader } from "./ui/card";
import { LoaderIcon, RefreshCw, XIcon } from "lucide-react"; import { LoaderIcon, RefreshCw, XIcon } from "lucide-react";
import { useTranslations } from "next-intl";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { DateRangePicker, type DateTimeValue } from "./DateTimePicker"; import { DateRangePicker, type DateTimeValue } from "./DateTimePicker";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { cn } from "@app/lib/cn"; import { Card, CardContent, CardHeader } from "./ui/card";
import { useTranslations } from "next-intl";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "./InfoSection";
import { Label } from "./ui/label";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -24,23 +29,10 @@ import {
SelectTrigger, SelectTrigger,
SelectValue SelectValue
} from "./ui/select"; } from "./ui/select";
import { Label } from "./ui/label";
import { Separator } from "./ui/separator"; import { Separator } from "./ui/separator";
import {
InfoSection,
InfoSectionContent,
InfoSections,
InfoSectionTitle
} from "./InfoSection";
import { WorldMap } from "./WorldMap"; import { WorldMap } from "./WorldMap";
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
import { import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "./ui/tooltip";
import { import {
ChartContainer, ChartContainer,
ChartLegend, ChartLegend,
@@ -49,7 +41,13 @@ import {
ChartTooltipContent, ChartTooltipContent,
type ChartConfig type ChartConfig
} from "./ui/chart"; } from "./ui/chart";
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "./ui/tooltip";
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
export type AnalyticsContentProps = { export type AnalyticsContentProps = {
orgId: string; orgId: string;
@@ -67,17 +65,18 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
const isEmptySearchParams = const isEmptySearchParams =
!filters.resourceId && !filters.timeStart && !filters.timeEnd; !filters.resourceId && !filters.timeStart && !filters.timeEnd;
const env = useEnvContext();
const [api] = useState(() => createApiClient(env));
const router = useRouter(); const router = useRouter();
console.log({ filters });
const dateRange = { const dateRange = {
startDate: filters.timeStart ? new Date(filters.timeStart) : undefined, startDate: filters.timeStart
endDate: filters.timeEnd ? new Date(filters.timeEnd) : undefined ? new Date(filters.timeStart)
: getSevenDaysAgo(),
endDate: filters.timeEnd ? new Date(filters.timeEnd) : new Date()
}; };
const { data: resources = [], isFetching: isFetchingResources } = useQuery( const { data: resources = [], isFetching: isFetchingResources } = useQuery(
resourceQueries.listNamesPerOrg(props.orgId, api) resourceQueries.listNamesPerOrg(props.orgId)
); );
const { const {
@@ -88,7 +87,6 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
} = useQuery( } = useQuery(
logQueries.requestAnalytics({ logQueries.requestAnalytics({
orgId: props.orgId, orgId: props.orgId,
api,
filters filters
}) })
); );

View File

@@ -1,16 +1,5 @@
"use client"; "use client";
import {
ColumnDef,
flexRender,
getCoreRowModel,
useReactTable,
getPaginationRowModel,
SortingState,
getSortedRowModel,
ColumnFiltersState,
getFilteredRowModel
} from "@tanstack/react-table";
import { import {
Table, Table,
TableBody, TableBody,
@@ -19,29 +8,36 @@ import {
TableHeader, TableHeader,
TableRow TableRow
} from "@/components/ui/table"; } from "@/components/ui/table";
import { Button } from "@app/components/ui/button";
import { useEffect, useMemo, useState } from "react";
import { Input } from "@app/components/ui/input";
import { DataTablePagination } from "@app/components/DataTablePagination"; import { DataTablePagination } from "@app/components/DataTablePagination";
import {
Plus,
Search,
RefreshCw,
Filter,
X,
Download,
ChevronRight,
ChevronDown
} from "lucide-react";
import {
Card,
CardContent,
CardHeader,
CardTitle
} from "@app/components/ui/card";
import { Tabs, TabsList, TabsTrigger } from "@app/components/ui/tabs";
import { useTranslations } from "next-intl";
import { DateRangePicker, DateTimeValue } from "@app/components/DateTimePicker"; import { DateRangePicker, DateTimeValue } from "@app/components/DateTimePicker";
import { Button } from "@app/components/ui/button";
import { Card, CardContent, CardHeader } from "@app/components/ui/card";
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable
} from "@tanstack/react-table";
import {
ChevronDown,
ChevronRight,
Download,
Loader,
RefreshCw
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useState, useEffect, useMemo } from "react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "./ui/tooltip";
const STORAGE_KEYS = { const STORAGE_KEYS = {
PAGE_SIZE: "datatable-page-size", PAGE_SIZE: "datatable-page-size",
@@ -400,15 +396,28 @@ export function LogDataTable<TData, TValue>({
</Button> </Button>
)} )}
{onExport && ( {onExport && (
<Button <TooltipProvider>
onClick={() => !disabled && onExport()} <Tooltip>
disabled={isExporting || disabled} <TooltipTrigger asChild>
> <Button
<Download onClick={() =>
className={`mr-2 h-4 w-4 ${isExporting ? "animate-spin" : ""}`} !disabled && onExport()
/> }
{t("exportCsv")} disabled={isExporting || disabled}
</Button> >
{isExporting ? (
<Loader className="mr-2 size-4 animate-spin" />
) : (
<Download className="mr-2 size-4" />
)}
{t("exportCsv")}
</Button>
</TooltipTrigger>
<TooltipContent>
{t("exportCsvTooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)} )}
</div> </div>
</CardHeader> </CardHeader>
@@ -533,6 +542,8 @@ export function LogDataTable<TData, TValue>({
isServerPagination={isServerPagination} isServerPagination={isServerPagination}
isLoading={isLoading} isLoading={isLoading}
disabled={disabled} disabled={disabled}
pageSize={pageSize}
pageIndex={currentPage}
/> />
</div> </div>
</CardContent> </CardContent>

View File

@@ -354,7 +354,7 @@ export default function MachineClientsTable({
setSelectedClient(null); setSelectedClient(null);
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p>{t("deleteClientQuestion")}</p> <p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p> <p>{t("clientMessageRemove")}</p>
</div> </div>

View File

@@ -189,7 +189,7 @@ export default function OrgApiKeysTable({
setSelected(null); setSelected(null);
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p>{t("apiKeysQuestionRemove")}</p> <p>{t("apiKeysQuestionRemove")}</p>
<p>{t("apiKeysMessageRemove")}</p> <p>{t("apiKeysMessageRemove")}</p>

View File

@@ -27,6 +27,7 @@ function getActionsCategories(root: boolean) {
[t("actionUpdateOrg")]: "updateOrg", [t("actionUpdateOrg")]: "updateOrg",
[t("actionGetOrgUser")]: "getOrgUser", [t("actionGetOrgUser")]: "getOrgUser",
[t("actionInviteUser")]: "inviteUser", [t("actionInviteUser")]: "inviteUser",
[t("actionRemoveInvitation")]: "removeInvitation",
[t("actionListInvitations")]: "listInvitations", [t("actionListInvitations")]: "listInvitations",
[t("actionRemoveUser")]: "removeUser", [t("actionRemoveUser")]: "removeUser",
[t("actionListUsers")]: "listUsers", [t("actionListUsers")]: "listUsers",

View File

@@ -535,7 +535,7 @@ export default function ProxyResourcesTable({
setSelectedResource(null); setSelectedResource(null);
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p>{t("resourceQuestionRemove")}</p> <p>{t("resourceQuestionRemove")}</p>
<p>{t("resourceMessageRemove")}</p> <p>{t("resourceMessageRemove")}</p>
</div> </div>

View File

@@ -93,7 +93,7 @@ type ResourceAuthPortalProps = {
export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
const router = useRouter(); const router = useRouter();
const t = useTranslations(); const t = useTranslations();
const { isUnlocked } = useLicenseStatusContext(); const { isUnlocked, licenseStatus } = useLicenseStatusContext();
const getNumMethods = () => { const getNumMethods = () => {
let colLength = 0; let colLength = 0;
@@ -737,6 +737,22 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
</span> </span>
</div> </div>
)} )}
{build === "enterprise" && !isUnlocked() ? (
<div className="text-center mt-2">
<span className="text-sm font-medium text-muted-foreground">
{t("instanceIsUnlicensed")}
</span>
</div>
) : null}
{build === "enterprise" &&
isUnlocked() &&
licenseStatus?.tier === "personal" ? (
<div className="text-center mt-2">
<span className="text-sm font-medium text-muted-foreground">
{t("loginPageLicenseWatermark")}
</span>
</div>
) : null}
</div> </div>
) : ( ) : (
<ResourceAccessDenied /> <ResourceAccessDenied />

View File

@@ -412,7 +412,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
setSelectedSite(null); setSelectedSite(null);
}} }}
dialog={ dialog={
<div className=""> <div className="space-y-2">
<p>{t("siteQuestionRemove")}</p> <p>{t("siteQuestionRemove")}</p>
<p>{t("siteMessageRemove")}</p> <p>{t("siteMessageRemove")}</p>
</div> </div>

View File

@@ -401,7 +401,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
setSelectedClient(null); setSelectedClient(null);
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p>{t("deleteClientQuestion")}</p> <p>{t("deleteClientQuestion")}</p>
<p>{t("clientMessageRemove")}</p> <p>{t("clientMessageRemove")}</p>
</div> </div>

View File

@@ -258,7 +258,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
setSelectedUser(null); setSelectedUser(null);
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p>{t("userQuestionOrgRemove")}</p> <p>{t("userQuestionOrgRemove")}</p>
<p>{t("userMessageOrgRemove")}</p> <p>{t("userMessageOrgRemove")}</p>
</div> </div>

View File

@@ -224,7 +224,7 @@ export default function ViewDevicesDialog({
} }
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p> <p>
{t("deviceQuestionRemove") || {t("deviceQuestionRemove") ||
"Are you sure you want to delete this device?"} "Are you sure you want to delete this device?"}

View File

@@ -177,7 +177,7 @@ export default function IdpTable({ idps, orgId }: Props) {
setSelectedIdp(null); setSelectedIdp(null);
}} }}
dialog={ dialog={
<div> <div className="space-y-2">
<p>{t("idpQuestionRemove")}</p> <p>{t("idpQuestionRemove")}</p>
<p>{t("idpMessageRemove")}</p> <p>{t("idpMessageRemove")}</p>
</div> </div>

View File

@@ -308,7 +308,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
role="option" role="option"
aria-selected={isSelected} aria-selected={isSelected}
className={cn( className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-accent", "relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 hover:bg-accent",
isSelected && isSelected &&
"bg-accent text-accent-foreground", "bg-accent text-accent-foreground",
classStyleProps?.commandItem classStyleProps?.commandItem

View File

@@ -10,7 +10,8 @@ import {
getSortedRowModel, getSortedRowModel,
ColumnFiltersState, ColumnFiltersState,
getFilteredRowModel, getFilteredRowModel,
VisibilityState VisibilityState,
PaginationState
} from "@tanstack/react-table"; } from "@tanstack/react-table";
// Extended ColumnDef type that includes optional friendlyName for column visibility dropdown // Extended ColumnDef type that includes optional friendlyName for column visibility dropdown
@@ -227,6 +228,10 @@ export function DataTable<TData, TValue>({
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>( const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
initialColumnVisibility initialColumnVisibility
); );
const [pagination, setPagination] = useState<PaginationState>({
pageIndex: 0,
pageSize: pageSize
});
const [activeTab, setActiveTab] = useState<string>( const [activeTab, setActiveTab] = useState<string>(
defaultTab || tabs?.[0]?.id || "" defaultTab || tabs?.[0]?.id || ""
); );
@@ -256,6 +261,7 @@ export function DataTable<TData, TValue>({
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
onGlobalFilterChange: setGlobalFilter, onGlobalFilterChange: setGlobalFilter,
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: setPagination,
initialState: { initialState: {
pagination: { pagination: {
pageSize: pageSize, pageSize: pageSize,
@@ -267,21 +273,18 @@ export function DataTable<TData, TValue>({
sorting, sorting,
columnFilters, columnFilters,
globalFilter, globalFilter,
columnVisibility columnVisibility,
pagination
} }
}); });
// Persist pageSize to localStorage when it changes
useEffect(() => { useEffect(() => {
const currentPageSize = table.getState().pagination.pageSize; if (persistPageSize && pagination.pageSize !== pageSize) {
if (currentPageSize !== pageSize) { setStoredPageSize(pagination.pageSize, tableId);
table.setPageSize(pageSize); setPageSize(pagination.pageSize);
// Persist to localStorage if enabled
if (persistPageSize) {
setStoredPageSize(pageSize, tableId);
}
} }
}, [pageSize, table, persistPageSize, tableId]); }, [pagination.pageSize, persistPageSize, tableId, pageSize]);
useEffect(() => { useEffect(() => {
// Persist column visibility to localStorage when it changes // Persist column visibility to localStorage when it changes
@@ -293,13 +296,17 @@ export function DataTable<TData, TValue>({
const handleTabChange = (value: string) => { const handleTabChange = (value: string) => {
setActiveTab(value); setActiveTab(value);
// Reset to first page when changing tabs // Reset to first page when changing tabs
table.setPageIndex(0); setPagination((prev) => ({ ...prev, pageIndex: 0 }));
}; };
// Enhanced pagination component that updates our local state // Enhanced pagination component that updates our local state
const handlePageSizeChange = (newPageSize: number) => { const handlePageSizeChange = (newPageSize: number) => {
setPagination((prev) => ({
...prev,
pageSize: newPageSize,
pageIndex: 0
}));
setPageSize(newPageSize); setPageSize(newPageSize);
table.setPageSize(newPageSize);
// Persist immediately when changed // Persist immediately when changed
if (persistPageSize) { if (persistPageSize) {
@@ -614,6 +621,8 @@ export function DataTable<TData, TValue>({
<DataTablePagination <DataTablePagination
table={table} table={table}
onPageSizeChange={handlePageSizeChange} onPageSizeChange={handlePageSizeChange}
pageSize={pagination.pageSize}
pageIndex={pagination.pageIndex}
/> />
</div> </div>
</CardContent> </CardContent>

View File

@@ -88,7 +88,7 @@ const DialogTitle = React.forwardRef<
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={cn( className={cn(
"text-xl font-semibold leading-none tracking-tight", "text-lg font-semibold leading-none tracking-tight",
className className
)} )}
{...props} {...props}

View File

@@ -0,0 +1,7 @@
export function getSevenDaysAgo() {
const today = new Date();
today.setHours(0, 0, 0, 0); // Set to midnight
const sevenDaysAgo = new Date(today);
sevenDaysAgo.setDate(today.getDate() - 7);
return sevenDaysAgo;
}

View File

@@ -180,17 +180,15 @@ export type LogAnalyticsFilters = z.TypeOf<typeof logAnalyticsFiltersSchema>;
export const logQueries = { export const logQueries = {
requestAnalytics: ({ requestAnalytics: ({
orgId, orgId,
filters, filters
api
}: { }: {
orgId: string; orgId: string;
filters: LogAnalyticsFilters; filters: LogAnalyticsFilters;
api: AxiosInstance;
}) => }) =>
queryOptions({ queryOptions({
queryKey: ["REQUEST_LOG_ANALYTICS", orgId, filters] as const, queryKey: ["REQUEST_LOG_ANALYTICS", orgId, filters] as const,
queryFn: async ({ signal }) => { queryFn: async ({ signal, meta }) => {
const res = await api.get< const res = await meta!.api.get<
AxiosResponse<QueryRequestAnalyticsResponse> AxiosResponse<QueryRequestAnalyticsResponse>
>(`/org/${orgId}/logs/analytics`, { >(`/org/${orgId}/logs/analytics`, {
params: filters, params: filters,
@@ -240,11 +238,11 @@ export const resourceQueries = {
return res.data.data.clients; return res.data.data.clients;
} }
}), }),
listNamesPerOrg: (orgId: string, api: AxiosInstance) => listNamesPerOrg: (orgId: string) =>
queryOptions({ queryOptions({
queryKey: ["RESOURCES_NAMES", orgId] as const, queryKey: ["RESOURCES_NAMES", orgId] as const,
queryFn: async ({ signal }) => { queryFn: async ({ signal, meta }) => {
const res = await api.get< const res = await meta!.api.get<
AxiosResponse<ListResourceNamesResponse> AxiosResponse<ListResourceNamesResponse>
>(`/org/${orgId}/resource-names`, { >(`/org/${orgId}/resource-names`, {
signal signal