diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 8af8625d..c2129493 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -107,7 +107,7 @@ jobs: - name: Build and push Docker images (Docker Hub) run: | TAG=${{ env.TAG }} - make build-release tag=$TAG + make -j4 build-release tag=$TAG echo "Built & pushed to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}" shell: bash diff --git a/.github/workflows/restart-runners.yml b/.github/workflows/restart-runners.yml new file mode 100644 index 00000000..14bbcefb --- /dev/null +++ b/.github/workflows/restart-runners.yml @@ -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" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 3b0627cc..41d43bd9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,11 +12,12 @@ on: jobs: test: runs-on: ubuntu-latest - 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: node-version: '22' @@ -57,8 +58,26 @@ jobs: echo "App failed to start" 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 - 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 - run: make build-pg + run: make dev-build-pg diff --git a/Dockerfile b/Dockerfile index fa2d71c0..c59490b6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,23 +43,25 @@ RUN test -f dist/server.mjs 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 WORKDIR /app -# Curl used for the health checks -# Python and build tools needed for better-sqlite3 native compilation -RUN apk add --no-cache curl tzdata python3 make g++ +# Only curl and tzdata needed at runtime - no build tools! +RUN apk add --no-cache curl tzdata -# COPY package.json package-lock.json ./ -COPY package*.json ./ - -RUN npm ci --omit=dev && npm cache clean --force +# Copy pre-built node_modules from builder (already pruned to production only) +# This includes the compiled native modules like better-sqlite3 +COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/dist ./dist COPY --from=builder /app/init ./dist/init +COPY --from=builder /app/package.json ./package.json COPY ./cli/wrapper.sh /usr/local/bin/pangctl RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs diff --git a/Makefile b/Makefile index 6c538a47..1519aec7 100644 --- a/Makefile +++ b/Makefile @@ -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) 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 \ echo "Error: tag is required. Usage: make build-release tag="; \ exit 1; \ @@ -16,6 +21,12 @@ build-release: --tag fosrl/pangolin:$(minor_tag) \ --tag fosrl/pangolin:$(tag) \ --push . + +build-postgresql: + @if [ -z "$(tag)" ]; then \ + echo "Error: tag is required. Usage: make build-release tag="; \ + exit 1; \ + fi docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=pg \ @@ -25,6 +36,12 @@ build-release: --tag fosrl/pangolin:postgresql-$(minor_tag) \ --tag fosrl/pangolin:postgresql-$(tag) \ --push . + +build-ee-sqlite: + @if [ -z "$(tag)" ]; then \ + echo "Error: tag is required. Usage: make build-release tag="; \ + exit 1; \ + fi docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=sqlite \ @@ -34,6 +51,12 @@ build-release: --tag fosrl/pangolin:ee-$(minor_tag) \ --tag fosrl/pangolin:ee-$(tag) \ --push . + +build-ee-postgresql: + @if [ -z "$(tag)" ]; then \ + echo "Error: tag is required. Usage: make build-release tag="; \ + exit 1; \ + fi docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=pg \ @@ -80,10 +103,10 @@ build-arm: build-x86: 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 . -build-pg: +dev-build-pg: docker build --build-arg DATABASE=pg -t fosrl/pangolin:postgresql-latest . test: diff --git a/README.md b/README.md index 5484f8fb..a842ed3b 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@

-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 @@ -60,14 +60,20 @@ Pangolin is a self-hosted tunneled reverse proxy server with identity and contex ## Key Features -Pangolin packages everything you need for seamless application access and exposure into one cohesive platform. - | | | |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------| -| **Manage applications in one place**

Pangolin provides a unified dashboard where you can monitor, configure, and secure all of your services regardless of where they are hosted. | | -| **Reverse proxy across networks anywhere**

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. | | -| **Enforce identity and context aware rules**

