Merge branch 'dev' into refactor/domain-picker-default-value
2
.github/workflows/cicd.yml
vendored
@@ -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
|
||||
|
||||
|
||||
39
.github/workflows/restart-runners.yml
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
name: Restart Runners
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 */7 * *'
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ec2-maintenance-prod:
|
||||
runs-on: ubuntu-latest
|
||||
permissions: write-all
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v2
|
||||
with:
|
||||
role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }}
|
||||
role-duration-seconds: 3600
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
|
||||
- name: Verify AWS identity
|
||||
run: aws sts get-caller-identity
|
||||
|
||||
- name: Start EC2 instance
|
||||
run: |
|
||||
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
|
||||
aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
|
||||
echo "EC2 instances started"
|
||||
|
||||
- name: Wait
|
||||
run: sleep 600
|
||||
|
||||
- name: Stop EC2 instance
|
||||
run: |
|
||||
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }}
|
||||
aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }}
|
||||
echo "EC2 instances stopped"
|
||||
29
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
16
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
|
||||
|
||||
31
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=<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=<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=<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=<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:
|
||||
|
||||
20
README.md
@@ -41,7 +41,7 @@
|
||||
</strong>
|
||||
</p>
|
||||
|
||||
Pangolin is a self-hosted tunneled reverse proxy server with identity and context aware access control, designed to easily expose and protect applications running anywhere. Pangolin acts as a central hub and connects isolated networks — even those behind restrictive firewalls — through encrypted tunnels, enabling easy access to remote services without opening ports or requiring a VPN.
|
||||
Pangolin is an open-source, identity-based remote access platform built on WireGuard that enables secure, seamless connectivity to private and public resources. Pangolin combines reverse proxy and VPN capabilities into one platform, providing browser-based access to web applications and client-based access to any private resources, all with zero-trust security and granular access control.
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -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.
|
||||
|
||||
| <img width=500 /> | <img width=500 /> |
|
||||
|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------|
|
||||
| **Manage applications in one place**<br /><br /> Pangolin provides a unified dashboard where you can monitor, configure, and secure all of your services regardless of where they are hosted. | <img src="public/screenshots/hero.png" width=500 /><tr></tr> |
|
||||
| **Reverse proxy across networks anywhere**<br /><br />Route traffic via tunnels to any private network. Pangolin works like a reverse proxy that spans multiple networks and handles routing, load balancing, health checking, and more to the right services on the other end. | <img src="public/screenshots/sites.png" width=500 /><tr></tr> |
|
||||
| **Enforce identity and context aware rules**<br /><br />Protect your applications with identity and context aware rules such as SSO, OIDC, PIN, password, temporary share links, geolocation, IP, and more. | <img src="public/auth-diagram1.png" width=500 /><tr></tr> |
|
||||
| **Quickly connect Pangolin sites**<br /><br />Pangolin's lightweight [Newt](https://github.com/fosrl/newt) client runs in userspace and can run anywhere. Use it as a site connector to route traffic to backends across all of your environments. | <img src="public/clip.gif" width=500 /><tr></tr> |
|
||||
| **Connect remote networks with sites**<br /><br />Pangolin's lightweight site connectors create secure tunnels from remote networks without requiring public IP addresses or open ports. Sites make any network anywhere available for authorized access. | <img src="public/screenshots/sites.png" width=500 /><tr></tr> |
|
||||
| **Browser-based reverse proxy access**<br /><br />Expose web applications through identity and context-aware tunneled reverse proxies. Pangolin handles routing, load balancing, health checking, and automatic SSL certificates without exposing your network directly to the internet. Users access applications through any web browser with authentication and granular access control. | <img src="public/clip.gif" width=500 /><tr></tr> |
|
||||
| **Client-based private resource access**<br /><br />Access private resources like SSH servers, databases, RDP, and entire network ranges through Pangolin clients. Intelligent NAT traversal enables connections even through restrictive firewalls, while DNS aliases provide friendly names and fast connections to resources across all your sites. | <img src="public/screenshots/private-resources.png" width=500 /><tr></tr> |
|
||||
| **Zero-trust granular access**<br /><br />Grant users access to specific resources, not entire networks. Unlike traditional VPNs that expose full network access, Pangolin's zero-trust model ensures users can only reach the applications and services you explicitly define, reducing security risk and attack surface. | <img src="public/screenshots/user-devices.png" width=500 /><tr></tr> |
|
||||
|
||||
## Download Clients
|
||||
|
||||
Download the Pangolin client for your platform:
|
||||
|
||||
- [Mac](https://pangolin.net/downloads/mac)
|
||||
- [Windows](https://pangolin.net/downloads/windows)
|
||||
- [Linux](https://pangolin.net/downloads/linux)
|
||||
|
||||
## Get Started
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)."
|
||||
}
|
||||
|
||||
211
package-lock.json
generated
@@ -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"
|
||||
|
||||
20
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 687 KiB |
|
Before Width: | Height: | Size: 713 KiB After Width: | Height: | Size: 493 KiB |
|
Before Width: | Height: | Size: 636 KiB |
|
Before Width: | Height: | Size: 713 KiB After Width: | Height: | Size: 484 KiB |
BIN
public/screenshots/private-resources.png
Normal file
|
After Width: | Height: | Size: 421 KiB |
BIN
public/screenshots/public-resources.png
Normal file
|
After Width: | Height: | Size: 484 KiB |
|
Before Width: | Height: | Size: 713 KiB |
|
Before Width: | Height: | Size: 456 KiB |
|
Before Width: | Height: | Size: 674 KiB After Width: | Height: | Size: 396 KiB |
BIN
public/screenshots/user-devices.png
Normal file
|
After Width: | Height: | Size: 434 KiB |
@@ -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 || [];
|
||||
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
161
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;
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
// Get the olm for this client
|
||||
const [olm] = await trx
|
||||
.select({ olmId: olms.olmId })
|
||||
.from(olms)
|
||||
.where(eq(olms.clientId, client.clientId))
|
||||
.limit(1);
|
||||
|
||||
if (!olm) {
|
||||
logger.warn(
|
||||
`Olm not found for client ${client.clientId}, skipping peer updates`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const olmId = olm.olmId;
|
||||
|
||||
if (!client.subnet || !client.pubKey) {
|
||||
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<any>[] = [];
|
||||
let olmJobs: Promise<any>[] = [];
|
||||
let exitNodeJobs: Promise<any>[] = [];
|
||||
const newtJobs: Promise<any>[] = [];
|
||||
const olmJobs: Promise<any>[] = [];
|
||||
const exitNodeJobs: Promise<any>[] = [];
|
||||
|
||||
for (const siteData of sitesData) {
|
||||
const site = siteData.sites;
|
||||
@@ -1130,18 +1125,8 @@ async function handleMessagesForClientResources(
|
||||
resourcesToRemove: number[],
|
||||
trx: Transaction | typeof db = db
|
||||
): Promise<void> {
|
||||
// Group resources by site
|
||||
const resourcesBySite = new Map<number, SiteResource[]>();
|
||||
|
||||
for (const resource of allNewResources) {
|
||||
if (!resourcesBySite.has(resource.siteId)) {
|
||||
resourcesBySite.set(resource.siteId, []);
|
||||
}
|
||||
resourcesBySite.get(resource.siteId)!.push(resource);
|
||||
}
|
||||
|
||||
let proxyJobs: Promise<any>[] = [];
|
||||
let olmJobs: Promise<any>[] = [];
|
||||
const proxyJobs: Promise<any>[] = [];
|
||||
const olmJobs: Promise<any>[] = [];
|
||||
|
||||
// Handle additions
|
||||
if (resourcesToAdd.length > 0) {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)), {
|
||||
|
||||
@@ -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)), {
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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<typeof queryRequestAuditLogsCombined>;
|
||||
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,
|
||||
|
||||
@@ -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)), {
|
||||
|
||||
@@ -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<number>(`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<number>(`org_${data.orgId}_retentionDays`) === undefined
|
||||
) {
|
||||
getRetentionDays(data.orgId).catch((err) =>
|
||||
logger.error("Error checking retention days:", err)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(error);
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function chunkArray<T>(array: T[], size: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < array.length; i += size) {
|
||||
chunks.push(array.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
export async function addTargets(newtId: string, targets: SubnetProxyTarget[]) {
|
||||
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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -304,7 +304,7 @@ export default function ExitNodesTable({
|
||||
setSelectedNode(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("remoteExitNodeQuestionRemove")}</p>
|
||||
|
||||
<p>{t("remoteExitNodeMessageRemove")}</p>
|
||||
|
||||
@@ -289,7 +289,7 @@ export default function GeneralPage() {
|
||||
setIsDeleteModalOpen(val);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("orgQuestionRemove")}</p>
|
||||
<p>{t("orgMessageRemove")}</p>
|
||||
</div>
|
||||
@@ -303,7 +303,7 @@ export default function GeneralPage() {
|
||||
open={isSecurityPolicyConfirmOpen}
|
||||
setOpen={setIsSecurityPolicyConfirmOpen}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("securityPolicyChangeDescription")}</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -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<any[]>([]);
|
||||
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<number>(() => {
|
||||
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={{
|
||||
|
||||
@@ -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<any[]>([]);
|
||||
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<number>(() => {
|
||||
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={{
|
||||
|
||||
@@ -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<any[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [isExporting, startTransition] = useTransition();
|
||||
|
||||
// Pagination state
|
||||
const [totalCount, setTotalCount] = useState<number>(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<number>(() => {
|
||||
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={{
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -315,7 +315,7 @@ export default function LicensePage() {
|
||||
setSelectedLicenseKey(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("licenseQuestionRemove")}</p>
|
||||
<p>
|
||||
<b>{t("licenseMessageRemove")}</b>
|
||||
@@ -360,7 +360,8 @@ export default function LicensePage() {
|
||||
<div className="space-y-2 text-green-500">
|
||||
<div className="text-2xl flex items-center gap-2">
|
||||
<Check />
|
||||
{t("licensed")}
|
||||
{t("licensed") +
|
||||
`${licenseStatus?.tier === "personal" ? ` (${t("personalUseOnly")})` : ""}`}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -243,7 +243,7 @@ export default function UsersTable({ users }: Props) {
|
||||
setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("userQuestionRemove")}</p>
|
||||
|
||||
<p>{t("userMessageRemove")}</p>
|
||||
|
||||
@@ -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")}
|
||||
</span>
|
||||
{build === "enterprise" &&
|
||||
licenseStatus?.isHostLicensed &&
|
||||
licenseStatus?.isLicenseValid &&
|
||||
licenseStatus?.tier === "personal" ? (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
<span>{t("personalUseOnly")}</span>
|
||||
</>
|
||||
) : null}
|
||||
{build === "enterprise" &&
|
||||
(!licenseStatus?.isHostLicensed ||
|
||||
!licenseStatus?.isLicenseValid) ? (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
<span>{t("unlicensed")}</span>
|
||||
</>
|
||||
) : null}
|
||||
{build === "saas" && (
|
||||
<>
|
||||
<Separator orientation="vertical" />
|
||||
|
||||
@@ -196,7 +196,7 @@ export default function IdpTable({ idps }: Props) {
|
||||
setSelectedIdp(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
{t("idpQuestionRemove", {
|
||||
name: selectedIdp.name
|
||||
|
||||
@@ -269,7 +269,7 @@ export default function UsersTable({ users }: Props) {
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{r.type !== "internal" && (
|
||||
{r.type === "internal" && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
generatePasswordResetCode(r.id);
|
||||
@@ -313,7 +313,7 @@ export default function UsersTable({ users }: Props) {
|
||||
setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
{t("userQuestionRemove", {
|
||||
selectedUser:
|
||||
|
||||
@@ -182,7 +182,7 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) {
|
||||
setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("apiKeysQuestionRemove")}</p>
|
||||
|
||||
<p>{t("apiKeysMessageRemove")}</p>
|
||||
|
||||
@@ -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={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("resourceQuestionRemove")}</p>
|
||||
<p>{t("resourceMessageRemove")}</p>
|
||||
</div>
|
||||
|
||||
@@ -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<PortMode>("all");
|
||||
const [udpPortMode, setUdpPortMode] = useState<PortMode>("all");
|
||||
const [tcpCustomPorts, setTcpCustomPorts] = useState<string>("");
|
||||
const [udpCustomPorts, setUdpCustomPorts] = useState<string>("");
|
||||
|
||||
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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Port Restrictions Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("portRestrictions")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* TCP Ports */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tcpPortRangeString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
TCP
|
||||
</FormLabel>
|
||||
{/*<InfoPopup
|
||||
info={t("tcpPortsDescription")}
|
||||
/>*/}
|
||||
<Select
|
||||
value={tcpPortMode}
|
||||
onValueChange={(value: PortMode) => {
|
||||
setTcpPortMode(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tcpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="80,443,8000-9000"
|
||||
value={tcpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setTcpCustomPorts(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder={
|
||||
tcpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* UDP Ports */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="udpPortRangeString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
UDP
|
||||
</FormLabel>
|
||||
{/*<InfoPopup
|
||||
info={t("udpPortsDescription")}
|
||||
/>*/}
|
||||
<Select
|
||||
value={udpPortMode}
|
||||
onValueChange={(value: PortMode) => {
|
||||
setUdpPortMode(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{udpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="53,123,500-600"
|
||||
value={udpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setUdpCustomPorts(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder={
|
||||
udpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* ICMP Toggle */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="disableIcmp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
ICMP
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={!field.value}
|
||||
onCheckedChange={(checked) => field.onChange(!checked)}
|
||||
/>
|
||||
</FormControl>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.value ? t("blocked") : t("allowed")}
|
||||
</span>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Control Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
|
||||
@@ -131,7 +131,7 @@ const CredenzaHeader = ({ className, children, ...props }: CredenzaProps) => {
|
||||
const CredenzaHeader = isDesktop ? DialogHeader : SheetHeader;
|
||||
|
||||
return (
|
||||
<CredenzaHeader className={cn("-mx-6 px-6 pb-6 border-b border-border", className)} {...props}>
|
||||
<CredenzaHeader className={cn("-mx-6 px-6", className)} {...props}>
|
||||
{children}
|
||||
</CredenzaHeader>
|
||||
);
|
||||
@@ -177,7 +177,13 @@ const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
|
||||
const CredenzaFooter = isDesktop ? DialogFooter : SheetFooter;
|
||||
|
||||
return (
|
||||
<CredenzaFooter className={cn("mt-8 md:mt-0 -mx-6 px-6 pt-6 border-t border-border", className)} {...props}>
|
||||
<CredenzaFooter
|
||||
className={cn(
|
||||
"mt-8 md:mt-0 -mx-6 px-6 pt-6 border-t border-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CredenzaFooter>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,8 @@ interface DataTablePaginationProps<TData> {
|
||||
isServerPagination?: boolean;
|
||||
isLoading?: boolean;
|
||||
disabled?: boolean;
|
||||
pageSize?: number;
|
||||
pageIndex?: number;
|
||||
}
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
@@ -33,10 +35,26 @@ export function DataTablePagination<TData>({
|
||||
totalCount,
|
||||
isServerPagination = false,
|
||||
isLoading = false,
|
||||
disabled = false
|
||||
disabled = false,
|
||||
pageSize: controlledPageSize,
|
||||
pageIndex: controlledPageIndex
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
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<TData>({
|
||||
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<TData>({
|
||||
}
|
||||
} 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<TData>({
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
value={`${pageSize}`}
|
||||
onValueChange={handlePageSizeChange}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[73px]" disabled={disabled}>
|
||||
<SelectValue
|
||||
placeholder={table.getState().pagination.pageSize}
|
||||
/>
|
||||
<SelectValue placeholder={pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="bottom">
|
||||
{[10, 20, 30, 40, 50, 100].map((pageSize) => (
|
||||
@@ -121,16 +143,11 @@ export function DataTablePagination<TData>({
|
||||
<div className="flex items-center justify-center text-sm font-medium">
|
||||
{isServerPagination && totalCount !== undefined
|
||||
? t("paginator", {
|
||||
current:
|
||||
table.getState().pagination.pageIndex + 1,
|
||||
last: Math.ceil(
|
||||
totalCount /
|
||||
table.getState().pagination.pageSize
|
||||
)
|
||||
current: pageIndex + 1,
|
||||
last: Math.ceil(totalCount / pageSize)
|
||||
})
|
||||
: t("paginator", {
|
||||
current:
|
||||
table.getState().pagination.pageIndex + 1,
|
||||
current: pageIndex + 1,
|
||||
last: table.getPageCount()
|
||||
})}
|
||||
</div>
|
||||
@@ -139,9 +156,7 @@ export function DataTablePagination<TData>({
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => handlePageNavigation("first")}
|
||||
disabled={
|
||||
!table.getCanPreviousPage() || isLoading || disabled
|
||||
}
|
||||
disabled={!canPreviousPage || isLoading || disabled}
|
||||
>
|
||||
<span className="sr-only">{t("paginatorToFirst")}</span>
|
||||
<DoubleArrowLeftIcon className="h-4 w-4" />
|
||||
@@ -150,9 +165,7 @@ export function DataTablePagination<TData>({
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handlePageNavigation("previous")}
|
||||
disabled={
|
||||
!table.getCanPreviousPage() || isLoading || disabled
|
||||
}
|
||||
disabled={!canPreviousPage || isLoading || disabled}
|
||||
>
|
||||
<span className="sr-only">
|
||||
{t("paginatorToPrevious")}
|
||||
@@ -163,9 +176,7 @@ export function DataTablePagination<TData>({
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => handlePageNavigation("next")}
|
||||
disabled={
|
||||
!table.getCanNextPage() || isLoading || disabled
|
||||
}
|
||||
disabled={!canNextPage || isLoading || disabled}
|
||||
>
|
||||
<span className="sr-only">{t("paginatorToNext")}</span>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
@@ -174,9 +185,7 @@ export function DataTablePagination<TData>({
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => handlePageNavigation("last")}
|
||||
disabled={
|
||||
!table.getCanNextPage() || isLoading || disabled
|
||||
}
|
||||
disabled={!canNextPage || isLoading || disabled}
|
||||
>
|
||||
<span className="sr-only">{t("paginatorToLast")}</span>
|
||||
<DoubleArrowRightIcon className="h-4 w-4" />
|
||||
|
||||
@@ -304,7 +304,7 @@ export default function DomainsTable({ domains, orgId }: Props) {
|
||||
setSelectedDomain(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("domainQuestionRemove")}</p>
|
||||
<p>{t("domainMessageRemove")}</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { Input } from "@app/components/ui/input";
|
||||
import {
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@app/components/ui/select";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
@@ -36,17 +37,86 @@ import { toast } from "@app/hooks/useToast";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { ListRolesResponse } from "@server/routers/role";
|
||||
import { ListUsersResponse } from "@server/routers/user";
|
||||
import { ListSiteResourceRolesResponse } from "@server/routers/siteResource/listSiteResourceRoles";
|
||||
import { ListSiteResourceUsersResponse } from "@server/routers/siteResource/listSiteResourceUsers";
|
||||
import { ListSiteResourceClientsResponse } from "@server/routers/siteResource/listSiteResourceClients";
|
||||
import { ListClientsResponse } from "@server/routers/client/listClients";
|
||||
import { Tag, TagInput } from "@app/components/tags/tag-input";
|
||||
import { AxiosResponse } from "axios";
|
||||
import { UserType } from "@server/types/UserTypes";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { orgQueries, resourceQueries } from "@app/lib/queries";
|
||||
// import { InfoPopup } from "@app/components/ui/info-popup";
|
||||
|
||||
// Helper to validate port range string format
|
||||
const isValidPortRangeString = (val: string | undefined | null): boolean => {
|
||||
if (!val || val.trim() === "" || val.trim() === "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const parts = val.split(",").map((p) => p.trim());
|
||||
|
||||
for (const part of parts) {
|
||||
if (part === "") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (part.includes("-")) {
|
||||
const [start, end] = part.split("-").map((p) => p.trim());
|
||||
if (!start || !end) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const startPort = parseInt(start, 10);
|
||||
const endPort = parseInt(end, 10);
|
||||
|
||||
if (isNaN(startPort) || isNaN(endPort)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (startPort < 1 || startPort > 65535 || endPort < 1 || endPort > 65535) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (startPort > endPort) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const port = parseInt(part, 10);
|
||||
if (isNaN(port)) {
|
||||
return false;
|
||||
}
|
||||
if (port < 1 || port > 65535) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// Port range string schema for client-side validation
|
||||
const portRangeStringSchema = z
|
||||
.string()
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine(
|
||||
(val) => isValidPortRangeString(val),
|
||||
{
|
||||
message:
|
||||
'Port range must be "*" for all ports, or a comma-separated list of ports and ranges (e.g., "80,443,8000-9000"). Ports must be between 1 and 65535.'
|
||||
}
|
||||
);
|
||||
|
||||
// Helper to determine the port mode from a port range string
|
||||
type PortMode = "all" | "blocked" | "custom";
|
||||
const getPortModeFromString = (val: string | undefined | null): PortMode => {
|
||||
if (val === "*") return "all";
|
||||
if (!val || val.trim() === "") return "blocked";
|
||||
return "custom";
|
||||
};
|
||||
|
||||
// Helper to get the port string for API from mode and custom value
|
||||
const getPortStringFromMode = (mode: PortMode, customValue: string): string | undefined => {
|
||||
if (mode === "all") return "*";
|
||||
if (mode === "blocked") return "";
|
||||
return customValue;
|
||||
};
|
||||
|
||||
type InternalResourceData = {
|
||||
id: number;
|
||||
@@ -61,6 +131,9 @@ type InternalResourceData = {
|
||||
destination: string;
|
||||
// destinationPort?: number | null;
|
||||
alias?: string | null;
|
||||
tcpPortRangeString?: string | null;
|
||||
udpPortRangeString?: string | null;
|
||||
disableIcmp?: boolean;
|
||||
};
|
||||
|
||||
type EditInternalResourceDialogProps = {
|
||||
@@ -94,6 +167,9 @@ export default function EditInternalResourceDialog({
|
||||
destination: z.string().min(1),
|
||||
// destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(),
|
||||
alias: z.string().nullish(),
|
||||
tcpPortRangeString: portRangeStringSchema,
|
||||
udpPortRangeString: portRangeStringSchema,
|
||||
disableIcmp: z.boolean().optional(),
|
||||
roles: z
|
||||
.array(
|
||||
z.object({
|
||||
@@ -255,6 +331,24 @@ export default function EditInternalResourceDialog({
|
||||
number | null
|
||||
>(null);
|
||||
|
||||
// Port restriction UI state
|
||||
const [tcpPortMode, setTcpPortMode] = useState<PortMode>(
|
||||
getPortModeFromString(resource.tcpPortRangeString)
|
||||
);
|
||||
const [udpPortMode, setUdpPortMode] = useState<PortMode>(
|
||||
getPortModeFromString(resource.udpPortRangeString)
|
||||
);
|
||||
const [tcpCustomPorts, setTcpCustomPorts] = useState<string>(
|
||||
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
|
||||
? resource.tcpPortRangeString
|
||||
: ""
|
||||
);
|
||||
const [udpCustomPorts, setUdpCustomPorts] = useState<string>(
|
||||
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
|
||||
? resource.udpPortRangeString
|
||||
: ""
|
||||
);
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
@@ -265,6 +359,9 @@ export default function EditInternalResourceDialog({
|
||||
destination: resource.destination || "",
|
||||
// destinationPort: resource.destinationPort ?? undefined,
|
||||
alias: resource.alias ?? null,
|
||||
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||
disableIcmp: resource.disableIcmp ?? false,
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
@@ -273,6 +370,17 @@ export default function EditInternalResourceDialog({
|
||||
|
||||
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);
|
||||
@@ -327,6 +435,9 @@ export default function EditInternalResourceDialog({
|
||||
data.alias.trim()
|
||||
? data.alias
|
||||
: null,
|
||||
tcpPortRangeString: data.tcpPortRangeString,
|
||||
udpPortRangeString: data.udpPortRangeString,
|
||||
disableIcmp: data.disableIcmp ?? false,
|
||||
roleIds: (data.roles || []).map((r) => parseInt(r.id)),
|
||||
userIds: (data.users || []).map((u) => u.id),
|
||||
clientIds: (data.clients || []).map((c) => parseInt(c.id))
|
||||
@@ -396,10 +507,26 @@ export default function EditInternalResourceDialog({
|
||||
mode: resource.mode || "host",
|
||||
destination: resource.destination || "",
|
||||
alias: resource.alias ?? null,
|
||||
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||
disableIcmp: resource.disableIcmp ?? false,
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
});
|
||||
// Reset port mode state
|
||||
setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString));
|
||||
setUdpPortMode(getPortModeFromString(resource.udpPortRangeString));
|
||||
setTcpCustomPorts(
|
||||
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
|
||||
? resource.tcpPortRangeString
|
||||
: ""
|
||||
);
|
||||
setUdpCustomPorts(
|
||||
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
|
||||
? resource.udpPortRangeString
|
||||
: ""
|
||||
);
|
||||
previousResourceId.current = resource.id;
|
||||
}
|
||||
|
||||
@@ -438,10 +565,26 @@ export default function EditInternalResourceDialog({
|
||||
destination: resource.destination || "",
|
||||
// destinationPort: resource.destinationPort ?? undefined,
|
||||
alias: resource.alias ?? null,
|
||||
tcpPortRangeString: resource.tcpPortRangeString ?? "*",
|
||||
udpPortRangeString: resource.udpPortRangeString ?? "*",
|
||||
disableIcmp: resource.disableIcmp ?? false,
|
||||
roles: [],
|
||||
users: [],
|
||||
clients: []
|
||||
});
|
||||
// Reset port mode state
|
||||
setTcpPortMode(getPortModeFromString(resource.tcpPortRangeString));
|
||||
setUdpPortMode(getPortModeFromString(resource.udpPortRangeString));
|
||||
setTcpCustomPorts(
|
||||
resource.tcpPortRangeString && resource.tcpPortRangeString !== "*"
|
||||
? resource.tcpPortRangeString
|
||||
: ""
|
||||
);
|
||||
setUdpCustomPorts(
|
||||
resource.udpPortRangeString && resource.udpPortRangeString !== "*"
|
||||
? resource.udpPortRangeString
|
||||
: ""
|
||||
);
|
||||
// Reset previous resource ID to ensure clean state on next open
|
||||
previousResourceId.current = null;
|
||||
}
|
||||
@@ -674,6 +817,163 @@ export default function EditInternalResourceDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Port Restrictions Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{t("portRestrictions")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* TCP Ports */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tcpPortRangeString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
TCP
|
||||
</FormLabel>
|
||||
{/*<InfoPopup
|
||||
info={t("tcpPortsDescription")}
|
||||
/>*/}
|
||||
<Select
|
||||
value={tcpPortMode}
|
||||
onValueChange={(value: PortMode) => {
|
||||
setTcpPortMode(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{tcpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="80,443,8000-9000"
|
||||
value={tcpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setTcpCustomPorts(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder={
|
||||
tcpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* UDP Ports */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="udpPortRangeString"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
UDP
|
||||
</FormLabel>
|
||||
{/*<InfoPopup
|
||||
info={t("udpPortsDescription")}
|
||||
/>*/}
|
||||
<Select
|
||||
value={udpPortMode}
|
||||
onValueChange={(value: PortMode) => {
|
||||
setUdpPortMode(value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t("allPorts")}
|
||||
</SelectItem>
|
||||
<SelectItem value="blocked">
|
||||
{t("blocked")}
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
{t("custom")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{udpPortMode === "custom" ? (
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="53,123,500-600"
|
||||
value={udpCustomPorts}
|
||||
onChange={(e) =>
|
||||
setUdpCustomPorts(e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</FormControl>
|
||||
) : (
|
||||
<Input
|
||||
disabled
|
||||
placeholder={
|
||||
udpPortMode === "all"
|
||||
? t("allPortsAllowed")
|
||||
: t("allPortsBlocked")
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* ICMP Toggle */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="disableIcmp"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center gap-2">
|
||||
<FormLabel className="min-w-10">
|
||||
ICMP
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={!field.value}
|
||||
onCheckedChange={(checked) => field.onChange(!checked)}
|
||||
/>
|
||||
</FormControl>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.value ? t("blocked") : t("allowed")}
|
||||
</span>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Access Control Section */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
|
||||
@@ -182,7 +182,7 @@ export default function InvitationsTable({
|
||||
setSelectedInvitation(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("inviteQuestionRemove")}</p>
|
||||
<p>{t("inviteMessageRemove")}</p>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
<FaGithub size={12} />
|
||||
</Link>
|
||||
</div>
|
||||
{build === "enterprise" &&
|
||||
isUnlocked() &&
|
||||
licenseStatus?.tier === "personal" ? (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{t("personalUseOnly")}
|
||||
</div>
|
||||
) : null}
|
||||
{build === "enterprise" && !isUnlocked() ? (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
{t("unlicensed")}
|
||||
</div>
|
||||
) : null}
|
||||
{env?.app?.version && (
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
<Link
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
"use client";
|
||||
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient } from "@app/lib/api";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import {
|
||||
logAnalyticsFiltersSchema,
|
||||
logQueries,
|
||||
resourceQueries
|
||||
} from "@app/lib/queries";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardHeader } from "./ui/card";
|
||||
import { LoaderIcon, RefreshCw, XIcon } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { DateRangePicker, type DateTimeValue } from "./DateTimePicker";
|
||||
import { Button } from "./ui/button";
|
||||
import { cn } from "@app/lib/cn";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Card, CardContent, CardHeader } from "./ui/card";
|
||||
|
||||
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "./InfoSection";
|
||||
import { Label } from "./ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -24,23 +29,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "./ui/select";
|
||||
import { Label } from "./ui/label";
|
||||
import { Separator } from "./ui/separator";
|
||||
import {
|
||||
InfoSection,
|
||||
InfoSectionContent,
|
||||
InfoSections,
|
||||
InfoSectionTitle
|
||||
} from "./InfoSection";
|
||||
import { WorldMap } from "./WorldMap";
|
||||
import { countryCodeToFlagEmoji } from "@app/lib/countryCodeToFlagEmoji";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "./ui/tooltip";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartLegend,
|
||||
@@ -49,7 +41,13 @@ import {
|
||||
ChartTooltipContent,
|
||||
type ChartConfig
|
||||
} from "./ui/chart";
|
||||
import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "./ui/tooltip";
|
||||
import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo";
|
||||
|
||||
export type AnalyticsContentProps = {
|
||||
orgId: string;
|
||||
@@ -67,17 +65,18 @@ export function LogAnalyticsData(props: AnalyticsContentProps) {
|
||||
const isEmptySearchParams =
|
||||
!filters.resourceId && !filters.timeStart && !filters.timeEnd;
|
||||
|
||||
const env = useEnvContext();
|
||||
const [api] = useState(() => 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
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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<TData, TValue>({
|
||||
</Button>
|
||||
)}
|
||||
{onExport && (
|
||||
<Button
|
||||
onClick={() => !disabled && onExport()}
|
||||
disabled={isExporting || disabled}
|
||||
>
|
||||
<Download
|
||||
className={`mr-2 h-4 w-4 ${isExporting ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("exportCsv")}
|
||||
</Button>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
onClick={() =>
|
||||
!disabled && onExport()
|
||||
}
|
||||
disabled={isExporting || disabled}
|
||||
>
|
||||
{isExporting ? (
|
||||
<Loader className="mr-2 size-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-2 size-4" />
|
||||
)}
|
||||
{t("exportCsv")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("exportCsvTooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -533,6 +542,8 @@ export function LogDataTable<TData, TValue>({
|
||||
isServerPagination={isServerPagination}
|
||||
isLoading={isLoading}
|
||||
disabled={disabled}
|
||||
pageSize={pageSize}
|
||||
pageIndex={currentPage}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -354,7 +354,7 @@ export default function MachineClientsTable({
|
||||
setSelectedClient(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("deleteClientQuestion")}</p>
|
||||
<p>{t("clientMessageRemove")}</p>
|
||||
</div>
|
||||
|
||||
@@ -189,7 +189,7 @@ export default function OrgApiKeysTable({
|
||||
setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("apiKeysQuestionRemove")}</p>
|
||||
|
||||
<p>{t("apiKeysMessageRemove")}</p>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -535,7 +535,7 @@ export default function ProxyResourcesTable({
|
||||
setSelectedResource(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("resourceQuestionRemove")}</p>
|
||||
<p>{t("resourceMessageRemove")}</p>
|
||||
</div>
|
||||
|
||||
@@ -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) {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{build === "enterprise" && !isUnlocked() ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("instanceIsUnlicensed")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
{build === "enterprise" &&
|
||||
isUnlocked() &&
|
||||
licenseStatus?.tier === "personal" ? (
|
||||
<div className="text-center mt-2">
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("loginPageLicenseWatermark")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<ResourceAccessDenied />
|
||||
|
||||
@@ -412,7 +412,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
|
||||
setSelectedSite(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="">
|
||||
<div className="space-y-2">
|
||||
<p>{t("siteQuestionRemove")}</p>
|
||||
<p>{t("siteMessageRemove")}</p>
|
||||
</div>
|
||||
|
||||
@@ -401,7 +401,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) {
|
||||
setSelectedClient(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("deleteClientQuestion")}</p>
|
||||
<p>{t("clientMessageRemove")}</p>
|
||||
</div>
|
||||
|
||||
@@ -258,7 +258,7 @@ export default function UsersTable({ users: u }: UsersTableProps) {
|
||||
setSelectedUser(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("userQuestionOrgRemove")}</p>
|
||||
<p>{t("userMessageOrgRemove")}</p>
|
||||
</div>
|
||||
|
||||
@@ -224,7 +224,7 @@ export default function ViewDevicesDialog({
|
||||
}
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
{t("deviceQuestionRemove") ||
|
||||
"Are you sure you want to delete this device?"}
|
||||
|
||||
@@ -177,7 +177,7 @@ export default function IdpTable({ idps, orgId }: Props) {
|
||||
setSelectedIdp(null);
|
||||
}}
|
||||
dialog={
|
||||
<div>
|
||||
<div className="space-y-2">
|
||||
<p>{t("idpQuestionRemove")}</p>
|
||||
<p>{t("idpMessageRemove")}</p>
|
||||
</div>
|
||||
|
||||
@@ -308,7 +308,7 @@ export const Autocomplete: React.FC<AutocompleteProps> = ({
|
||||
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
|
||||
|
||||
@@ -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<TData, TValue>({
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
initialColumnVisibility
|
||||
);
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: pageSize
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
defaultTab || tabs?.[0]?.id || ""
|
||||
);
|
||||
@@ -256,6 +261,7 @@ export function DataTable<TData, TValue>({
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
initialState: {
|
||||
pagination: {
|
||||
pageSize: pageSize,
|
||||
@@ -267,21 +273,18 @@ export function DataTable<TData, TValue>({
|
||||
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<TData, TValue>({
|
||||
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<TData, TValue>({
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
onPageSizeChange={handlePageSizeChange}
|
||||
pageSize={pagination.pageSize}
|
||||
pageIndex={pagination.pageIndex}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -88,7 +88,7 @@ const DialogTitle = React.forwardRef<
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-xl font-semibold leading-none tracking-tight",
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
7
src/lib/getSevenDaysAgo.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function getSevenDaysAgo() {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0); // Set to midnight
|
||||
const sevenDaysAgo = new Date(today);
|
||||
sevenDaysAgo.setDate(today.getDate() - 7);
|
||||
return sevenDaysAgo;
|
||||
}
|
||||
@@ -180,17 +180,15 @@ export type LogAnalyticsFilters = z.TypeOf<typeof logAnalyticsFiltersSchema>;
|
||||
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<QueryRequestAnalyticsResponse>
|
||||
>(`/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<ListResourceNamesResponse>
|
||||
>(`/org/${orgId}/resource-names`, {
|
||||
signal
|
||||
|
||||