Protect your applications with identity and context aware rules such as SSO, OIDC, PIN, password, temporary share links, geolocation, IP, and more. | | -| **Quickly connect Pangolin sites**

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. | | +| **Connect remote networks with sites**

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. | | +| **Browser-based reverse proxy access**

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. | | +| **Client-based private resource access**

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. | | +| **Zero-trust granular access**

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. | | + +## 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 diff --git a/install/containers.go b/install/containers.go index 9993e117..464186c2 100644 --- a/install/containers.go +++ b/install/containers.go @@ -73,7 +73,7 @@ func installDocker() error { case strings.Contains(osRelease, "ID=ubuntu"): installCmd = exec.Command("bash", "-c", fmt.Sprintf(` 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 && 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 && @@ -82,7 +82,7 @@ func installDocker() error { case strings.Contains(osRelease, "ID=debian"): installCmd = exec.Command("bash", "-c", fmt.Sprintf(` 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 && 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 && diff --git a/messages/de-DE.json b/messages/de-DE.json index 333c7052..13ab3d11 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1043,7 +1043,7 @@ "actionDeleteSite": "Standort löschen", "actionGetSite": "Standort abrufen", "actionListSites": "Standorte auflisten", - "actionApplyBlueprint": "Blaupause anwenden", + "actionApplyBlueprint": "Blueprint anwenden", "setupToken": "Setup-Token", "setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.", "setupTokenRequired": "Setup-Token ist erforderlich", @@ -1102,7 +1102,7 @@ "actionDeleteIdpOrg": "IDP-Organisationsrichtlinie löschen", "actionListIdpOrgs": "IDP-Organisationen auflisten", "actionUpdateIdpOrg": "IDP-Organisation aktualisieren", - "actionCreateClient": "Endgerät anlegen", + "actionCreateClient": "Client erstellen", "actionDeleteClient": "Client löschen", "actionUpdateClient": "Client aktualisieren", "actionListClients": "Clients auflisten", @@ -1201,24 +1201,24 @@ "sidebarLogsAnalytics": "Analytik", "blueprints": "Baupläne", "blueprintsDescription": "Deklarative Konfigurationen anwenden und vorherige Abläufe anzeigen", - "blueprintAdd": "Blaupause hinzufügen", - "blueprintGoBack": "Alle Blaupausen ansehen", - "blueprintCreate": "Blaupause erstellen", - "blueprintCreateDescription2": "Folge den Schritten unten, um eine neue Blaupause zu erstellen und anzuwenden", - "blueprintDetails": "Blaupausendetails", - "blueprintDetailsDescription": "Siehe das Ergebnis der angewendeten Blaupause und alle aufgetretenen Fehler", - "blueprintInfo": "Blaupauseninformation", + "blueprintAdd": "Blueprint hinzufügen", + "blueprintGoBack": "Alle Blueprints ansehen", + "blueprintCreate": "Blueprint erstellen", + "blueprintCreateDescription2": "Folge den unten aufgeführten Schritten, um einen neuen Blueprint zu erstellen und anzuwenden", + "blueprintDetails": "Blueprint Detailinformationen", + "blueprintDetailsDescription": "Siehe das Ergebnis des angewendeten Blueprints und alle aufgetretenen Fehler", + "blueprintInfo": "Blueprint Informationen", "message": "Nachricht", "blueprintContentsDescription": "Den YAML-Inhalt definieren, der die Infrastruktur beschreibt", - "blueprintErrorCreateDescription": "Fehler beim Anwenden der Blaupause", - "blueprintErrorCreate": "Fehler beim Erstellen der Blaupause", - "searchBlueprintProgress": "Blaupausen suchen...", + "blueprintErrorCreateDescription": "Fehler beim Anwenden des Blueprints", + "blueprintErrorCreate": "Fehler beim Erstellen des Blueprints", + "searchBlueprintProgress": "Blueprints suchen...", "appliedAt": "Angewandt am", "source": "Quelle", "contents": "Inhalt", "parsedContents": "Analysierte Inhalte (Nur lesen)", - "enableDockerSocket": "Docker Blaupause aktivieren", - "enableDockerSocketDescription": "Aktiviere Docker-Socket-Label-Scraping für Blaupausenbeschriftungen. Der Socket-Pfad muss neu angegeben werden.", + "enableDockerSocket": "Docker Blueprint aktivieren", + "enableDockerSocketDescription": "Aktiviere Docker-Socket-Label-Scraping für Blueprintbeschriftungen. Der Socket-Pfad muss neu angegeben werden.", "enableDockerSocketLink": "Mehr erfahren", "viewDockerContainers": "Docker Container anzeigen", "containersIn": "Container in {siteName}", @@ -1543,7 +1543,7 @@ "healthCheckPathRequired": "Gesundheits-Check-Pfad ist erforderlich", "healthCheckMethodRequired": "HTTP-Methode ist erforderlich", "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", "httpMethod": "HTTP-Methode", "selectHttpMethod": "HTTP-Methode auswählen", diff --git a/messages/en-US.json b/messages/en-US.json index 3dd1c94e..148db379 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -419,7 +419,7 @@ "userErrorExistsDescription": "This user is already a member of the organization.", "inviteError": "Failed to invite user", "inviteErrorDescription": "An error occurred while inviting the user", - "userInvited": "User invited", + "userInvited": "User Invited", "userInvitedDescription": "The user has been successfully invited.", "userErrorCreate": "Failed to create user", "userErrorCreateDescription": "An error occurred while creating the user", @@ -1035,6 +1035,7 @@ "updateOrgUser": "Update Org User", "createOrgUser": "Create Org User", "actionUpdateOrg": "Update Organization", + "actionRemoveInvitation": "Remove Invitation", "actionUpdateUser": "Update User", "actionGetUser": "Get User", "actionGetOrgUser": "Get Organization User", @@ -2067,6 +2068,8 @@ "timestamp": "Timestamp", "accessLogs": "Access Logs", "exportCsv": "Export CSV", + "exportError": "Unknown error when exporting CSV", + "exportCsvTooltip": "Within Time Range", "actorId": "Actor ID", "allowedByRule": "Allowed by Rule", "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.", "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.", - "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)." } diff --git a/package-lock.json b/package-lock.json index b594dea0..b3a18c31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "SEE LICENSE IN LICENSE AND README.md", "dependencies": { "@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", "@headlessui/react": "2.2.9", "@hookform/resolvers": "5.2.2", @@ -72,32 +72,32 @@ "jmespath": "0.16.0", "js-yaml": "4.1.1", "jsonwebtoken": "9.0.3", - "lucide-react": "0.556.0", + "lucide-react": "0.559.0", "maxmind": "5.0.1", "moment": "2.30.1", - "next": "15.5.7", + "next": "15.5.9", "next-intl": "4.5.8", "next-themes": "0.4.6", "nextjs-toploader": "3.9.17", "node-cache": "5.1.2", "node-fetch": "3.3.2", "nodemailer": "7.0.11", - "npm": "11.6.4", + "npm": "11.7.0", "nprogress": "0.2.0", "oslo": "1.2.1", "pg": "8.16.3", "posthog-node": "5.17.2", "qrcode.react": "4.2.0", - "react": "19.2.1", + "react": "19.2.3", "react-day-picker": "9.12.0", - "react-dom": "19.2.1", + "react-dom": "19.2.3", "react-easy-sort": "1.8.0", "react-hook-form": "7.68.0", "react-icons": "5.5.0", "rebuild": "0.1.2", "recharts": "2.15.4", "reodotdev": "1.0.0", - "resend": "6.5.2", + "resend": "6.6.0", "semver": "7.7.3", "stripe": "20.0.0", "swagger-ui-express": "5.0.1", @@ -133,7 +133,7 @@ "@types/node": "24.10.2", "@types/nodemailer": "7.0.4", "@types/nprogress": "0.2.3", - "@types/pg": "8.15.6", + "@types/pg": "8.16.0", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", "@types/semver": "7.7.1", @@ -147,7 +147,7 @@ "esbuild-node-externals": "1.20.1", "postcss": "8.5.6", "prettier": "3.7.4", - "react-email": "5.0.6", + "react-email": "5.0.7", "tailwindcss": "4.1.17", "tsc-alias": "1.8.16", "tsx": "4.21.0", @@ -396,23 +396,23 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.947.0.tgz", - "integrity": "sha512-ICgnI8D3ccIX9alsLksPFY2bX5CAIbyB+q19sXJgPhzCJ5kWeQ6LQ5xBmRVT5kccmsVGbbJdhnLXHyiN5LZsWg==", + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.948.0.tgz", + "integrity": "sha512-uvEjds8aYA9SzhBS8RKDtsDUhNV9VhqKiHTcmvhM7gJO92q0WTn8/QeFTdNyLc6RxpiDyz+uBxS7PcdNiZzqfA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.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-expect-continue": "3.936.0", "@aws-sdk/middleware-flexible-checksums": "3.947.0", "@aws-sdk/middleware-host-header": "3.936.0", "@aws-sdk/middleware-location-constraint": "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-ssec": "3.936.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": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.947.0.tgz", - "integrity": "sha512-sDwcO8SP290WSErY1S8pz8hTafeghKmmWjNVks86jDK30wx62CfazOTeU70IpWgrUBEygyXk/zPogHsUMbW2Rg==", + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.948.0.tgz", + "integrity": "sha512-iWjchXy8bIAVBUsKnbfKYXRwhLgRg3EqCQ5FTr3JbR+QR75rZm4ZOYXlvHGztVTmtAZ+PQVA1Y4zO7v7N87C0A==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -472,7 +472,7 @@ "@aws-sdk/core": "3.947.0", "@aws-sdk/middleware-host-header": "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/region-config-resolver": "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": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.947.0.tgz", - "integrity": "sha512-A2ZUgJUJZERjSzvCi2NR/hBVbVkTXPD0SdKcR/aITb30XwF+n3T963b+pJl90qhOspoy7h0IVYNR7u5Nr9tJdQ==", + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.948.0.tgz", + "integrity": "sha512-Cl//Qh88e8HBL7yYkJNpF5eq76IO6rq8GsatKcfVBm7RFVxCqYEPSSBtkHdbtNwQdRQqAMXc6E/lEB/CZUDxnA==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/core": "3.947.0", "@aws-sdk/credential-provider-env": "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-sso": "3.947.0", - "@aws-sdk/credential-provider-web-identity": "3.947.0", - "@aws-sdk/nested-clients": "3.947.0", + "@aws-sdk/credential-provider-sso": "3.948.0", + "@aws-sdk/credential-provider-web-identity": "3.948.0", + "@aws-sdk/nested-clients": "3.948.0", "@aws-sdk/types": "3.936.0", "@smithy/credential-provider-imds": "^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": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.947.0.tgz", - "integrity": "sha512-u7M3hazcB7aJiVwosNdJRbIJDzbwQ861NTtl6S0HmvWpixaVb7iyhJZWg8/plyUznboZGBm7JVEdxtxv3u0bTA==", + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.948.0.tgz", + "integrity": "sha512-gcKO2b6eeTuZGp3Vvgr/9OxajMrD3W+FZ2FCyJox363ZgMoYJsyNid1vuZrEuAGkx0jvveLXfwiVS0UXyPkgtw==", "license": "Apache-2.0", "dependencies": { "@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", "@smithy/property-provider": "^4.2.5", "@smithy/protocol-http": "^5.3.5", @@ -616,17 +616,17 @@ } }, "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-node": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.947.0.tgz", - "integrity": "sha512-S0Zqebr71KyrT6J4uYPhwV65g4V5uDPHnd7dt2W34FcyPu+hVC7Hx4MFmsPyVLeT5cMCkkZvmY3kAoEzgUPJJg==", + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.948.0.tgz", + "integrity": "sha512-ep5vRLnrRdcsP17Ef31sNN4g8Nqk/4JBydcUJuFRbGuyQtrZZrVT81UeH2xhz6d0BK6ejafDB9+ZpBjXuWT5/Q==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/credential-provider-env": "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-sso": "3.947.0", - "@aws-sdk/credential-provider-web-identity": "3.947.0", + "@aws-sdk/credential-provider-sso": "3.948.0", + "@aws-sdk/credential-provider-web-identity": "3.948.0", "@aws-sdk/types": "3.936.0", "@smithy/credential-provider-imds": "^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": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.947.0.tgz", - "integrity": "sha512-NktnVHTGaUMaozxycYrepvb3yfFquHTQ53lt6hBEVjYBzK3C4tVz0siUpr+5RMGLSiZ5bLBp2UjJPgwx4i4waQ==", + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.948.0.tgz", + "integrity": "sha512-gqLhX1L+zb/ZDnnYbILQqJ46j735StfWV5PbDjxRzBKS7GzsiYoaf6MyHseEopmWrez5zl5l6aWzig7UpzSeQQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-sso": "3.947.0", + "@aws-sdk/client-sso": "3.948.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", "@smithy/property-provider": "^4.2.5", "@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": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.947.0.tgz", - "integrity": "sha512-gokm/e/YHiHLrZgLq4j8tNAn8RJDPbIcglFRKgy08q8DmAqHQ8MXAKW3eS0QjAuRXU9mcMmUo1NrX6FRNBCCPw==", + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.948.0.tgz", + "integrity": "sha512-MvYQlXVoJyfF3/SmnNzOVEtANRAiJIObEUYYyjTqKZTmcRIVVky0tPuG26XnB8LmTYgtESwJIZJj/Eyyc9WURQ==", "license": "Apache-2.0", "dependencies": { "@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", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", @@ -692,6 +692,22 @@ "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": { "version": "3.947.0", "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": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.947.0.tgz", - "integrity": "sha512-DjRJEYNnHUTu9kGPPQDTSXquwSEd6myKR4ssI4FaYLFhdT3ldWpj73yYt807H3tdmhS7vPmdVqchSJnjurUQAw==", + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.948.0.tgz", + "integrity": "sha512-zcbJfBsB6h254o3NuoEkf0+UY1GpE9ioiQdENWv7odo69s8iaGBEQ4BDpsIMqcuiiUXw1uKIVNxCB1gUGYz8lw==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -746,7 +762,7 @@ "@aws-sdk/core": "3.947.0", "@aws-sdk/middleware-host-header": "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/region-config-resolver": "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": { - "version": "3.947.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.947.0.tgz", - "integrity": "sha512-X/DyB8GuK44rsE89Tn5+s542B3PhGbXQSgV8lvqHDzvicwCt0tWny6790st6CPETrVVV2K3oJMfG5U3/jAmaZA==", + "version": "3.948.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.948.0.tgz", + "integrity": "sha512-V487/kM4Teq5dcr1t5K6eoUKuqlGr9FRWL3MIMukMERJXHZvio6kox60FZ/YtciRHRI75u14YUqm2Dzddcu3+A==", "license": "Apache-2.0", "dependencies": { "@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", "@smithy/property-provider": "^4.2.5", "@smithy/shared-ini-file-loader": "^4.4.0", @@ -1264,6 +1280,7 @@ "version": "3.936.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.936.0.tgz", "integrity": "sha512-l4aGbHpXM45YNgXggIux1HgsCVAvvBoqHPkqLnqMl9QVapfuSTjJHfDYDsx1Xxct6/m7qSMUzanBALhiaGO2fA==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "3.936.0", @@ -3818,9 +3835,9 @@ } }, "node_modules/@next/env": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz", - "integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", + "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -9360,9 +9377,9 @@ "license": "MIT" }, "node_modules/@types/pg": { - "version": "8.15.6", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.6.tgz", - "integrity": "sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", "devOptional": true, "license": "MIT", "peer": true, @@ -15915,9 +15932,9 @@ } }, "node_modules/lucide-react": { - "version": "0.556.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.556.0.tgz", - "integrity": "sha512-iOb8dRk7kLaYBZhR2VlV1CeJGxChBgUthpSP8wom9jfj79qovgG6qcSdiy6vkoREKPnbUYzJsCn4o4PtG3Iy+A==", + "version": "0.559.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.559.0.tgz", + "integrity": "sha512-3ymrkBPXWk3U2bwUDg6TdA6hP5iGDMgPEAMLhchEgTQmA+g0Zk24tOtKtXMx35w1PizTmsBC3RhP88QYm+7mHQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -16274,13 +16291,13 @@ } }, "node_modules/next": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz", - "integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==", + "version": "15.5.9", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz", + "integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==", "license": "MIT", "peer": true, "dependencies": { - "@next/env": "15.5.7", + "@next/env": "15.5.9", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -16515,9 +16532,9 @@ } }, "node_modules/npm": { - "version": "11.6.4", - "resolved": "https://registry.npmjs.org/npm/-/npm-11.6.4.tgz", - "integrity": "sha512-ERjKtGoFpQrua/9bG0+h3xiv/4nVdGViCjUYA1AmlV24fFvfnSB7B7dIfZnySQ1FDLd0ZVrWPsLLp78dCtJdRQ==", + "version": "11.7.0", + "resolved": "https://registry.npmjs.org/npm/-/npm-11.7.0.tgz", + "integrity": "sha512-wiCZpv/41bIobCoJ31NStIWKfAxxYyD1iYnWCtiyns8s5v3+l8y0HCP/sScuH6B5+GhIfda4HQKiqeGZwJWhFw==", "bundleDependencies": [ "@isaacs/string-locale-compare", "@npmcli/arborist", @@ -16596,8 +16613,8 @@ ], "dependencies": { "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/arborist": "^9.1.8", - "@npmcli/config": "^10.4.4", + "@npmcli/arborist": "^9.1.9", + "@npmcli/config": "^10.4.5", "@npmcli/fs": "^5.0.0", "@npmcli/map-workspaces": "^5.0.3", "@npmcli/metavuln-calculator": "^9.0.3", @@ -16622,11 +16639,11 @@ "is-cidr": "^6.0.1", "json-parse-even-better-errors": "^5.0.0", "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.0.11", - "libnpmexec": "^10.1.10", - "libnpmfund": "^7.0.11", + "libnpmdiff": "^8.0.12", + "libnpmexec": "^10.1.11", + "libnpmfund": "^7.0.12", "libnpmorg": "^8.0.1", - "libnpmpack": "^9.0.11", + "libnpmpack": "^9.0.12", "libnpmpublish": "^11.1.3", "libnpmsearch": "^9.0.1", "libnpmteam": "^8.0.2", @@ -16734,7 +16751,7 @@ } }, "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.1.8", + "version": "9.1.9", "inBundle": true, "license": "ISC", "dependencies": { @@ -16780,7 +16797,7 @@ } }, "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.4.4", + "version": "10.4.5", "inBundle": true, "license": "ISC", "dependencies": { @@ -17518,11 +17535,11 @@ } }, "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.0.11", + "version": "8.0.12", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.8", + "@npmcli/arborist": "^9.1.9", "@npmcli/installed-package-contents": "^4.0.0", "binary-extensions": "^3.0.0", "diff": "^8.0.2", @@ -17536,11 +17553,11 @@ } }, "node_modules/npm/node_modules/libnpmexec": { - "version": "10.1.10", + "version": "10.1.11", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.8", + "@npmcli/arborist": "^9.1.9", "@npmcli/package-json": "^7.0.0", "@npmcli/run-script": "^10.0.0", "ci-info": "^4.0.0", @@ -17558,11 +17575,11 @@ } }, "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.11", + "version": "7.0.12", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.8" + "@npmcli/arborist": "^9.1.9" }, "engines": { "node": "^20.17.0 || >=22.9.0" @@ -17581,11 +17598,11 @@ } }, "node_modules/npm/node_modules/libnpmpack": { - "version": "9.0.11", + "version": "9.0.12", "inBundle": true, "license": "ISC", "dependencies": { - "@npmcli/arborist": "^9.1.8", + "@npmcli/arborist": "^9.1.9", "@npmcli/run-script": "^10.0.0", "npm-package-arg": "^13.0.0", "pacote": "^21.0.2" @@ -19720,9 +19737,9 @@ } }, "node_modules/react": { - "version": "19.2.1", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", - "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", + "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", "peer": true, "engines": { @@ -19751,16 +19768,16 @@ } }, "node_modules/react-dom": { - "version": "19.2.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz", - "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.1" + "react": "^19.2.3" } }, "node_modules/react-easy-sort": { @@ -19780,9 +19797,9 @@ } }, "node_modules/react-email": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/react-email/-/react-email-5.0.6.tgz", - "integrity": "sha512-DEGzWpEiC3CquPEaaEJuipNT3WZ9mK58rbkpOe4Slbgyf60PLa1wONnt5a3afbBBRbNdW2aYhIvVI41yS6UIRA==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-5.0.7.tgz", + "integrity": "sha512-JsWzxl3O82Gw9HRRNJm8VjQLB8c7R5TGbP89Ffj+/Qdb2H2N4J0XRXkhqiucMvmucuqNqe9mNndZkh3jh638xA==", "dev": true, "license": "MIT", "dependencies": { @@ -20871,9 +20888,9 @@ "license": "MIT" }, "node_modules/resend": { - "version": "6.5.2", - "resolved": "https://registry.npmjs.org/resend/-/resend-6.5.2.tgz", - "integrity": "sha512-Yl83UvS8sYsjgmF8dVbNPzlfpmb3DkLUk3VwsAbkaEFo9UMswpNuPGryHBXGk+Ta4uYMv5HmjVk3j9jmNkcEDg==", + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.6.0.tgz", + "integrity": "sha512-d1WoOqSxj5x76JtQMrieNAG1kZkh4NU4f+Je1yq4++JsDpLddhEwnJlNfvkCzvUuZy9ZquWmMMAm2mENd2JvRw==", "license": "MIT", "dependencies": { "svix": "1.76.1" diff --git a/package.json b/package.json index 63af7f51..2aebc439 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@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", "@headlessui/react": "2.2.9", "@hookform/resolvers": "5.2.2", @@ -96,32 +96,32 @@ "jmespath": "0.16.0", "js-yaml": "4.1.1", "jsonwebtoken": "9.0.3", - "lucide-react": "0.556.0", + "lucide-react": "0.559.0", "maxmind": "5.0.1", "moment": "2.30.1", - "next": "15.5.7", + "next": "15.5.9", "next-intl": "4.5.8", "next-themes": "0.4.6", "nextjs-toploader": "3.9.17", "node-cache": "5.1.2", "node-fetch": "3.3.2", "nodemailer": "7.0.11", - "npm": "11.6.4", + "npm": "11.7.0", "nprogress": "0.2.0", "oslo": "1.2.1", "pg": "8.16.3", "posthog-node": "5.17.2", "qrcode.react": "4.2.0", - "react": "19.2.1", + "react": "19.2.3", "react-day-picker": "9.12.0", - "react-dom": "19.2.1", + "react-dom": "19.2.3", "react-easy-sort": "1.8.0", "react-hook-form": "7.68.0", "react-icons": "5.5.0", "rebuild": "0.1.2", "recharts": "2.15.4", "reodotdev": "1.0.0", - "resend": "6.5.2", + "resend": "6.6.0", "semver": "7.7.3", "stripe": "20.0.0", "swagger-ui-express": "5.0.1", @@ -156,7 +156,7 @@ "@types/node": "24.10.2", "@types/nodemailer": "7.0.4", "@types/nprogress": "0.2.3", - "@types/pg": "8.15.6", + "@types/pg": "8.16.0", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", "@types/semver": "7.7.1", @@ -171,11 +171,11 @@ "esbuild-node-externals": "1.20.1", "postcss": "8.5.6", "prettier": "3.7.4", - "react-email": "5.0.6", + "react-email": "5.0.7", "tailwindcss": "4.1.17", "tsc-alias": "1.8.16", "tsx": "4.21.0", "typescript": "5.9.3", "typescript-eslint": "8.49.0" } -} \ No newline at end of file +} diff --git a/public/screenshots/create-resource.png b/public/screenshots/create-resource.png deleted file mode 100644 index 3b21f22b..00000000 Binary files a/public/screenshots/create-resource.png and /dev/null differ diff --git a/public/screenshots/create-site.png b/public/screenshots/create-site.png index b5ff8048..8d12a962 100644 Binary files a/public/screenshots/create-site.png and b/public/screenshots/create-site.png differ diff --git a/public/screenshots/edit-resource.png b/public/screenshots/edit-resource.png deleted file mode 100644 index 2d21afa6..00000000 Binary files a/public/screenshots/edit-resource.png and /dev/null differ diff --git a/public/screenshots/hero.png b/public/screenshots/hero.png index 86216cf6..f42a830e 100644 Binary files a/public/screenshots/hero.png and b/public/screenshots/hero.png differ diff --git a/public/screenshots/private-resources.png b/public/screenshots/private-resources.png new file mode 100644 index 00000000..f48d9279 Binary files /dev/null and b/public/screenshots/private-resources.png differ diff --git a/public/screenshots/public-resources.png b/public/screenshots/public-resources.png new file mode 100644 index 00000000..f42a830e Binary files /dev/null and b/public/screenshots/public-resources.png differ diff --git a/public/screenshots/resources.png b/public/screenshots/resources.png deleted file mode 100644 index 86216cf6..00000000 Binary files a/public/screenshots/resources.png and /dev/null differ diff --git a/public/screenshots/sites-fade.png b/public/screenshots/sites-fade.png deleted file mode 100644 index 7e21c2cd..00000000 Binary files a/public/screenshots/sites-fade.png and /dev/null differ diff --git a/public/screenshots/sites.png b/public/screenshots/sites.png index 0aaa79d0..86b32b81 100644 Binary files a/public/screenshots/sites.png and b/public/screenshots/sites.png differ diff --git a/public/screenshots/user-devices.png b/public/screenshots/user-devices.png new file mode 100644 index 00000000..7b407cd6 Binary files /dev/null and b/public/screenshots/user-devices.png differ diff --git a/server/db/pg/driver.ts b/server/db/pg/driver.ts index 9456effb..2ee34da6 100644 --- a/server/db/pg/driver.ts +++ b/server/db/pg/driver.ts @@ -6,28 +6,28 @@ import { withReplicas } from "drizzle-orm/pg-core"; function createDb() { const config = readConfigFile(); - if (!config.postgres) { - // check the environment variables for postgres config - if (process.env.POSTGRES_CONNECTION_STRING) { - config.postgres = { - connection_string: process.env.POSTGRES_CONNECTION_STRING - }; - if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) { - const replicas = - process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split( - "," - ).map((conn) => ({ + // check the environment variables for postgres config first before the config file + if (process.env.POSTGRES_CONNECTION_STRING) { + config.postgres = { + connection_string: process.env.POSTGRES_CONNECTION_STRING + }; + if (process.env.POSTGRES_REPLICA_CONNECTION_STRINGS) { + const replicas = + process.env.POSTGRES_REPLICA_CONNECTION_STRINGS.split(",").map( + (conn) => ({ connection_string: conn.trim() - })); - config.postgres.replicas = replicas; - } - } else { - throw new Error( - "Postgres configuration is missing in the configuration file." - ); + }) + ); + config.postgres.replicas = replicas; } } + if (!config.postgres) { + throw new Error( + "Postgres configuration is missing in the configuration file." + ); + } + const connectionString = config.postgres?.connection_string; const replicaConnections = config.postgres?.replicas || []; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 71877f2f..e8077754 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -213,7 +213,10 @@ export const siteResources = pgTable("siteResources", { destination: varchar("destination").notNull(), // ip, cidr, hostname; validate against the mode enabled: boolean("enabled").notNull().default(true), 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", { diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 6e17cac4..de8ad8d0 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -234,7 +234,10 @@ export const siteResources = sqliteTable("siteResources", { destination: text("destination").notNull(), // ip, cidr, hostname enabled: integer("enabled", { mode: "boolean" }).notNull().default(true), 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", { diff --git a/server/emails/sendEmail.ts b/server/emails/sendEmail.ts index c8a0b077..32a5fb47 100644 --- a/server/emails/sendEmail.ts +++ b/server/emails/sendEmail.ts @@ -10,6 +10,7 @@ export async function sendEmail( from: string | undefined; to: string | undefined; subject: string; + replyTo?: string; } ) { if (!emailClient) { @@ -32,6 +33,7 @@ export async function sendEmail( address: opts.from }, to: opts.to, + replyTo: opts.replyTo, subject: opts.subject, html: emailHtml }); diff --git a/server/lib/consts.ts b/server/lib/consts.ts index b380023e..d1f66a9e 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // 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 __DIRNAME = path.dirname(__FILENAME); diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 36065df3..21c148ac 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -1,10 +1,4 @@ -import { - clientSitesAssociationsCache, - db, - SiteResource, - siteResources, - Transaction -} from "@server/db"; +import { db, SiteResource, siteResources, Transaction } from "@server/db"; import { clients, orgs, sites } from "@server/db"; import { and, eq, isNotNull } from "drizzle-orm"; import config from "@server/lib/config"; @@ -120,11 +114,13 @@ function bigIntToIp(num: bigint, version: IPVersion): string { * Parses an endpoint string (ip:port) handling both IPv4 and IPv6 addresses. * IPv6 addresses may be bracketed like [::1]:8080 or unbracketed like ::1:8080. * For unbracketed IPv6, the last colon-separated segment is treated as the port. - * + * * @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 */ -export function parseEndpoint(endpoint: string): { ip: string; port: number } | null { +export function parseEndpoint( + endpoint: string +): { ip: string; port: number } | null { if (!endpoint) return null; // Check for bracketed IPv6 format: [ip]:port @@ -138,7 +134,7 @@ export function parseEndpoint(endpoint: string): { ip: string; port: number } | // Check if this looks like IPv6 (contains multiple colons) const colonCount = (endpoint.match(/:/g) || []).length; - + if (colonCount > 1) { // This is IPv6 - the port is after the last colon const lastColonIndex = endpoint.lastIndexOf(":"); @@ -163,7 +159,7 @@ export function parseEndpoint(endpoint: string): { ip: string; port: number } | /** * Formats an IP and port into a consistent endpoint string. * IPv6 addresses are wrapped in brackets for proper parsing. - * + * * @param ip The IP address (IPv4 or IPv6) * @param port The port number * @returns Formatted endpoint string @@ -430,7 +426,12 @@ export function generateRemoteSubnets( ): string[] { const remoteSubnets = allSiteResources .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") { // check if its a valid IP using zod 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 function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { - let aliasConfigs = allSiteResources + return allSiteResources .filter((sr) => sr.alias && sr.aliasAddress && sr.mode == "host") .map((sr) => ({ alias: sr.alias, aliasAddress: sr.aliasAddress })); - return aliasConfigs; } export type SubnetProxyTarget = { sourcePrefix: string; // must be a cidr destPrefix: string; // must be a cidr + disableIcmp?: boolean; rewriteTo?: string; // must be a cidr portRange?: { min: number; max: number; + protocol: "tcp" | "udp"; }[]; }; @@ -499,6 +501,11 @@ export function generateSubnetProxyTargets( } 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") { let destination = siteResource.destination; @@ -509,7 +516,9 @@ export function generateSubnetProxyTargets( targets.push({ sourcePrefix: clientPrefix, - destPrefix: destination + destPrefix: destination, + portRange, + disableIcmp }); } @@ -518,13 +527,17 @@ export function generateSubnetProxyTargets( targets.push({ sourcePrefix: clientPrefix, destPrefix: `${siteResource.aliasAddress}/32`, - rewriteTo: destination + rewriteTo: destination, + portRange, + disableIcmp }); } } else if (siteResource.mode == "cidr") { targets.push({ sourcePrefix: clientPrefix, - destPrefix: siteResource.destination + destPrefix: siteResource.destination, + portRange, + disableIcmp }); } } @@ -536,3 +549,117 @@ export function generateSubnetProxyTargets( 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; +} diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index e0867dc5..625e5793 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -955,28 +955,8 @@ export async function rebuildClientAssociationsFromClient( /////////// 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 - await handleMessagesForClientSites( - client, - olm.olmId, - sitesToAdd, - sitesToRemove, - trx - ); + await handleMessagesForClientSites(client, sitesToAdd, sitesToRemove, trx); // Handle subnet proxy target updates for resources await handleMessagesForClientResources( @@ -996,11 +976,26 @@ async function handleMessagesForClientSites( userId: string | null; orgId: string; }, - olmId: string, sitesToAdd: number[], sitesToRemove: number[], trx: Transaction | typeof db = db ): Promise { + // 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) { logger.warn( `Client ${client.clientId} missing subnet or pubKey, skipping peer updates` @@ -1021,9 +1016,9 @@ async function handleMessagesForClientSites( .leftJoin(newts, eq(sites.siteId, newts.siteId)) .where(inArray(sites.siteId, allSiteIds)); - let newtJobs: Promise[] = []; - let olmJobs: Promise[] = []; - let exitNodeJobs: Promise[] = []; + const newtJobs: Promise[] = []; + const olmJobs: Promise[] = []; + const exitNodeJobs: Promise[] = []; for (const siteData of sitesData) { const site = siteData.sites; @@ -1130,18 +1125,8 @@ async function handleMessagesForClientResources( resourcesToRemove: number[], trx: Transaction | typeof db = db ): Promise { - // Group resources by site - const resourcesBySite = new Map(); - - for (const resource of allNewResources) { - if (!resourcesBySite.has(resource.siteId)) { - resourcesBySite.set(resource.siteId, []); - } - resourcesBySite.get(resource.siteId)!.push(resource); - } - - let proxyJobs: Promise[] = []; - let olmJobs: Promise[] = []; + const proxyJobs: Promise[] = []; + const olmJobs: Promise[] = []; // Handle additions if (resourcesToAdd.length > 0) { diff --git a/server/lib/telemetry.ts b/server/lib/telemetry.ts index 13ba1c95..fda59f39 100644 --- a/server/lib/telemetry.ts +++ b/server/lib/telemetry.ts @@ -2,9 +2,9 @@ import { PostHog } from "posthog-node"; import config from "./config"; import { getHostMeta } from "./hostMeta"; 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 { eq, count, notInArray, and } from "drizzle-orm"; +import { eq, count, notInArray, and, isNotNull, isNull } from "drizzle-orm"; import { APP_VERSION } from "./consts"; import crypto from "crypto"; import { UserType } from "@server/types/UserTypes"; @@ -25,7 +25,7 @@ class TelemetryClient { return; } - if (build !== "oss") { + if (build === "saas") { return; } @@ -41,14 +41,18 @@ class TelemetryClient { this.client?.shutdown(); }); - this.sendStartupEvents().catch((err) => { - logger.error("Failed to send startup telemetry:", err); - }); + this.sendStartupEvents() + .catch((err) => { + logger.error("Failed to send startup telemetry:", err); + }) + .then(() => { + logger.debug("Successfully sent startup telemetry data"); + }); this.startAnalyticsInterval(); 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) { logger.info( @@ -60,9 +64,13 @@ class TelemetryClient { private startAnalyticsInterval() { this.intervalId = setInterval( () => { - this.collectAndSendAnalytics().catch((err) => { - logger.error("Failed to collect analytics:", err); - }); + this.collectAndSendAnalytics() + .catch((err) => { + logger.error("Failed to collect analytics:", err); + }) + .then(() => { + logger.debug("Successfully sent analytics data"); + }); }, 48 * 60 * 60 * 1000 ); @@ -99,9 +107,14 @@ class TelemetryClient { const [resourcesCount] = await db .select({ count: count() }) .from(resources); - const [clientsCount] = await db + const [userDevicesCount] = await db .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 [onlineSitesCount] = await db .select({ count: count() }) @@ -146,6 +159,24 @@ class TelemetryClient { 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 { numSites: sitesCount.count, numUsers: usersCount.count, @@ -153,7 +184,11 @@ class TelemetryClient { numUsersOidc: usersOidcCount.count, numOrganizations: orgsCount.count, numResources: resourcesCount.count, - numClients: clientsCount.count, + numPrivateResources: numPrivResources, + numPrivateResourceAliases: numPrivResourceAliases, + numPrivateResourceHosts: numPrivResourceHosts, + numUserDevices: userDevicesCount.count, + numMachineClients: machineClients.count, numIdentityProviders: idpCount.count, numSitesOnline: onlineSitesCount.count, resources: resourceDetails, @@ -196,7 +231,7 @@ class TelemetryClient { logger.debug("Sending enterprise startup telemetry payload:", { payload }); - // this.client.capture(payload); + this.client.capture(payload); } if (build === "oss") { @@ -246,7 +281,12 @@ class TelemetryClient { num_users_oidc: stats.numUsersOidc, num_organizations: stats.numOrganizations, 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_sites_online: stats.numSitesOnline, num_resources_sso_enabled: stats.resources.filter( diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 8060ccad..82568216 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -823,7 +823,7 @@ export async function getTraefikConfig( (cert) => cert.queriedDomain === lp.fullDomain ); if (!matchingCert) { - logger.warn( + logger.debug( `No matching certificate found for login page domain: ${lp.fullDomain}` ); continue; diff --git a/server/private/license/license.ts b/server/private/license/license.ts index db3db509..f8f774c6 100644 --- a/server/private/license/license.ts +++ b/server/private/license/license.ts @@ -84,14 +84,11 @@ LQIDAQAB -----END PUBLIC KEY-----`; constructor(private hostMeta: HostMeta) { - setInterval( - async () => { - this.doRecheck = true; - await this.check(); - this.doRecheck = false; - }, - 1000 * this.phoneHomeInterval - ); + setInterval(async () => { + this.doRecheck = true; + await this.check(); + this.doRecheck = false; + }, 1000 * this.phoneHomeInterval); } public listKeys(): LicenseKeyCache[] { @@ -242,7 +239,9 @@ LQIDAQAB // First failure: fail silently logger.error("Error communicating with license server:"); 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 this.statusCache.get( this.statusKey diff --git a/server/private/routers/auditLogs/exportAccessAuditLog.ts b/server/private/routers/auditLogs/exportAccessAuditLog.ts index fbeca932..7e912f8c 100644 --- a/server/private/routers/auditLogs/exportAccessAuditLog.ts +++ b/server/private/routers/auditLogs/exportAccessAuditLog.ts @@ -22,9 +22,11 @@ import logger from "@server/logger"; import { queryAccessAuditLogsParams, queryAccessAuditLogsQuery, - queryAccess + queryAccess, + countAccessQuery } from "./queryAccessAuditLog"; import { generateCSV } from "@server/routers/auditLogs/generateCSV"; +import { MAX_EXPORT_LIMIT } from "@server/routers/auditLogs"; registry.registerPath({ method: "get", @@ -65,6 +67,15 @@ export async function exportAccessAuditLogs( } 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); diff --git a/server/private/routers/auditLogs/exportActionAuditLog.ts b/server/private/routers/auditLogs/exportActionAuditLog.ts index 1fc4d743..d8987916 100644 --- a/server/private/routers/auditLogs/exportActionAuditLog.ts +++ b/server/private/routers/auditLogs/exportActionAuditLog.ts @@ -22,9 +22,11 @@ import logger from "@server/logger"; import { queryActionAuditLogsParams, queryActionAuditLogsQuery, - queryAction + queryAction, + countActionQuery } from "./queryActionAuditLog"; import { generateCSV } from "@server/routers/auditLogs/generateCSV"; +import { MAX_EXPORT_LIMIT } from "@server/routers/auditLogs"; registry.registerPath({ method: "get", @@ -65,6 +67,15 @@ export async function exportActionAuditLogs( } 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); diff --git a/server/private/routers/auditLogs/queryAccessAuditLog.ts b/server/private/routers/auditLogs/queryAccessAuditLog.ts index 5d3162aa..eb0cae5d 100644 --- a/server/private/routers/auditLogs/queryAccessAuditLog.ts +++ b/server/private/routers/auditLogs/queryAccessAuditLog.ts @@ -24,6 +24,7 @@ import { fromError } from "zod-validation-error"; import { QueryAccessAuditLogResponse } from "@server/routers/auditLogs/types"; import response from "@server/lib/response"; import logger from "@server/logger"; +import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; export const queryAccessAuditLogsQuery = z.object({ // iso string just validate its a parseable date @@ -32,7 +33,14 @@ export const queryAccessAuditLogsQuery = z.object({ .refine((val) => !isNaN(Date.parse(val)), { 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 .string() .refine((val) => !isNaN(Date.parse(val)), { diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index eca583b4..518eb982 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -24,6 +24,7 @@ import { fromError } from "zod-validation-error"; import { QueryActionAuditLogResponse } from "@server/routers/auditLogs/types"; import response from "@server/lib/response"; import logger from "@server/logger"; +import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; export const queryActionAuditLogsQuery = z.object({ // iso string just validate its a parseable date @@ -32,7 +33,14 @@ export const queryActionAuditLogsQuery = z.object({ .refine((val) => !isNaN(Date.parse(val)), { 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 .string() .refine((val) => !isNaN(Date.parse(val)), { diff --git a/server/private/routers/misc/sendSupportEmail.ts b/server/private/routers/misc/sendSupportEmail.ts index 404a2501..cd37560d 100644 --- a/server/private/routers/misc/sendSupportEmail.ts +++ b/server/private/routers/misc/sendSupportEmail.ts @@ -66,6 +66,7 @@ export async function sendSupportEmail( { name: req.user?.email || "Support User", to: "support@pangolin.net", + replyTo: req.user?.email || undefined, from: config.getNoReplyEmail(), subject: `Support Request: ${subject}` } diff --git a/server/routers/auditLogs/exportRequestAuditLog.ts b/server/routers/auditLogs/exportRequestAuditLog.ts index 9e55cfc4..8b70ec5e 100644 --- a/server/routers/auditLogs/exportRequestAuditLog.ts +++ b/server/routers/auditLogs/exportRequestAuditLog.ts @@ -9,17 +9,23 @@ import logger from "@server/logger"; import { queryAccessAuditLogsQuery, queryRequestAuditLogsParams, - queryRequest + queryRequest, + countRequestQuery } from "./queryRequestAuditLog"; import { generateCSV } from "./generateCSV"; +export const MAX_EXPORT_LIMIT = 50_000; + registry.registerPath({ method: "get", path: "/org/{orgId}/logs/request", description: "Query the request audit log for an organization", tags: [OpenAPITags.Org], request: { - query: queryAccessAuditLogsQuery, + query: queryAccessAuditLogsQuery.omit({ + limit: true, + offset: true + }), params: queryRequestAuditLogsParams }, responses: {} @@ -53,9 +59,19 @@ export async function exportRequestAuditLogs( 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 log = await baseQuery.limit(data.limit).offset(data.offset); + const log = await baseQuery.limit(MAX_EXPORT_LIMIT); const csvData = generateCSV(log); diff --git a/server/routers/auditLogs/queryRequestAnalytics.ts b/server/routers/auditLogs/queryRequestAnalytics.ts index 9e4ea17e..a765f176 100644 --- a/server/routers/auditLogs/queryRequestAnalytics.ts +++ b/server/routers/auditLogs/queryRequestAnalytics.ts @@ -2,7 +2,7 @@ import { db, requestAuditLog, driver } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } 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 { z } from "zod"; import createHttpError from "http-errors"; @@ -10,6 +10,7 @@ import HttpCode from "@server/types/HttpCode"; import { fromError } from "zod-validation-error"; import response from "@server/lib/response"; import logger from "@server/logger"; +import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; const queryAccessAuditLogsQuery = z.object({ // 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" }) .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 .string() .refine((val) => !isNaN(Date.parse(val)), { @@ -55,15 +63,10 @@ type Q = z.infer; async function query(query: Q) { let baseConditions = and( 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) { baseConditions = and( baseConditions, diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index 663ad787..9cedec63 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error"; import { QueryRequestAuditLogResponse } from "@server/routers/auditLogs/types"; import response from "@server/lib/response"; import logger from "@server/logger"; +import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; export const queryAccessAuditLogsQuery = z.object({ // iso string just validate its a parseable date @@ -19,7 +20,14 @@ export const queryAccessAuditLogsQuery = z.object({ .refine((val) => !isNaN(Date.parse(val)), { 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 .string() .refine((val) => !isNaN(Date.parse(val)), { diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 1cf97f98..1343bdaa 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -148,7 +148,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) { } } -export function logRequestAudit( +export async function logRequestAudit( data: { action: boolean; reason: number; @@ -174,14 +174,13 @@ export function logRequestAudit( } ) { try { - // Quick synchronous check - if org has 0 retention, skip immediately + // Check retention before buffering any logs if (data.orgId) { - const cached = cache.get(`org_${data.orgId}_retentionDays`); - if (cached === 0) { + const retentionDays = await getRetentionDays(data.orgId); + if (retentionDays === 0) { // do not log return; } - // If not cached or > 0, we'll log it (async retention check happens in background) } let actorType: string | undefined; @@ -261,16 +260,6 @@ export function logRequestAudit( } else { scheduleFlush(); } - - // Async retention check in background (don't await) - if ( - data.orgId && - cache.get(`org_${data.orgId}_retentionDays`) === undefined - ) { - getRetentionDays(data.orgId).catch((err) => - logger.error("Error checking retention days:", err) - ); - } } catch (error) { logger.error(error); } diff --git a/server/routers/client/targets.ts b/server/routers/client/targets.ts index b7b91925..653a2578 100644 --- a/server/routers/client/targets.ts +++ b/server/routers/client/targets.ts @@ -4,21 +4,48 @@ import { Alias, SubnetProxyTarget } from "@server/lib/ip"; import logger from "@server/logger"; import { eq } from "drizzle-orm"; +const BATCH_SIZE = 50; +const BATCH_DELAY_MS = 50; + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function chunkArray(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[]) { - await sendToClient(newtId, { - type: `newt/wg/targets/add`, - data: targets - }); + const batches = chunkArray(targets, BATCH_SIZE); + for (let i = 0; i < batches.length; i++) { + if (i > 0) { + await sleep(BATCH_DELAY_MS); + } + await sendToClient(newtId, { + type: `newt/wg/targets/add`, + data: batches[i] + }); + } } export async function removeTargets( newtId: string, targets: SubnetProxyTarget[] ) { - await sendToClient(newtId, { - type: `newt/wg/targets/remove`, - data: targets - }); + const batches = chunkArray(targets, BATCH_SIZE); + for (let i = 0; i < batches.length; i++) { + if (i > 0) { + await sleep(BATCH_DELAY_MS); + } + await sendToClient(newtId, { + type: `newt/wg/targets/remove`, + data: batches[i] + }); + } } export async function updateTargets( @@ -28,12 +55,24 @@ export async function updateTargets( newTargets: SubnetProxyTarget[]; } ) { - await sendToClient(newtId, { - type: `newt/wg/targets/update`, - data: targets - }).catch((error) => { - logger.warn(`Error sending message:`, error); - }); + const oldBatches = chunkArray(targets.oldTargets, BATCH_SIZE); + const newBatches = chunkArray(targets.newTargets, BATCH_SIZE); + const maxBatches = Math.max(oldBatches.length, newBatches.length); + + 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( diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index ba3ab7ad..488ef75b 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -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) { return next( diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 878d61fa..6301bb6d 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -352,6 +352,14 @@ authenticated.post( user.inviteUser ); +authenticated.delete( + "/org/:orgId/invitations/:inviteId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.removeInvitation), + logActionAudit(ActionsEnum.removeInvitation), + user.removeInvitation +); + authenticated.get( "/resource/:resourceId/roles", verifyApiKeyResourceAccess, diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 77e49a20..c7f2131e 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -346,6 +346,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { type: "newt/wg/connect", data: { endpoint: `${exitNode.endpoint}:${exitNode.listenPort}`, + relayPort: config.getRawConfig().gerbil.clients_start_port, publicKey: exitNode.publicKey, serverIP: exitNode.address.split("/")[0], tunnelIP: siteSubnet.split("/")[0], diff --git a/server/routers/olm/getOlmToken.ts b/server/routers/olm/getOlmToken.ts index 3852b00e..b6dc8148 100644 --- a/server/routers/olm/getOlmToken.ts +++ b/server/routers/olm/getOlmToken.ts @@ -197,6 +197,7 @@ export async function getOlmToken( const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => { return { publicKey: exitNode.publicKey, + relayPort: config.getRawConfig().gerbil.clients_start_port, endpoint: exitNode.endpoint }; }); diff --git a/server/routers/olm/handleOlmRelayMessage.ts b/server/routers/olm/handleOlmRelayMessage.ts index 595b35ba..88886cd1 100644 --- a/server/routers/olm/handleOlmRelayMessage.ts +++ b/server/routers/olm/handleOlmRelayMessage.ts @@ -4,6 +4,7 @@ import { clients, clientSitesAssociationsCache, Olm } from "@server/db"; import { and, eq } from "drizzle-orm"; import { updatePeer as newtUpdatePeer } from "../newt/peers"; import logger from "@server/logger"; +import config from "@server/lib/config"; export const handleOlmRelayMessage: MessageHandler = async (context) => { const { message, client: c, sendToClient } = context; @@ -88,7 +89,8 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => { type: "olm/wg/peer/relay", data: { siteId: siteId, - relayEndpoint: exitNode.endpoint + relayEndpoint: exitNode.endpoint, + relayPort: config.getRawConfig().gerbil.clients_start_port } }, broadcast: false, diff --git a/server/routers/olm/peers.ts b/server/routers/olm/peers.ts index 4aa8edd7..e164b257 100644 --- a/server/routers/olm/peers.ts +++ b/server/routers/olm/peers.ts @@ -1,5 +1,6 @@ import { sendToClient } from "#dynamic/routers/ws"; import { db, olms } from "@server/db"; +import config from "@server/lib/config"; import logger from "@server/logger"; import { eq } from "drizzle-orm"; import { Alias } from "yaml"; @@ -156,6 +157,7 @@ export async function initPeerAddHandshake( siteId: peer.siteId, exitNode: { publicKey: peer.exitNode.publicKey, + relayPort: config.getRawConfig().gerbil.clients_start_port, endpoint: peer.exitNode.endpoint } } diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index e0e42754..f1d06566 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -31,7 +31,12 @@ import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsFor const createOrgSchema = z.strictObject({ orgId: z.string(), 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({ @@ -81,15 +86,6 @@ export async function createOrg( 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 // make sure the subnet is unique // const subnetExists = await db diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index e5719e7f..f2e343cd 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -10,7 +10,7 @@ import { userSiteResources } from "@server/db"; 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 response from "@server/lib/response"; import logger from "@server/logger"; @@ -45,7 +45,10 @@ const createSiteResourceSchema = z .optional(), userIds: z.array(z.string()), roleIds: z.array(z.int()), - clientIds: z.array(z.int()) + clientIds: z.array(z.int()), + tcpPortRangeString: portRangeStringSchema, + udpPortRangeString: portRangeStringSchema, + disableIcmp: z.boolean().optional() }) .strict() .refine( @@ -53,7 +56,8 @@ const createSiteResourceSchema = z if (data.mode === "host") { // Check if it's a valid IP address using zod (v4 or v6) 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; if (isValidIP) { @@ -80,7 +84,8 @@ const createSiteResourceSchema = z if (data.mode === "cidr") { // Check if it's a valid CIDR (v4 or v6) 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; return isValidCIDR; } @@ -152,7 +157,10 @@ export async function createSiteResource( alias, userIds, roleIds, - clientIds + clientIds, + tcpPortRangeString, + udpPortRangeString, + disableIcmp } = parsedBody.data; // Verify the site exists and belongs to the org @@ -237,7 +245,10 @@ export async function createSiteResource( destination, enabled, alias, - aliasAddress + aliasAddress, + tcpPortRangeString, + udpPortRangeString, + disableIcmp }) .returning(); diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index f6975cd2..7b2e0233 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -97,6 +97,9 @@ export async function listAllSiteResourcesByOrg( destination: siteResources.destination, enabled: siteResources.enabled, alias: siteResources.alias, + tcpPortRangeString: siteResources.tcpPortRangeString, + udpPortRangeString: siteResources.udpPortRangeString, + disableIcmp: siteResources.disableIcmp, siteName: sites.name, siteNiceId: sites.niceId, siteAddress: sites.address diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index efc4939b..376d9c0a 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -23,7 +23,8 @@ import { updatePeerData, updateTargets } from "@server/routers/client/targets"; import { generateAliasConfig, generateRemoteSubnets, - generateSubnetProxyTargets + generateSubnetProxyTargets, + portRangeStringSchema } from "@server/lib/ip"; import { getClientSiteResourceAccess, @@ -55,14 +56,18 @@ const updateSiteResourceSchema = z .nullish(), userIds: z.array(z.string()), roleIds: z.array(z.int()), - clientIds: z.array(z.int()) + clientIds: z.array(z.int()), + tcpPortRangeString: portRangeStringSchema, + udpPortRangeString: portRangeStringSchema, + disableIcmp: z.boolean().optional() }) .strict() .refine( (data) => { if (data.mode === "host" && data.destination) { 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; if (isValidIP) { @@ -89,7 +94,8 @@ const updateSiteResourceSchema = z if (data.mode === "cidr" && data.destination) { // Check if it's a valid CIDR (v4 or v6) 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; return isValidCIDR; } @@ -158,7 +164,10 @@ export async function updateSiteResource( enabled, userIds, roleIds, - clientIds + clientIds, + tcpPortRangeString, + udpPortRangeString, + disableIcmp } = parsedBody.data; const [site] = await db @@ -224,7 +233,10 @@ export async function updateSiteResource( mode: mode, destination: destination, enabled: enabled, - alias: alias && alias.trim() ? alias : null + alias: alias && alias.trim() ? alias : null, + tcpPortRangeString: tcpPortRangeString, + udpPortRangeString: udpPortRangeString, + disableIcmp: disableIcmp }) .where( and( @@ -346,10 +358,18 @@ export async function handleMessagingForUpdatedSiteResource( const aliasChanged = existingSiteResource && 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 (destinationChanged || aliasChanged) { + if (destinationChanged || aliasChanged || portRangesChanged) { const [newt] = await trx .select() .from(newts) @@ -363,7 +383,7 @@ export async function handleMessagingForUpdatedSiteResource( } // Only update targets on newt if destination changed - if (destinationChanged) { + if (destinationChanged || portRangesChanged) { const oldTargets = generateSubnetProxyTargets( existingSiteResource, mergedAllClients diff --git a/server/routers/user/removeInvitation.ts b/server/routers/user/removeInvitation.ts index 6a000afc..ab6a96d2 100644 --- a/server/routers/user/removeInvitation.ts +++ b/server/routers/user/removeInvitation.ts @@ -8,12 +8,24 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; const removeInvitationParamsSchema = z.strictObject({ orgId: 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( req: Request, res: Response, diff --git a/server/setup/ensureSetupToken.ts b/server/setup/ensureSetupToken.ts index 64298029..87b86321 100644 --- a/server/setup/ensureSetupToken.ts +++ b/server/setup/ensureSetupToken.ts @@ -16,11 +16,23 @@ function generateToken(): string { 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 { const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; 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() { try { // Check if a server admin already exists @@ -38,17 +50,48 @@ export async function ensureSetupToken() { } // Check if a setup token already exists - const existingTokens = await db + const [existingToken] = await db .select() .from(setupTokens) .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 (existingTokens.length > 0) { - console.log("=== SETUP TOKEN EXISTS ==="); - console.log("Token:", existingTokens[0].token); - console.log("Use this token on the initial setup page"); - console.log("================================"); + if (existingToken) { + showSetupToken(existingToken.token, "EXISTS"); return; } @@ -64,10 +107,7 @@ export async function ensureSetupToken() { dateUsed: null }); - console.log("=== SETUP TOKEN GENERATED ==="); - console.log("Token:", token); - console.log("Use this token on the initial setup page"); - console.log("================================"); + showSetupToken(token, "GENERATED"); } catch (error) { console.error("Failed to ensure setup token:", error); throw error; diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx index a38f3b86..e5250bea 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/ExitNodesTable.tsx @@ -304,7 +304,7 @@ export default function ExitNodesTable({ setSelectedNode(null); }} dialog={ -
+

{t("remoteExitNodeQuestionRemove")}

{t("remoteExitNodeMessageRemove")}

diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index e391922f..97dd4a03 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -289,7 +289,7 @@ export default function GeneralPage() { setIsDeleteModalOpen(val); }} dialog={ -
+

{t("orgQuestionRemove")}

{t("orgMessageRemove")}

@@ -303,7 +303,7 @@ export default function GeneralPage() { open={isSecurityPolicyConfirmOpen} setOpen={setIsSecurityPolicyConfirmOpen} dialog={ -
+

{t("securityPolicyChangeDescription")}

} diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index 1e7f2476..d5b12ddb 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -1,16 +1,12 @@ "use client"; import { Button } from "@app/components/ui/button"; 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 { 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 { LogDataTable } from "@app/components/LogDataTable"; import { ColumnDef } from "@tanstack/react-table"; import { DateTimeValue } from "@app/components/DateTimePicker"; import { ArrowUpRight, Key, User } from "lucide-react"; @@ -21,21 +17,22 @@ import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusCo import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { build } from "@server/build"; 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() { - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const router = useRouter(); const searchParams = useSearchParams(); const api = createApiClient(useEnvContext()); const t = useTranslations(); - const { env } = useEnvContext(); const { orgId } = useParams(); const subscription = useSubscriptionStatusContext(); const { isUnlocked } = useLicenseStatusContext(); const [rows, setRows] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); - const [isExporting, setIsExporting] = useState(false); + const [isExporting, startTransition] = useTransition(); const [filterAttributes, setFilterAttributes] = useState<{ actors: string[]; resources: { @@ -70,9 +67,7 @@ export default function GeneralPage() { const [isLoading, setIsLoading] = useState(false); // Initialize page size from storage or default - const [pageSize, setPageSize] = useState(() => { - return getStoredPageSize("access-audit-logs", 20); - }); + const [pageSize, setPageSize] = useStoredPageSize("access-audit-logs", 20); // Set default date range to last 24 hours const getDefaultDateRange = () => { @@ -91,11 +86,11 @@ export default function GeneralPage() { } const now = new Date(); - const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const lastWeek = getSevenDaysAgo(); return { startDate: { - date: yesterday + date: lastWeek }, endDate: { date: now @@ -148,7 +143,6 @@ export default function GeneralPage() { // Handle page size changes const handlePageSizeChange = (newPageSize: number) => { setPageSize(newPageSize); - setStoredPageSize(newPageSize, "access-audit-logs"); setCurrentPage(0); // Reset to first page when changing page size queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); }; @@ -309,8 +303,6 @@ export default function GeneralPage() { const exportData = async () => { try { - setIsExporting(true); - // Prepare query params for export const params: any = { timeStart: dateRange.startDate?.date @@ -339,11 +331,21 @@ export default function GeneralPage() { document.body.appendChild(link); link.click(); link.parentNode?.removeChild(link); - setIsExporting(false); } 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({ title: t("error"), - description: t("exportError"), + description: apiErrorMessage ?? t("exportError"), variant: "destructive" }); } @@ -631,7 +633,7 @@ export default function GeneralPage() { title={t("accessLogs")} onRefresh={refreshData} isRefreshing={isRefreshing} - onExport={exportData} + onExport={() => startTransition(exportData)} isExporting={isExporting} onDateRangeChange={handleDateRangeChange} dateRange={{ diff --git a/src/app/[orgId]/settings/logs/action/page.tsx b/src/app/[orgId]/settings/logs/action/page.tsx index 68f67b07..344866bb 100644 --- a/src/app/[orgId]/settings/logs/action/page.tsx +++ b/src/app/[orgId]/settings/logs/action/page.tsx @@ -1,32 +1,28 @@ "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 { DateTimeValue } from "@app/components/DateTimePicker"; +import { LogDataTable } from "@app/components/LogDataTable"; 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 { 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() { - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const router = useRouter(); const api = createApiClient(useEnvContext()); const t = useTranslations(); - const { env } = useEnvContext(); const { orgId } = useParams(); const searchParams = useSearchParams(); const subscription = useSubscriptionStatusContext(); @@ -34,7 +30,7 @@ export default function GeneralPage() { const [rows, setRows] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); - const [isExporting, setIsExporting] = useState(false); + const [isExporting, startTransition] = useTransition(); const [filterAttributes, setFilterAttributes] = useState<{ actors: string[]; actions: string[]; @@ -58,9 +54,7 @@ export default function GeneralPage() { const [isLoading, setIsLoading] = useState(false); // Initialize page size from storage or default - const [pageSize, setPageSize] = useState(() => { - return getStoredPageSize("action-audit-logs", 20); - }); + const [pageSize, setPageSize] = useStoredPageSize("action-audit-logs", 20); // Set default date range to last 24 hours const getDefaultDateRange = () => { @@ -79,11 +73,11 @@ export default function GeneralPage() { } const now = new Date(); - const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const lastWeek = getSevenDaysAgo(); return { startDate: { - date: yesterday + date: lastWeek }, endDate: { date: now @@ -136,7 +130,6 @@ export default function GeneralPage() { // Handle page size changes const handlePageSizeChange = (newPageSize: number) => { setPageSize(newPageSize); - setStoredPageSize(newPageSize, "action-audit-logs"); setCurrentPage(0); // Reset to first page when changing page size queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); }; @@ -293,8 +286,6 @@ export default function GeneralPage() { const exportData = async () => { try { - setIsExporting(true); - // Prepare query params for export const params: any = { timeStart: dateRange.startDate?.date @@ -323,11 +314,21 @@ export default function GeneralPage() { document.body.appendChild(link); link.click(); link.parentNode?.removeChild(link); - setIsExporting(false); } 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({ title: t("error"), - description: t("exportError"), + description: apiErrorMessage ?? t("exportError"), variant: "destructive" }); } @@ -484,7 +485,7 @@ export default function GeneralPage() { searchColumn="action" onRefresh={refreshData} isRefreshing={isRefreshing} - onExport={exportData} + onExport={() => startTransition(exportData)} isExporting={isExporting} onDateRangeChange={handleDateRangeChange} dateRange={{ diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index 42cfec57..741dd994 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -1,34 +1,32 @@ "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 { DateTimeValue } from "@app/components/DateTimePicker"; +import { LogDataTable } from "@app/components/LogDataTable"; 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() { const router = useRouter(); const api = createApiClient(useEnvContext()); const t = useTranslations(); - const { env } = useEnvContext(); const { orgId } = useParams(); const searchParams = useSearchParams(); const [rows, setRows] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); - const [isExporting, setIsExporting] = useState(false); + const [isExporting, startTransition] = useTransition(); // Pagination state const [totalCount, setTotalCount] = useState(0); @@ -36,9 +34,7 @@ export default function GeneralPage() { const [isLoading, setIsLoading] = useState(false); // Initialize page size from storage or default - const [pageSize, setPageSize] = useState(() => { - return getStoredPageSize("request-audit-logs", 20); - }); + const [pageSize, setPageSize] = useStoredPageSize("request-audit-logs", 20); const [filterAttributes, setFilterAttributes] = useState<{ actors: string[]; @@ -95,11 +91,11 @@ export default function GeneralPage() { } const now = new Date(); - const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000); + const lastWeek = getSevenDaysAgo(); return { startDate: { - date: yesterday + date: lastWeek }, endDate: { date: now @@ -152,7 +148,6 @@ export default function GeneralPage() { // Handle page size changes const handlePageSizeChange = (newPageSize: number) => { setPageSize(newPageSize); - setStoredPageSize(newPageSize, "request-audit-logs"); setCurrentPage(0); // Reset to first page when changing page size queryDateTime(dateRange.startDate, dateRange.endDate, 0, newPageSize); }; @@ -302,8 +297,6 @@ export default function GeneralPage() { const exportData = async () => { try { - setIsExporting(true); - // Prepare query params for export const params: any = { timeStart: dateRange.startDate?.date @@ -335,11 +328,21 @@ export default function GeneralPage() { document.body.appendChild(link); link.click(); link.parentNode?.removeChild(link); - setIsExporting(false); } 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({ title: t("error"), - description: t("exportError"), + description: apiErrorMessage ?? t("exportError"), variant: "destructive" }); } @@ -773,7 +776,7 @@ export default function GeneralPage() { searchColumn="host" onRefresh={refreshData} isRefreshing={isRefreshing} - onExport={exportData} + onExport={() => startTransition(exportData)} isExporting={isExporting} onDateRangeChange={handleDateRangeChange} dateRange={{ diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index 49ccb97f..eacab1d2 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -67,7 +67,10 @@ export default async function ClientResourcesPage( // destinationPort: siteResource.destinationPort, alias: siteResource.alias || null, siteNiceId: siteResource.siteNiceId, - niceId: siteResource.niceId + niceId: siteResource.niceId, + tcpPortRangeString: siteResource.tcpPortRangeString || null, + udpPortRangeString: siteResource.udpPortRangeString || null, + disableIcmp: siteResource.disableIcmp || false, }; } ); diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index ed1080f3..9b2c120e 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -225,7 +225,7 @@ export default function GeneralForm() { name: data.name, niceId: data.niceId, subdomain: data.subdomain, - fullDomain: resource.fullDomain, + fullDomain: updated.fullDomain, proxyPort: data.proxyPort // ...(!resource.http && { // enableProxy: data.enableProxy diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx index 39badea4..78a1b896 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/rules/page.tsx @@ -449,15 +449,16 @@ export default function ResourceRules(props: { type="number" onClick={(e) => e.currentTarget.focus()} onBlur={(e) => { - const parsed = z + const parsed = z.coerce + .number() .int() .optional() .safeParse(e.target.value); - if (!parsed.data) { + if (!parsed.success) { toast({ variant: "destructive", - title: t("rulesErrorInvalidIpAddress"), // correct priority or IP? + title: t("rulesErrorInvalidPriority"), // correct priority or IP? description: t( "rulesErrorInvalidPriorityDescription" ) diff --git a/src/app/admin/license/page.tsx b/src/app/admin/license/page.tsx index ac6d3e67..4e0586bd 100644 --- a/src/app/admin/license/page.tsx +++ b/src/app/admin/license/page.tsx @@ -315,7 +315,7 @@ export default function LicensePage() { setSelectedLicenseKey(null); }} dialog={ -
+

{t("licenseQuestionRemove")}

{t("licenseMessageRemove")} @@ -360,7 +360,8 @@ export default function LicensePage() {

- {t("licensed")} + {t("licensed") + + `${licenseStatus?.tier === "personal" ? ` (${t("personalUseOnly")})` : ""}`}
) : ( diff --git a/src/app/admin/users/AdminUsersTable.tsx b/src/app/admin/users/AdminUsersTable.tsx index efcf9484..1c7d1b7f 100644 --- a/src/app/admin/users/AdminUsersTable.tsx +++ b/src/app/admin/users/AdminUsersTable.tsx @@ -243,7 +243,7 @@ export default function UsersTable({ users }: Props) { setSelected(null); }} dialog={ -
+

{t("userQuestionRemove")}

{t("userMessageRemove")}

diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx index 70439824..6a72006b 100644 --- a/src/app/auth/layout.tsx +++ b/src/app/auth/layout.tsx @@ -23,6 +23,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { const t = await getTranslations(); let hideFooter = false; + let licenseStatus: GetLicenseStatusResponse | null = null; if (build == "enterprise") { const licenseStatusRes = await cache( async () => @@ -30,10 +31,12 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { "/license/status" ) )(); + licenseStatus = licenseStatusRes.data.data; if ( env.branding.hideAuthLayoutFooter && licenseStatusRes.data.data.isHostLicensed && - licenseStatusRes.data.data.isLicenseValid + licenseStatusRes.data.data.isLicenseValid && + licenseStatusRes.data.data.tier !== "personal" ) { hideFooter = true; } @@ -83,6 +86,23 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { ? t("enterpriseEdition") : t("pangolinCloud")} + {build === "enterprise" && + licenseStatus?.isHostLicensed && + licenseStatus?.isLicenseValid && + licenseStatus?.tier === "personal" ? ( + <> + + {t("personalUseOnly")} + + ) : null} + {build === "enterprise" && + (!licenseStatus?.isHostLicensed || + !licenseStatus?.isLicenseValid) ? ( + <> + + {t("unlicensed")} + + ) : null} {build === "saas" && ( <> diff --git a/src/components/AdminIdpTable.tsx b/src/components/AdminIdpTable.tsx index 76a0fdd7..75a7c545 100644 --- a/src/components/AdminIdpTable.tsx +++ b/src/components/AdminIdpTable.tsx @@ -196,7 +196,7 @@ export default function IdpTable({ idps }: Props) { setSelectedIdp(null); }} dialog={ -
+

{t("idpQuestionRemove", { name: selectedIdp.name diff --git a/src/components/AdminUsersTable.tsx b/src/components/AdminUsersTable.tsx index 9c741cee..f12d21fc 100644 --- a/src/components/AdminUsersTable.tsx +++ b/src/components/AdminUsersTable.tsx @@ -269,7 +269,7 @@ export default function UsersTable({ users }: Props) { - {r.type !== "internal" && ( + {r.type === "internal" && ( { generatePasswordResetCode(r.id); @@ -313,7 +313,7 @@ export default function UsersTable({ users }: Props) { setSelected(null); }} dialog={ -

+

{t("userQuestionRemove", { selectedUser: diff --git a/src/components/ApiKeysTable.tsx b/src/components/ApiKeysTable.tsx index c3202277..8987fa2c 100644 --- a/src/components/ApiKeysTable.tsx +++ b/src/components/ApiKeysTable.tsx @@ -182,7 +182,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { setSelected(null); }} dialog={ -

+

{t("apiKeysQuestionRemove")}

{t("apiKeysMessageRemove")}

diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index a5e257c7..758b6e12 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -41,6 +41,9 @@ export type InternalResourceRow = { // destinationPort: number | null; alias: string | null; niceId: string; + tcpPortRangeString: string | null; + udpPortRangeString: string | null; + disableIcmp: boolean; }; type ClientResourcesTableProps = { @@ -284,7 +287,7 @@ export default function ClientResourcesTable({ setSelectedInternalResource(null); }} dialog={ -
+

{t("resourceQuestionRemove")}

{t("resourceMessageRemove")}

diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 91ef26da..894315b8 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -42,15 +42,14 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; +import { Switch } from "@app/components/ui/switch"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { cn } from "@app/lib/cn"; import { orgQueries } from "@app/lib/queries"; import { zodResolver } from "@hookform/resolvers/zod"; -import { ListClientsResponse } from "@server/routers/client/listClients"; import { ListSitesResponse } from "@server/routers/site"; -import { ListUsersResponse } from "@server/routers/user"; import { UserType } from "@server/types/UserTypes"; import { useQuery } from "@tanstack/react-query"; import { AxiosResponse } from "axios"; @@ -59,6 +58,82 @@ import { useTranslations } from "next-intl"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; 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]; @@ -103,6 +178,9 @@ export default function CreateInternalResourceDialog({ // .max(65535, t("createInternalResourceDialogDestinationPortMax")) // .nullish(), alias: z.string().nullish(), + tcpPortRangeString: portRangeStringSchema, + udpPortRangeString: portRangeStringSchema, + disableIcmp: z.boolean().optional(), roles: z .array( z.object({ @@ -209,6 +287,12 @@ export default function CreateInternalResourceDialog({ number | null >(null); + // Port restriction UI state - default to "all" (*) for new resources + const [tcpPortMode, setTcpPortMode] = useState("all"); + const [udpPortMode, setUdpPortMode] = useState("all"); + const [tcpCustomPorts, setTcpCustomPorts] = useState(""); + const [udpCustomPorts, setUdpCustomPorts] = useState(""); + const availableSites = sites.filter( (site) => site.type === "newt" && site.subnet ); @@ -224,6 +308,9 @@ export default function CreateInternalResourceDialog({ destination: "", // destinationPort: undefined, alias: "", + tcpPortRangeString: "*", + udpPortRangeString: "*", + disableIcmp: false, roles: [], users: [], clients: [] @@ -232,6 +319,17 @@ export default function CreateInternalResourceDialog({ 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) const isHostname = (destination: string): boolean => { return /[a-zA-Z]/.test(destination); @@ -258,10 +356,18 @@ export default function CreateInternalResourceDialog({ destination: "", // destinationPort: undefined, alias: "", + tcpPortRangeString: "*", + udpPortRangeString: "*", + disableIcmp: false, roles: [], users: [], clients: [] }); + // Reset port mode state + setTcpPortMode("all"); + setUdpPortMode("all"); + setTcpCustomPorts(""); + setUdpCustomPorts(""); } }, [open]); @@ -304,6 +410,9 @@ export default function CreateInternalResourceDialog({ data.alias.trim() ? data.alias : undefined, + tcpPortRangeString: data.tcpPortRangeString, + udpPortRangeString: data.udpPortRangeString, + disableIcmp: data.disableIcmp ?? false, roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [], @@ -727,6 +836,163 @@ export default function CreateInternalResourceDialog({
)} + {/* Port Restrictions Section */} +
+

+ {t("portRestrictions")} +

+
+ {/* TCP Ports */} + ( + +
+ + TCP + + {/**/} + + {tcpPortMode === "custom" ? ( + + + setTcpCustomPorts(e.target.value) + } + className="flex-1" + /> + + ) : ( + + )} +
+ +
+ )} + /> + + {/* UDP Ports */} + ( + +
+ + UDP + + {/**/} + + {udpPortMode === "custom" ? ( + + + setUdpCustomPorts(e.target.value) + } + className="flex-1" + /> + + ) : ( + + )} +
+ +
+ )} + /> + + {/* ICMP Toggle */} + ( + +
+ + ICMP + + + field.onChange(!checked)} + /> + + + {field.value ? t("blocked") : t("allowed")} + +
+ +
+ )} + /> +
+
+ {/* Access Control Section */}

diff --git a/src/components/Credenza.tsx b/src/components/Credenza.tsx index 8176273f..6a48fc54 100644 --- a/src/components/Credenza.tsx +++ b/src/components/Credenza.tsx @@ -131,7 +131,7 @@ const CredenzaHeader = ({ className, children, ...props }: CredenzaProps) => { const CredenzaHeader = isDesktop ? DialogHeader : SheetHeader; return ( - + {children} ); @@ -177,7 +177,13 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => { const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter; return ( - + {children} ); diff --git a/src/components/DataTablePagination.tsx b/src/components/DataTablePagination.tsx index be12ca47..4abcf1c5 100644 --- a/src/components/DataTablePagination.tsx +++ b/src/components/DataTablePagination.tsx @@ -24,6 +24,8 @@ interface DataTablePaginationProps { isServerPagination?: boolean; isLoading?: boolean; disabled?: boolean; + pageSize?: number; + pageIndex?: number; } export function DataTablePagination({ @@ -33,10 +35,26 @@ export function DataTablePagination({ totalCount, isServerPagination = false, isLoading = false, - disabled = false + disabled = false, + pageSize: controlledPageSize, + pageIndex: controlledPageIndex }: DataTablePaginationProps) { 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 newPageSize = Number(value); table.setPageSize(newPageSize); @@ -51,7 +69,7 @@ export function DataTablePagination({ action: "first" | "previous" | "next" | "last" ) => { if (isServerPagination && onPageChange) { - const currentPage = table.getState().pagination.pageIndex; + const currentPage = pageIndex; const pageCount = table.getPageCount(); let newPage: number; @@ -77,18 +95,24 @@ export function DataTablePagination({ } } else { // 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) { case "first": table.setPageIndex(0); break; case "previous": - table.previousPage(); + if (pageIndex > 0) { + table.previousPage(); + } break; case "next": - table.nextPage(); + if (pageIndex < pageCount - 1) { + table.nextPage(); + } break; case "last": - table.setPageIndex(table.getPageCount() - 1); + table.setPageIndex(Math.max(0, pageCount - 1)); break; } } @@ -98,14 +122,12 @@ export function DataTablePagination({
{ + setTcpPortMode(value); + }} + > + + + + + + {t("allPorts")} + + + {t("blocked")} + + + {t("custom")} + + + + {tcpPortMode === "custom" ? ( + + + setTcpCustomPorts(e.target.value) + } + className="flex-1" + /> + + ) : ( + + )} +
+ + + )} + /> + + {/* UDP Ports */} + ( + +
+ + UDP + + {/**/} + + {udpPortMode === "custom" ? ( + + + setUdpCustomPorts(e.target.value) + } + className="flex-1" + /> + + ) : ( + + )} +
+ +
+ )} + /> + + {/* ICMP Toggle */} + ( + +
+ + ICMP + + + field.onChange(!checked)} + /> + + + {field.value ? t("blocked") : t("allowed")} + +
+ +
+ )} + /> +
+

+ {/* Access Control Section */}

diff --git a/src/components/InvitationsTable.tsx b/src/components/InvitationsTable.tsx index 1b218ea3..0d2d3e9b 100644 --- a/src/components/InvitationsTable.tsx +++ b/src/components/InvitationsTable.tsx @@ -182,7 +182,7 @@ export default function InvitationsTable({ setSelectedInvitation(null); }} dialog={ -
+

{t("inviteQuestionRemove")}

{t("inviteMessageRemove")}

diff --git a/src/components/LayoutSidebar.tsx b/src/components/LayoutSidebar.tsx index 8006b6e8..45d0292b 100644 --- a/src/components/LayoutSidebar.tsx +++ b/src/components/LayoutSidebar.tsx @@ -25,6 +25,7 @@ import { useEffect, useState } from "react"; import { FaGithub } from "react-icons/fa"; import SidebarLicenseButton from "./SidebarLicenseButton"; import { SidebarSupportButton } from "./SidebarSupportButton"; +import { is } from "drizzle-orm"; const ProductUpdates = dynamic(() => import("./ProductUpdates"), { ssr: false @@ -52,7 +53,7 @@ export function LayoutSidebar({ const pathname = usePathname(); const isAdminPage = pathname?.startsWith("/admin"); const { user } = useUserContext(); - const { isUnlocked } = useLicenseStatusContext(); + const { isUnlocked, licenseStatus } = useLicenseStatusContext(); const { env } = useEnvContext(); const t = useTranslations(); @@ -226,6 +227,18 @@ export function LayoutSidebar({
+ {build === "enterprise" && + isUnlocked() && + licenseStatus?.tier === "personal" ? ( +
+ {t("personalUseOnly")} +
+ ) : null} + {build === "enterprise" && !isUnlocked() ? ( +
+ {t("unlicensed")} +
+ ) : null} {env?.app?.version && (
createApiClient(env)); const router = useRouter(); + console.log({ filters }); const dateRange = { - startDate: filters.timeStart ? new Date(filters.timeStart) : undefined, - endDate: filters.timeEnd ? new Date(filters.timeEnd) : undefined + startDate: filters.timeStart + ? new Date(filters.timeStart) + : getSevenDaysAgo(), + endDate: filters.timeEnd ? new Date(filters.timeEnd) : new Date() }; const { data: resources = [], isFetching: isFetchingResources } = useQuery( - resourceQueries.listNamesPerOrg(props.orgId, api) + resourceQueries.listNamesPerOrg(props.orgId) ); const { @@ -88,7 +87,6 @@ export function LogAnalyticsData(props: AnalyticsContentProps) { } = useQuery( logQueries.requestAnalytics({ orgId: props.orgId, - api, filters }) ); diff --git a/src/components/LogDataTable.tsx b/src/components/LogDataTable.tsx index 492a8c15..d48a3871 100644 --- a/src/components/LogDataTable.tsx +++ b/src/components/LogDataTable.tsx @@ -1,16 +1,5 @@ "use client"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, - getPaginationRowModel, - SortingState, - getSortedRowModel, - ColumnFiltersState, - getFilteredRowModel -} from "@tanstack/react-table"; import { Table, TableBody, @@ -19,29 +8,36 @@ import { TableHeader, TableRow } 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 { - 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 { 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 = { PAGE_SIZE: "datatable-page-size", @@ -400,15 +396,28 @@ export function LogDataTable({ )} {onExport && ( - + + + + + + + {t("exportCsvTooltip")} + + + )}
@@ -533,6 +542,8 @@ export function LogDataTable({ isServerPagination={isServerPagination} isLoading={isLoading} disabled={disabled} + pageSize={pageSize} + pageIndex={currentPage} />

diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index 7ac10eb7..67ed2e08 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -354,7 +354,7 @@ export default function MachineClientsTable({ setSelectedClient(null); }} dialog={ -
+

{t("deleteClientQuestion")}

{t("clientMessageRemove")}

diff --git a/src/components/OrgApiKeysTable.tsx b/src/components/OrgApiKeysTable.tsx index 2f50e571..72509bc4 100644 --- a/src/components/OrgApiKeysTable.tsx +++ b/src/components/OrgApiKeysTable.tsx @@ -189,7 +189,7 @@ export default function OrgApiKeysTable({ setSelected(null); }} dialog={ -
+

{t("apiKeysQuestionRemove")}

{t("apiKeysMessageRemove")}

diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index 49a10215..4862d780 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -27,6 +27,7 @@ function getActionsCategories(root: boolean) { [t("actionUpdateOrg")]: "updateOrg", [t("actionGetOrgUser")]: "getOrgUser", [t("actionInviteUser")]: "inviteUser", + [t("actionRemoveInvitation")]: "removeInvitation", [t("actionListInvitations")]: "listInvitations", [t("actionRemoveUser")]: "removeUser", [t("actionListUsers")]: "listUsers", diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 6f39c099..bd4bb4e1 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -535,7 +535,7 @@ export default function ProxyResourcesTable({ setSelectedResource(null); }} dialog={ -
+

{t("resourceQuestionRemove")}

{t("resourceMessageRemove")}

diff --git a/src/components/ResourceAuthPortal.tsx b/src/components/ResourceAuthPortal.tsx index 15cbba1f..90d6cf78 100644 --- a/src/components/ResourceAuthPortal.tsx +++ b/src/components/ResourceAuthPortal.tsx @@ -93,7 +93,7 @@ type ResourceAuthPortalProps = { export default function ResourceAuthPortal(props: ResourceAuthPortalProps) { const router = useRouter(); const t = useTranslations(); - const { isUnlocked } = useLicenseStatusContext(); + const { isUnlocked, licenseStatus } = useLicenseStatusContext(); const getNumMethods = () => { let colLength = 0; @@ -737,6 +737,22 @@ export default function ResourceAuthPortal(props: ResourceAuthPortalProps) {
)} + {build === "enterprise" && !isUnlocked() ? ( +
+ + {t("instanceIsUnlicensed")} + +
+ ) : null} + {build === "enterprise" && + isUnlocked() && + licenseStatus?.tier === "personal" ? ( +
+ + {t("loginPageLicenseWatermark")} + +
+ ) : null}
) : ( diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 3a8085c2..46aeb79f 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -412,7 +412,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { setSelectedSite(null); }} dialog={ -
+

{t("siteQuestionRemove")}

{t("siteMessageRemove")}

diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 88a0d4a8..71321bf8 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -401,7 +401,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { setSelectedClient(null); }} dialog={ -
+

{t("deleteClientQuestion")}

{t("clientMessageRemove")}

diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index 55ee9505..0d0ff379 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -258,7 +258,7 @@ export default function UsersTable({ users: u }: UsersTableProps) { setSelectedUser(null); }} dialog={ -
+

{t("userQuestionOrgRemove")}

{t("userMessageOrgRemove")}

diff --git a/src/components/ViewDevicesDialog.tsx b/src/components/ViewDevicesDialog.tsx index 33e9d87f..70c55ded 100644 --- a/src/components/ViewDevicesDialog.tsx +++ b/src/components/ViewDevicesDialog.tsx @@ -224,7 +224,7 @@ export default function ViewDevicesDialog({ } }} dialog={ -
+

{t("deviceQuestionRemove") || "Are you sure you want to delete this device?"} diff --git a/src/components/private/OrgIdpTable.tsx b/src/components/private/OrgIdpTable.tsx index 8730053e..f5fdfe40 100644 --- a/src/components/private/OrgIdpTable.tsx +++ b/src/components/private/OrgIdpTable.tsx @@ -177,7 +177,7 @@ export default function IdpTable({ idps, orgId }: Props) { setSelectedIdp(null); }} dialog={ -

+

{t("idpQuestionRemove")}

{t("idpMessageRemove")}

diff --git a/src/components/tags/autocomplete.tsx b/src/components/tags/autocomplete.tsx index 3230f11b..ee865eb6 100644 --- a/src/components/tags/autocomplete.tsx +++ b/src/components/tags/autocomplete.tsx @@ -308,7 +308,7 @@ export const Autocomplete: React.FC = ({ role="option" aria-selected={isSelected} 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 && "bg-accent text-accent-foreground", classStyleProps?.commandItem diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index 86dc4961..41529692 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -10,7 +10,8 @@ import { getSortedRowModel, ColumnFiltersState, getFilteredRowModel, - VisibilityState + VisibilityState, + PaginationState } from "@tanstack/react-table"; // Extended ColumnDef type that includes optional friendlyName for column visibility dropdown @@ -227,6 +228,10 @@ export function DataTable({ const [columnVisibility, setColumnVisibility] = useState( initialColumnVisibility ); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: pageSize + }); const [activeTab, setActiveTab] = useState( defaultTab || tabs?.[0]?.id || "" ); @@ -256,6 +261,7 @@ export function DataTable({ getFilteredRowModel: getFilteredRowModel(), onGlobalFilterChange: setGlobalFilter, onColumnVisibilityChange: setColumnVisibility, + onPaginationChange: setPagination, initialState: { pagination: { pageSize: pageSize, @@ -267,21 +273,18 @@ export function DataTable({ sorting, columnFilters, globalFilter, - columnVisibility + columnVisibility, + pagination } }); + // Persist pageSize to localStorage when it changes useEffect(() => { - const currentPageSize = table.getState().pagination.pageSize; - if (currentPageSize !== pageSize) { - table.setPageSize(pageSize); - - // Persist to localStorage if enabled - if (persistPageSize) { - setStoredPageSize(pageSize, tableId); - } + if (persistPageSize && pagination.pageSize !== pageSize) { + setStoredPageSize(pagination.pageSize, tableId); + setPageSize(pagination.pageSize); } - }, [pageSize, table, persistPageSize, tableId]); + }, [pagination.pageSize, persistPageSize, tableId, pageSize]); useEffect(() => { // Persist column visibility to localStorage when it changes @@ -293,13 +296,17 @@ export function DataTable({ const handleTabChange = (value: string) => { setActiveTab(value); // Reset to first page when changing tabs - table.setPageIndex(0); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); }; // Enhanced pagination component that updates our local state const handlePageSizeChange = (newPageSize: number) => { + setPagination((prev) => ({ + ...prev, + pageSize: newPageSize, + pageIndex: 0 + })); setPageSize(newPageSize); - table.setPageSize(newPageSize); // Persist immediately when changed if (persistPageSize) { @@ -614,6 +621,8 @@ export function DataTable({
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 3f223d92..328431e9 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -88,7 +88,7 @@ const DialogTitle = React.forwardRef< ; export const logQueries = { requestAnalytics: ({ orgId, - filters, - api + filters }: { orgId: string; filters: LogAnalyticsFilters; - api: AxiosInstance; }) => queryOptions({ queryKey: ["REQUEST_LOG_ANALYTICS", orgId, filters] as const, - queryFn: async ({ signal }) => { - const res = await api.get< + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< AxiosResponse >(`/org/${orgId}/logs/analytics`, { params: filters, @@ -240,11 +238,11 @@ export const resourceQueries = { return res.data.data.clients; } }), - listNamesPerOrg: (orgId: string, api: AxiosInstance) => + listNamesPerOrg: (orgId: string) => queryOptions({ queryKey: ["RESOURCES_NAMES", orgId] as const, - queryFn: async ({ signal }) => { - const res = await api.get< + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< AxiosResponse >(`/org/${orgId}/resource-names`, { signal