diff --git a/.dockerignore b/.dockerignore index ecd919cd5..d4f63d635 100644 --- a/.dockerignore +++ b/.dockerignore @@ -28,7 +28,9 @@ LICENSE CONTRIBUTING.md dist .git -migrations/ +server/migrations/ config/ build.ts -tsconfig.json \ No newline at end of file +tsconfig.json +Dockerfile* +drizzle.config.ts diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 196676e99..685be384c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -44,19 +44,9 @@ updates: schedule: interval: "daily" groups: - dev-patch-updates: - dependency-type: "development" + patch-updates: update-types: - "patch" - dev-minor-updates: - dependency-type: "development" + minor-updates: update-types: - "minor" - prod-patch-updates: - dependency-type: "production" - update-types: - - "patch" - prod-minor-updates: - dependency-type: "production" - update-types: - - "minor" \ No newline at end of file diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 011c2d273..fff21995d 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -1,4 +1,4 @@ -name: CI/CD Pipeline +name: Public CICD Pipeline # CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries. # Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events. @@ -17,16 +17,42 @@ on: push: tags: - "[0-9]+.[0-9]+.[0-9]+" - - "[0-9]+.[0-9]+.[0-9]+.rc.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+" concurrency: group: ${{ github.ref }} cancel-in-progress: true jobs: - release: - name: Build and Release - runs-on: [self-hosted, linux, x64] + pre-run: + runs-on: ubuntu-latest + permissions: write-all + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + 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 instances + 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" + + + release-arm: + name: Build and Release (ARM64) + runs-on: [self-hosted, linux, arm64, us-east-1] + needs: [pre-run] + if: >- + ${{ + needs.pre-run.result == 'success' + }} # Job-level timeout to avoid runaway or stuck runs timeout-minutes: 120 env: @@ -36,27 +62,209 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Set up QEMU - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1 + - name: Monitor storage space + run: | + THRESHOLD=75 + USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g') + echo "Used space: $USED_SPACE%" + if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then + echo "Used space is below the threshold of 75% free. Running Docker system prune." + echo y | docker system prune -a + else + echo "Storage space is above the threshold. No action needed." + fi - name: Log in to Docker Hub - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: docker.io username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Extract tag name + id: get-tag + run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + shell: bash + + - name: Update version in package.json + run: | + TAG=${{ env.TAG }} + sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts + cat server/lib/consts.ts + shell: bash + + - name: Check if release candidate + id: check-rc + run: | + TAG=${{ env.TAG }} + if [[ "$TAG" == *"-rc."* ]]; then + echo "IS_RC=true" >> $GITHUB_ENV + else + echo "IS_RC=false" >> $GITHUB_ENV + fi + shell: bash + + - name: Build and push Docker images (Docker Hub - ARM64) + run: | + TAG=${{ env.TAG }} + if [ "$IS_RC" = "true" ]; then + make build-rc-arm tag=$TAG + else + make build-release-arm tag=$TAG + fi + echo "Built & pushed ARM64 images to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}" + shell: bash + + release-amd: + name: Build and Release (AMD64) + runs-on: [self-hosted, linux, x64, us-east-1] + needs: [pre-run] + if: >- + ${{ + needs.pre-run.result == 'success' + }} + # Job-level timeout to avoid runaway or stuck runs + timeout-minutes: 120 + env: + # Target images + DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }} + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Monitor storage space + run: | + THRESHOLD=75 + USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g') + echo "Used space: $USED_SPACE%" + if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then + echo "Used space is below the threshold of 75% free. Running Docker system prune." + echo y | docker system prune -a + else + echo "Storage space is above the threshold. No action needed." + fi + + - name: Log in to Docker Hub + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + with: + registry: docker.io + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Extract tag name + id: get-tag + run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + shell: bash + + - name: Update version in package.json + run: | + TAG=${{ env.TAG }} + sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts + cat server/lib/consts.ts + shell: bash + + - name: Check if release candidate + id: check-rc + run: | + TAG=${{ env.TAG }} + if [[ "$TAG" == *"-rc."* ]]; then + echo "IS_RC=true" >> $GITHUB_ENV + else + echo "IS_RC=false" >> $GITHUB_ENV + fi + shell: bash + + - name: Build and push Docker images (Docker Hub - AMD64) + run: | + TAG=${{ env.TAG }} + if [ "$IS_RC" = "true" ]; then + make build-rc-amd tag=$TAG + else + make build-release-amd tag=$TAG + fi + echo "Built & pushed AMD64 images to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}" + shell: bash + + create-manifest: + name: Create Multi-Arch Manifests + runs-on: [self-hosted, linux, x64, us-east-1] + needs: [release-arm, release-amd] + if: >- + ${{ + needs.release-arm.result == 'success' && + needs.release-amd.result == 'success' + }} + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Log in to Docker Hub + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + with: + registry: docker.io + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }} + + - name: Extract tag name + id: get-tag + run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + shell: bash + + - name: Check if release candidate + id: check-rc + run: | + TAG=${{ env.TAG }} + if [[ "$TAG" == *"-rc."* ]]; then + echo "IS_RC=true" >> $GITHUB_ENV + else + echo "IS_RC=false" >> $GITHUB_ENV + fi + shell: bash + + - name: Create multi-arch manifests + run: | + TAG=${{ env.TAG }} + if [ "$IS_RC" = "true" ]; then + make create-manifests-rc tag=$TAG + else + make create-manifests tag=$TAG + fi + echo "Created multi-arch manifests for tag: ${TAG}" + shell: bash + + sign-and-package: + name: Sign and Package + runs-on: [self-hosted, linux, x64, us-east-1] + needs: [release-arm, release-amd, create-manifest] + if: >- + ${{ + needs.release-arm.result == 'success' && + needs.release-amd.result == 'success' && + needs.create-manifest.result == 'success' + }} + # Job-level timeout to avoid runaway or stuck runs + timeout-minutes: 120 + env: + # Target images + DOCKERHUB_IMAGE: docker.io/fosrl/${{ github.event.repository.name }} + GHCR_IMAGE: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }} + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Extract tag name id: get-tag run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV shell: bash - name: Install Go - uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6.1.0 + uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0 with: go-version: 1.24 @@ -81,36 +289,21 @@ jobs: echo "LATEST_BADGER_TAG=$LATEST_TAG" >> $GITHUB_ENV shell: bash - - name: Update install/main.go - run: | - PANGOLIN_VERSION=${{ env.TAG }} - GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }} - BADGER_VERSION=${{ env.LATEST_BADGER_TAG }} - sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$PANGOLIN_VERSION\"/" install/main.go - sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$GERBIL_VERSION\"/" install/main.go - sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$BADGER_VERSION\"/" install/main.go - echo "Updated install/main.go with Pangolin version $PANGOLIN_VERSION, Gerbil version $GERBIL_VERSION, and Badger version $BADGER_VERSION" - cat install/main.go - shell: bash - - name: Build installer working-directory: install run: | - make go-build-release + make go-build-release \ + PANGOLIN_VERSION=${{ env.TAG }} \ + GERBIL_VERSION=${{ env.LATEST_GERBIL_TAG }} \ + BADGER_VERSION=${{ env.LATEST_BADGER_TAG }} + shell: bash - name: Upload artifacts from /install/bin - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: install-bin path: install/bin/ - - name: Build and push Docker images (Docker Hub) - run: | - TAG=${{ env.TAG }} - make -j4 build-release tag=$TAG - echo "Built & pushed to: ${{ env.DOCKERHUB_IMAGE }}:${TAG}" - shell: bash - - name: Install skopeo + jq # skopeo: copy/inspect images between registries # jq: JSON parsing tool used to extract digest values @@ -121,23 +314,100 @@ jobs: shell: bash - name: Login to GHCR + env: + REGISTRY_AUTH_FILE: ${{ runner.temp }}/containers/auth.json run: | + mkdir -p "$(dirname "$REGISTRY_AUTH_FILE")" skopeo login ghcr.io -u "${{ github.actor }}" -p "${{ secrets.GITHUB_TOKEN }}" shell: bash - - name: Copy tag from Docker Hub to GHCR - # Mirror the already-built image (all architectures) to GHCR so we can sign it + - name: Copy tags from Docker Hub to GHCR + # Mirror the already-built images (all architectures) to GHCR so we can sign them + # Wait a bit for both architectures to be available in Docker Hub manifest + env: + REGISTRY_AUTH_FILE: ${{ runner.temp }}/containers/auth.json run: | set -euo pipefail TAG=${{ env.TAG }} - echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}" - skopeo copy --all --retry-times 3 \ - docker://$DOCKERHUB_IMAGE:$TAG \ - docker://$GHCR_IMAGE:$TAG + MAJOR_TAG=$(echo $TAG | cut -d. -f1) + MINOR_TAG=$(echo $TAG | cut -d. -f1,2) + + echo "Waiting for multi-arch manifests to be ready..." + sleep 30 + + # Determine if this is an RC release + IS_RC="false" + if [[ "$TAG" == *"-rc."* ]]; then + IS_RC="true" + fi + + if [ "$IS_RC" = "true" ]; then + echo "RC release detected - copying version-specific tags only" + + # SQLite OSS + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:$TAG \ + docker://$GHCR_IMAGE:$TAG + + # PostgreSQL OSS + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:postgresql-$TAG \ + docker://$GHCR_IMAGE:postgresql-$TAG + + # SQLite Enterprise + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-${TAG}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:ee-$TAG \ + docker://$GHCR_IMAGE:ee-$TAG + + # PostgreSQL Enterprise + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:ee-postgresql-$TAG \ + docker://$GHCR_IMAGE:ee-postgresql-$TAG + else + echo "Regular release detected - copying all tags (latest, major, minor, full version)" + + # SQLite OSS - all tags + for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:${TAG_SUFFIX}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:$TAG_SUFFIX \ + docker://$GHCR_IMAGE:$TAG_SUFFIX + done + + # PostgreSQL OSS - all tags + for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:postgresql-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:postgresql-${TAG_SUFFIX}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:postgresql-$TAG_SUFFIX \ + docker://$GHCR_IMAGE:postgresql-$TAG_SUFFIX + done + + # SQLite Enterprise - all tags + for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:ee-${TAG_SUFFIX}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:ee-$TAG_SUFFIX \ + docker://$GHCR_IMAGE:ee-$TAG_SUFFIX + done + + # PostgreSQL Enterprise - all tags + for TAG_SUFFIX in "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG"; do + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:ee-postgresql-${TAG_SUFFIX} -> ${{ env.GHCR_IMAGE }}:ee-postgresql-${TAG_SUFFIX}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:ee-postgresql-$TAG_SUFFIX \ + docker://$GHCR_IMAGE:ee-postgresql-$TAG_SUFFIX + done + fi + + echo "All images copied successfully to GHCR!" shell: bash - + - name: Login to GitHub Container Registry (for cosign) - uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -145,7 +415,7 @@ jobs: - name: Install cosign # cosign is used to sign and verify container images (key and keyless) - uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 - name: Dual-sign and verify (GHCR & Docker Hub) # Sign each image by digest using keyless (OIDC) and key-based signing, @@ -162,26 +432,155 @@ jobs: issuer="https://token.actions.githubusercontent.com" id_regex="^https://github.com/${{ github.repository }}/.+" # accept this repo (all workflows/refs) - for IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do - echo "Processing ${IMAGE}:${TAG}" + # Track failures + FAILED_TAGS=() + SUCCESSFUL_TAGS=() - DIGEST="$(skopeo inspect --retry-times 3 docker://${IMAGE}:${TAG} | jq -r '.Digest')" - REF="${IMAGE}@${DIGEST}" - echo "Resolved digest: ${REF}" + # Determine if this is an RC release + IS_RC="false" + if [[ "$TAG" == *"-rc."* ]]; then + IS_RC="true" + fi - echo "==> cosign sign (keyless) --recursive ${REF}" - cosign sign --recursive "${REF}" + # Define image variants to sign + if [ "$IS_RC" = "true" ]; then + echo "RC release - signing version-specific tags only" + IMAGE_TAGS=( + "${TAG}" + "postgresql-${TAG}" + "ee-${TAG}" + "ee-postgresql-${TAG}" + ) + else + echo "Regular release - signing all tags" + MAJOR_TAG=$(echo $TAG | cut -d. -f1) + MINOR_TAG=$(echo $TAG | cut -d. -f1,2) + IMAGE_TAGS=( + "latest" "$MAJOR_TAG" "$MINOR_TAG" "$TAG" + "postgresql-latest" "postgresql-$MAJOR_TAG" "postgresql-$MINOR_TAG" "postgresql-$TAG" + "ee-latest" "ee-$MAJOR_TAG" "ee-$MINOR_TAG" "ee-$TAG" + "ee-postgresql-latest" "ee-postgresql-$MAJOR_TAG" "ee-postgresql-$MINOR_TAG" "ee-postgresql-$TAG" + ) + fi - echo "==> cosign sign (key) --recursive ${REF}" - cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" + # Sign each image variant for both registries + for BASE_IMAGE in "${GHCR_IMAGE}" "${DOCKERHUB_IMAGE}"; do + for IMAGE_TAG in "${IMAGE_TAGS[@]}"; do + echo "Processing ${BASE_IMAGE}:${IMAGE_TAG}" + TAG_FAILED=false - echo "==> cosign verify (public key) ${REF}" - cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text + # Wrap the entire tag processing in error handling + ( + set -e + DIGEST="$(skopeo inspect --retry-times 3 docker://${BASE_IMAGE}:${IMAGE_TAG} | jq -r '.Digest')" + REF="${BASE_IMAGE}@${DIGEST}" + echo "Resolved digest: ${REF}" - echo "==> cosign verify (keyless policy) ${REF}" - cosign verify \ - --certificate-oidc-issuer "${issuer}" \ - --certificate-identity-regexp "${id_regex}" \ - "${REF}" -o text + echo "==> cosign sign (keyless) --recursive ${REF}" + cosign sign --recursive "${REF}" + + echo "==> cosign sign (key) --recursive ${REF}" + cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" + + # Retry wrapper for verification to handle registry propagation delays + retry_verify() { + local cmd="$1" + local attempts=6 + local delay=5 + local i=1 + until eval "$cmd"; do + if [ $i -ge $attempts ]; then + echo "Verification failed after $attempts attempts" + return 1 + fi + echo "Verification not yet available. Retry $i/$attempts after ${delay}s..." + sleep $delay + i=$((i+1)) + delay=$((delay*2)) + # Cap the delay to avoid very long waits + if [ $delay -gt 60 ]; then delay=60; fi + done + return 0 + } + + echo "==> cosign verify (public key) ${REF}" + if retry_verify "cosign verify --key env://COSIGN_PUBLIC_KEY '${REF}' -o text"; then + VERIFIED_INDEX=true + else + VERIFIED_INDEX=false + fi + + echo "==> cosign verify (keyless policy) ${REF}" + if retry_verify "cosign verify --certificate-oidc-issuer '${issuer}' --certificate-identity-regexp '${id_regex}' '${REF}' -o text"; then + VERIFIED_INDEX_KEYLESS=true + else + VERIFIED_INDEX_KEYLESS=false + fi + + # Check if verification succeeded + if [ "${VERIFIED_INDEX}" != "true" ] && [ "${VERIFIED_INDEX_KEYLESS}" != "true" ]; then + echo "⚠️ WARNING: Verification not available for ${BASE_IMAGE}:${IMAGE_TAG}" + echo "This may be due to registry propagation delays. Continuing anyway." + fi + ) || TAG_FAILED=true + + if [ "$TAG_FAILED" = "true" ]; then + echo "⚠️ WARNING: Failed to sign/verify ${BASE_IMAGE}:${IMAGE_TAG}" + FAILED_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}") + else + echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}" + SUCCESSFUL_TAGS+=("${BASE_IMAGE}:${IMAGE_TAG}") + fi + done done + + # Report summary + echo "" + echo "==========================================" + echo "Sign and Verify Summary" + echo "==========================================" + echo "Successful: ${#SUCCESSFUL_TAGS[@]}" + echo "Failed: ${#FAILED_TAGS[@]}" + echo "" + + if [ ${#FAILED_TAGS[@]} -gt 0 ]; then + echo "Failed tags:" + for tag in "${FAILED_TAGS[@]}"; do + echo " - $tag" + done + echo "" + echo "⚠️ WARNING: Some tags failed to sign/verify, but continuing anyway" + else + echo "✓ All images signed and verified successfully!" + fi shell: bash + + post-run: + needs: [pre-run, release-arm, release-amd, create-manifest, sign-and-package] + if: >- + ${{ + always() && + needs.pre-run.result == 'success' && + (needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure') && + (needs.release-amd.result == 'success' || needs.release-amd.result == 'skipped' || needs.release-amd.result == 'failure') && + (needs.create-manifest.result == 'success' || needs.create-manifest.result == 'skipped' || needs.create-manifest.result == 'failure') && + (needs.sign-and-package.result == 'success' || needs.sign-and-package.result == 'skipped' || needs.sign-and-package.result == 'failure') + }} + runs-on: ubuntu-latest + permissions: write-all + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + 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: Stop EC2 instances + run: | + aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} + aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_AMD_RUNNER }} + echo "EC2 instances stopped" diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml index 98f9b1c8f..cf574dd3c 100644 --- a/.github/workflows/linting.yml +++ b/.github/workflows/linting.yml @@ -21,12 +21,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Node.js - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: '22' + node-version: '24' - name: Install dependencies run: npm ci diff --git a/.github/workflows/mirror.yaml b/.github/workflows/mirror.yaml index c4f059f91..d6dfdb8fb 100644 --- a/.github/workflows/mirror.yaml +++ b/.github/workflows/mirror.yaml @@ -23,7 +23,7 @@ jobs: skopeo --version - name: Install cosign - uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 # v4.1.0 - name: Input check run: | @@ -45,7 +45,7 @@ jobs: run: | set -euo pipefail skopeo list-tags --retry-times 3 docker://"${SOURCE_IMAGE}" \ - | jq -r '.Tags[]' | sort -u > src-tags.txt + | jq -r '.Tags[]' | grep -v -e '-arm64' -e '-amd64' | sort -u > src-tags.txt echo "Found source tags: $(wc -l < src-tags.txt)" head -n 20 src-tags.txt || true diff --git a/.github/workflows/restart-runners.yml b/.github/workflows/restart-runners.yml index 16901d1b2..6c0f7cbc1 100644 --- a/.github/workflows/restart-runners.yml +++ b/.github/workflows/restart-runners.yml @@ -14,7 +14,7 @@ jobs: permissions: write-all steps: - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v5 + uses: aws-actions/configure-aws-credentials@v6 with: role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} role-duration-seconds: 3600 diff --git a/.github/workflows/saas.yml b/.github/workflows/saas.yml new file mode 100644 index 000000000..7c3d0adac --- /dev/null +++ b/.github/workflows/saas.yml @@ -0,0 +1,160 @@ +name: SAAS Pipeline + +# CI/CD workflow for building, publishing, mirroring, signing container images and building release binaries. +# Actions are pinned to specific SHAs to reduce supply-chain risk. This workflow triggers on tag push events. + +permissions: + contents: read + packages: write # for GHCR push + id-token: write # for Cosign Keyless (OIDC) Signing + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+-s.[0-9]+" + +concurrency: + group: ${{ github.ref }} + cancel-in-progress: true + +jobs: + pre-run: + runs-on: ubuntu-latest + permissions: write-all + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + 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 instances + run: | + aws ec2 start-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} + echo "EC2 instances started" + + + release-arm: + name: Build and Release (ARM64) + runs-on: [self-hosted, linux, arm64, us-east-1] + needs: [pre-run] + if: >- + ${{ + needs.pre-run.result == 'success' + }} + # Job-level timeout to avoid runaway or stuck runs + timeout-minutes: 120 + env: + # Target images + AWS_IMAGE: ${{ secrets.aws_account_id }}.dkr.ecr.us-east-1.amazonaws.com/${{ github.event.repository.name }} + + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Download MaxMind GeoLite2 databases + env: + MAXMIND_LICENSE_KEY: ${{ secrets.MAXMIND_LICENSE_KEY }} + run: | + echo "Downloading MaxMind GeoLite2 databases..." + + # Download GeoLite2-Country + curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \ + -o GeoLite2-Country.tar.gz + + # Download GeoLite2-ASN + curl -L "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=${MAXMIND_LICENSE_KEY}&suffix=tar.gz" \ + -o GeoLite2-ASN.tar.gz + + # Extract the .mmdb files + tar -xzf GeoLite2-Country.tar.gz --strip-components=1 --wildcards '*.mmdb' + tar -xzf GeoLite2-ASN.tar.gz --strip-components=1 --wildcards '*.mmdb' + + # Verify files exist + if [ ! -f "GeoLite2-Country.mmdb" ]; then + echo "ERROR: Failed to download GeoLite2-Country.mmdb" + exit 1 + fi + + if [ ! -f "GeoLite2-ASN.mmdb" ]; then + echo "ERROR: Failed to download GeoLite2-ASN.mmdb" + exit 1 + fi + + # Clean up tar files + rm -f GeoLite2-Country.tar.gz GeoLite2-ASN.tar.gz + + echo "MaxMind databases downloaded successfully" + ls -lh GeoLite2-*.mmdb + + - name: Monitor storage space + run: | + THRESHOLD=75 + USED_SPACE=$(df / | grep / | awk '{ print $5 }' | sed 's/%//g') + echo "Used space: $USED_SPACE%" + if [ "$USED_SPACE" -ge "$THRESHOLD" ]; then + echo "Used space is below the threshold of 75% free. Running Docker system prune." + echo y | docker system prune -a + else + echo "Storage space is above the threshold. No action needed." + fi + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + 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: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Extract tag name + id: get-tag + run: echo "TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + shell: bash + + - name: Update version in package.json + run: | + TAG=${{ env.TAG }} + sed -i "s/export const APP_VERSION = \".*\";/export const APP_VERSION = \"$TAG\";/" server/lib/consts.ts + cat server/lib/consts.ts + shell: bash + + - name: Build and push Docker images (Docker Hub - ARM64) + run: | + TAG=${{ env.TAG }} + make build-saas tag=$TAG + echo "Built & pushed ARM64 images to: ${{ env.AWS_IMAGE }}:${TAG}" + shell: bash + + post-run: + needs: [pre-run, release-arm] + if: >- + ${{ + always() && + needs.pre-run.result == 'success' && + (needs.release-arm.result == 'success' || needs.release-arm.result == 'skipped' || needs.release-arm.result == 'failure') + }} + runs-on: ubuntu-latest + permissions: write-all + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v6 + 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: Stop EC2 instances + run: | + aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} + echo "EC2 instances stopped" diff --git a/.github/workflows/stale-bot.yml b/.github/workflows/stale-bot.yml index 4df7e93ec..2db8632e9 100644 --- a/.github/workflows/stale-bot.yml +++ b/.github/workflows/stale-bot.yml @@ -14,7 +14,7 @@ jobs: stale: runs-on: ubuntu-latest steps: - - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1 + - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0 with: days-before-stale: 14 days-before-close: 14 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 41d43bd9a..30567f0f7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,12 +14,12 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Install Node - uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6.1.0 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: '22' + node-version: '24' - name: Copy config file run: cp config/config.example.yml config/config.yml @@ -34,10 +34,10 @@ jobs: run: npm run set:oss - name: Generate database migrations - run: npm run db:sqlite:generate + run: npm run db:generate - name: Apply database migrations - run: npm run db:sqlite:push + run: npm run db:push - name: Test with tsc run: npx tsc --noEmit @@ -62,10 +62,7 @@ jobs: 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 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Build Docker image sqlite run: make dev-build-sqlite @@ -74,10 +71,7 @@ jobs: 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 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Build Docker image pg run: make dev-build-pg diff --git a/.gitignore b/.gitignore index 700963cc5..004f95c18 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,7 @@ dynamic/ *.mmdb scratch/ tsconfig.json -hydrateSaas.ts \ No newline at end of file +hydrateSaas.ts +CLAUDE.md +drizzle.config.ts +server/setup/migrations.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 77440d967..5092cb6c1 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,7 +4,7 @@ }, "editor.defaultFormatter": "esbenp.prettier-vscode", "[jsonc]": { - "editor.defaultFormatter": "esbenp.prettier-vscode" + "editor.defaultFormatter": "vscode.json-language-features" }, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" @@ -19,4 +19,4 @@ "editor.defaultFormatter": "esbenp.prettier-vscode" }, "editor.formatOnSave": true -} +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index fa2d71c03..9af37f89c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,70 +1,93 @@ -FROM node:24-alpine AS builder +# FROM node:24-slim AS base +FROM public.ecr.aws/docker/library/node:24-slim AS base WORKDIR /app -ARG BUILD=oss -ARG DATABASE=sqlite +RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/* -RUN apk add --no-cache curl tzdata python3 make g++ - -# COPY package.json package-lock.json ./ COPY package*.json ./ + +FROM base AS builder-dev + RUN npm ci COPY . . -RUN echo "export * from \"./$DATABASE\";" > server/db/index.ts -RUN echo "export const driver: \"pg\" | \"sqlite\" = \"$DATABASE\";" >> server/db/index.ts +ARG BUILD=oss +ARG DATABASE=sqlite -RUN echo "export const build = \"$BUILD\" as \"saas\" | \"enterprise\" | \"oss\";" > server/build.ts +RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi && \ + npm run set:$DATABASE && \ + npm run set:$BUILD && \ + npm run db:generate && \ + npm run build && \ + npm run build:cli && \ + test -f dist/server.mjs -# Copy the appropriate TypeScript configuration based on build type -RUN if [ "$BUILD" = "oss" ]; then cp tsconfig.oss.json tsconfig.json; \ - elif [ "$BUILD" = "saas" ]; then cp tsconfig.saas.json tsconfig.json; \ - elif [ "$BUILD" = "enterprise" ]; then cp tsconfig.enterprise.json tsconfig.json; \ - fi +# Create placeholder files for MaxMind databases to avoid COPY errors +# Real files should be present for saas builds, placeholders for oss builds +RUN touch /app/GeoLite2-Country.mmdb /app/GeoLite2-ASN.mmdb -# if the build is oss then remove the server/private directory -RUN if [ "$BUILD" = "oss" ]; then rm -rf server/private; fi +FROM base AS builder -RUN if [ "$DATABASE" = "pg" ]; then npx drizzle-kit generate --dialect postgresql --schema ./server/db/pg/schema --out init; else npx drizzle-kit generate --dialect $DATABASE --schema ./server/db/$DATABASE/schema --out init; fi +RUN npm ci --omit=dev -RUN mkdir -p dist -RUN npm run next:build -RUN node esbuild.mjs -e server/index.ts -o dist/server.mjs -b $BUILD -RUN if [ "$DATABASE" = "pg" ]; then \ - node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs; \ - else \ - node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs; \ - fi - -# test to make sure the build output is there and error if not -RUN test -f dist/server.mjs - -RUN npm run build:cli - -FROM node:24-alpine AS runner +# FROM node:24-slim AS runner +FROM public.ecr.aws/docker/library/node:24-slim 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++ +RUN apt-get update && apt-get install -y curl tzdata && rm -rf /var/lib/apt/lists/* -# COPY package.json package-lock.json ./ -COPY package*.json ./ +COPY --from=builder /app/node_modules ./node_modules +COPY --from=builder /app/package.json ./package.json -RUN npm ci --omit=dev && npm cache clean --force - -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-dev /app/.next/standalone ./ +COPY --from=builder-dev /app/.next/static ./.next/static +COPY --from=builder-dev /app/dist ./dist +COPY --from=builder-dev /app/server/migrations ./dist/init COPY ./cli/wrapper.sh /usr/local/bin/pangctl RUN chmod +x /usr/local/bin/pangctl ./dist/cli.mjs COPY server/db/names.json ./dist/names.json +COPY server/db/ios_models.json ./dist/ios_models.json +COPY server/db/mac_models.json ./dist/mac_models.json COPY public ./public +# Copy MaxMind databases for SaaS builds +ARG BUILD=oss + +RUN mkdir -p ./maxmind + +# Copy MaxMind databases (placeholders exist for oss builds, real files for saas) +COPY --from=builder-dev /app/GeoLite2-Country.mmdb ./maxmind/GeoLite2-Country.mmdb +COPY --from=builder-dev /app/GeoLite2-ASN.mmdb ./maxmind/GeoLite2-ASN.mmdb + +# Remove MaxMind databases for non-saas builds (keep only for saas) +RUN if [ "$BUILD" != "saas" ]; then rm -rf ./maxmind; fi + +# OCI Image Labels - Build Args for dynamic values +ARG VERSION="dev" +ARG REVISION="" +ARG CREATED="" +ARG LICENSE="AGPL-3.0" + +# Derive title and description based on BUILD type +ARG IMAGE_TITLE="Pangolin" +ARG IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" + +# OCI Image Labels +# https://github.com/opencontainers/image-spec/blob/main/annotations.md +LABEL org.opencontainers.image.source="https://github.com/fosrl/pangolin" \ + org.opencontainers.image.url="https://github.com/fosrl/pangolin" \ + org.opencontainers.image.documentation="https://docs.pangolin.net" \ + org.opencontainers.image.vendor="Fossorial" \ + org.opencontainers.image.licenses="${LICENSE}" \ + org.opencontainers.image.title="${IMAGE_TITLE}" \ + org.opencontainers.image.description="${IMAGE_DESCRIPTION}" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${REVISION}" \ + org.opencontainers.image.created="${CREATED}" + CMD ["npm", "run", "start"] diff --git a/Dockerfile.dev b/Dockerfile.dev index c40775c23..3e5965fc1 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -1,7 +1,9 @@ -FROM node:22-alpine +FROM node:24-alpine WORKDIR /app +RUN apk add --no-cache python3 make g++ + COPY package*.json ./ # Install dependencies diff --git a/Makefile b/Makefile index 1519aec7d..da31b6e27 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,27 @@ -.PHONY: build dev-build-sqlite dev-build-pg build-release build-arm build-x86 test clean +.PHONY: build build-pg build-release build-release-arm build-release-amd create-manifests build-arm build-x86 test clean major_tag := $(shell echo $(tag) | cut -d. -f1) minor_tag := $(shell echo $(tag) | cut -d. -f1,2) +# OCI label variables +CREATED := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") +REVISION := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown") + +# Common OCI build args for OSS builds +OCI_ARGS_OSS = --build-arg VERSION=$(tag) \ + --build-arg REVISION=$(REVISION) \ + --build-arg CREATED=$(CREATED) \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" + +# Common OCI build args for Enterprise builds +OCI_ARGS_EE = --build-arg VERSION=$(tag) \ + --build-arg REVISION=$(REVISION) \ + --build-arg CREATED=$(CREATED) \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" + .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 @@ -15,6 +34,7 @@ build-sqlite: docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=sqlite \ + $(OCI_ARGS_OSS) \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:latest \ --tag fosrl/pangolin:$(major_tag) \ @@ -30,6 +50,7 @@ build-postgresql: docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=pg \ + $(OCI_ARGS_OSS) \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:postgresql-latest \ --tag fosrl/pangolin:postgresql-$(major_tag) \ @@ -45,6 +66,7 @@ build-ee-sqlite: docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=sqlite \ + $(OCI_ARGS_EE) \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:ee-latest \ --tag fosrl/pangolin:ee-$(major_tag) \ @@ -60,6 +82,7 @@ build-ee-postgresql: docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=pg \ + $(OCI_ARGS_EE) \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:ee-postgresql-latest \ --tag fosrl/pangolin:ee-postgresql-$(major_tag) \ @@ -67,47 +90,431 @@ build-ee-postgresql: --tag fosrl/pangolin:ee-postgresql-$(tag) \ --push . -build-rc: +build-saas: @if [ -z "$(tag)" ]; then \ echo "Error: tag is required. Usage: make build-release tag="; \ exit 1; \ fi + docker buildx build \ + --build-arg BUILD=saas \ + --build-arg DATABASE=pg \ + --platform linux/arm64 \ + --tag $(AWS_IMAGE):$(tag) \ + --push . + +build-release-arm: + @if [ -z "$(tag)" ]; then \ + echo "Error: tag is required. Usage: make build-release-arm tag="; \ + exit 1; \ + fi + @MAJOR_TAG=$$(echo $(tag) | cut -d. -f1); \ + MINOR_TAG=$$(echo $(tag) | cut -d. -f1,2); \ + CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/arm64 \ + --tag fosrl/pangolin:latest-arm64 \ + --tag fosrl/pangolin:$$MAJOR_TAG-arm64 \ + --tag fosrl/pangolin:$$MINOR_TAG-arm64 \ + --tag fosrl/pangolin:$(tag)-arm64 \ + --push . && \ + docker buildx build \ + --build-arg BUILD=oss \ + --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/arm64 \ + --tag fosrl/pangolin:postgresql-latest-arm64 \ + --tag fosrl/pangolin:postgresql-$$MAJOR_TAG-arm64 \ + --tag fosrl/pangolin:postgresql-$$MINOR_TAG-arm64 \ + --tag fosrl/pangolin:postgresql-$(tag)-arm64 \ + --push . && \ + docker buildx build \ + --build-arg BUILD=enterprise \ + --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/arm64 \ + --tag fosrl/pangolin:ee-latest-arm64 \ + --tag fosrl/pangolin:ee-$$MAJOR_TAG-arm64 \ + --tag fosrl/pangolin:ee-$$MINOR_TAG-arm64 \ + --tag fosrl/pangolin:ee-$(tag)-arm64 \ + --push . && \ + docker buildx build \ + --build-arg BUILD=enterprise \ + --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/arm64 \ + --tag fosrl/pangolin:ee-postgresql-latest-arm64 \ + --tag fosrl/pangolin:ee-postgresql-$$MAJOR_TAG-arm64 \ + --tag fosrl/pangolin:ee-postgresql-$$MINOR_TAG-arm64 \ + --tag fosrl/pangolin:ee-postgresql-$(tag)-arm64 \ + --push . + +build-release-amd: + @if [ -z "$(tag)" ]; then \ + echo "Error: tag is required. Usage: make build-release-amd tag="; \ + exit 1; \ + fi + @MAJOR_TAG=$$(echo $(tag) | cut -d. -f1); \ + MINOR_TAG=$$(echo $(tag) | cut -d. -f1,2); \ + CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ + docker buildx build \ + --build-arg BUILD=oss \ + --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/amd64 \ + --tag fosrl/pangolin:latest-amd64 \ + --tag fosrl/pangolin:$$MAJOR_TAG-amd64 \ + --tag fosrl/pangolin:$$MINOR_TAG-amd64 \ + --tag fosrl/pangolin:$(tag)-amd64 \ + --push . && \ + docker buildx build \ + --build-arg BUILD=oss \ + --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/amd64 \ + --tag fosrl/pangolin:postgresql-latest-amd64 \ + --tag fosrl/pangolin:postgresql-$$MAJOR_TAG-amd64 \ + --tag fosrl/pangolin:postgresql-$$MINOR_TAG-amd64 \ + --tag fosrl/pangolin:postgresql-$(tag)-amd64 \ + --push . && \ + docker buildx build \ + --build-arg BUILD=enterprise \ + --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/amd64 \ + --tag fosrl/pangolin:ee-latest-amd64 \ + --tag fosrl/pangolin:ee-$$MAJOR_TAG-amd64 \ + --tag fosrl/pangolin:ee-$$MINOR_TAG-amd64 \ + --tag fosrl/pangolin:ee-$(tag)-amd64 \ + --push . && \ + docker buildx build \ + --build-arg BUILD=enterprise \ + --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/amd64 \ + --tag fosrl/pangolin:ee-postgresql-latest-amd64 \ + --tag fosrl/pangolin:ee-postgresql-$$MAJOR_TAG-amd64 \ + --tag fosrl/pangolin:ee-postgresql-$$MINOR_TAG-amd64 \ + --tag fosrl/pangolin:ee-postgresql-$(tag)-amd64 \ + --push . + +create-manifests: + @if [ -z "$(tag)" ]; then \ + echo "Error: tag is required. Usage: make create-manifests tag="; \ + exit 1; \ + fi + @MAJOR_TAG=$$(echo $(tag) | cut -d. -f1); \ + MINOR_TAG=$$(echo $(tag) | cut -d. -f1,2); \ + echo "Creating multi-arch manifests for sqlite (oss)..." && \ + docker buildx imagetools create \ + --tag fosrl/pangolin:latest \ + --tag fosrl/pangolin:$$MAJOR_TAG \ + --tag fosrl/pangolin:$$MINOR_TAG \ + --tag fosrl/pangolin:$(tag) \ + fosrl/pangolin:latest-arm64 \ + fosrl/pangolin:latest-amd64 && \ + echo "Creating multi-arch manifests for postgresql (oss)..." && \ + docker buildx imagetools create \ + --tag fosrl/pangolin:postgresql-latest \ + --tag fosrl/pangolin:postgresql-$$MAJOR_TAG \ + --tag fosrl/pangolin:postgresql-$$MINOR_TAG \ + --tag fosrl/pangolin:postgresql-$(tag) \ + fosrl/pangolin:postgresql-latest-arm64 \ + fosrl/pangolin:postgresql-latest-amd64 && \ + echo "Creating multi-arch manifests for sqlite (enterprise)..." && \ + docker buildx imagetools create \ + --tag fosrl/pangolin:ee-latest \ + --tag fosrl/pangolin:ee-$$MAJOR_TAG \ + --tag fosrl/pangolin:ee-$$MINOR_TAG \ + --tag fosrl/pangolin:ee-$(tag) \ + fosrl/pangolin:ee-latest-arm64 \ + fosrl/pangolin:ee-latest-amd64 && \ + echo "Creating multi-arch manifests for postgresql (enterprise)..." && \ + docker buildx imagetools create \ + --tag fosrl/pangolin:ee-postgresql-latest \ + --tag fosrl/pangolin:ee-postgresql-$$MAJOR_TAG \ + --tag fosrl/pangolin:ee-postgresql-$$MINOR_TAG \ + --tag fosrl/pangolin:ee-postgresql-$(tag) \ + fosrl/pangolin:ee-postgresql-latest-arm64 \ + fosrl/pangolin:ee-postgresql-latest-amd64 && \ + echo "All multi-arch manifests created successfully!" + +build-rc: + @if [ -z "$(tag)" ]; then \ + echo "Error: tag is required. Usage: make build-release tag="; \ + exit 1; \ + fi + @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ + docker buildx build \ + --build-arg BUILD=oss \ + --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:$(tag) \ - --push . + --push . && \ docker buildx build \ --build-arg BUILD=oss \ --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:postgresql-$(tag) \ - --push . + --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:ee-$(tag) \ - --push . + --push . && \ docker buildx build \ --build-arg BUILD=enterprise \ --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ --platform linux/arm64,linux/amd64 \ --tag fosrl/pangolin:ee-postgresql-$(tag) \ --push . +build-rc-arm: + @if [ -z "$(tag)" ]; then \ + echo "Error: tag is required. Usage: make build-rc-arm tag="; \ + exit 1; \ + fi + @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ + docker buildx build \ + --build-arg BUILD=oss \ + --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/arm64 \ + --tag fosrl/pangolin:$(tag)-arm64 \ + --push . && \ + docker buildx build \ + --build-arg BUILD=oss \ + --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/arm64 \ + --tag fosrl/pangolin:postgresql-$(tag)-arm64 \ + --push . && \ + docker buildx build \ + --build-arg BUILD=enterprise \ + --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/arm64 \ + --tag fosrl/pangolin:ee-$(tag)-arm64 \ + --push . && \ + docker buildx build \ + --build-arg BUILD=enterprise \ + --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/arm64 \ + --tag fosrl/pangolin:ee-postgresql-$(tag)-arm64 \ + --push . + +build-rc-amd: + @if [ -z "$(tag)" ]; then \ + echo "Error: tag is required. Usage: make build-rc-amd tag="; \ + exit 1; \ + fi + @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ + docker buildx build \ + --build-arg BUILD=oss \ + --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/amd64 \ + --tag fosrl/pangolin:$(tag)-amd64 \ + --push . && \ + docker buildx build \ + --build-arg BUILD=oss \ + --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/amd64 \ + --tag fosrl/pangolin:postgresql-$(tag)-amd64 \ + --push . && \ + docker buildx build \ + --build-arg BUILD=enterprise \ + --build-arg DATABASE=sqlite \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/amd64 \ + --tag fosrl/pangolin:ee-$(tag)-amd64 \ + --push . && \ + docker buildx build \ + --build-arg BUILD=enterprise \ + --build-arg DATABASE=pg \ + --build-arg VERSION=$(tag) \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg LICENSE="Fossorial Commercial" \ + --build-arg IMAGE_TITLE="Pangolin EE" \ + --build-arg IMAGE_DESCRIPTION="Pangolin Enterprise Edition - Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/amd64 \ + --tag fosrl/pangolin:ee-postgresql-$(tag)-amd64 \ + --push . + +create-manifests-rc: + @if [ -z "$(tag)" ]; then \ + echo "Error: tag is required. Usage: make create-manifests-rc tag="; \ + exit 1; \ + fi + @echo "Creating multi-arch manifests for RC sqlite (oss)..." && \ + docker buildx imagetools create \ + --tag fosrl/pangolin:$(tag) \ + fosrl/pangolin:$(tag)-arm64 \ + fosrl/pangolin:$(tag)-amd64 && \ + echo "Creating multi-arch manifests for RC postgresql (oss)..." && \ + docker buildx imagetools create \ + --tag fosrl/pangolin:postgresql-$(tag) \ + fosrl/pangolin:postgresql-$(tag)-arm64 \ + fosrl/pangolin:postgresql-$(tag)-amd64 && \ + echo "Creating multi-arch manifests for RC sqlite (enterprise)..." && \ + docker buildx imagetools create \ + --tag fosrl/pangolin:ee-$(tag) \ + fosrl/pangolin:ee-$(tag)-arm64 \ + fosrl/pangolin:ee-$(tag)-amd64 && \ + echo "Creating multi-arch manifests for RC postgresql (enterprise)..." && \ + docker buildx imagetools create \ + --tag fosrl/pangolin:ee-postgresql-$(tag) \ + fosrl/pangolin:ee-postgresql-$(tag)-arm64 \ + fosrl/pangolin:ee-postgresql-$(tag)-amd64 && \ + echo "All RC multi-arch manifests created successfully!" + build-arm: - docker buildx build --platform linux/arm64 -t fosrl/pangolin:latest . + @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ + docker buildx build \ + --build-arg VERSION=dev \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/arm64 \ + -t fosrl/pangolin:latest . build-x86: - docker buildx build --platform linux/amd64 -t fosrl/pangolin:latest . + @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ + docker buildx build \ + --build-arg VERSION=dev \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ + --platform linux/amd64 \ + -t fosrl/pangolin:latest . dev-build-sqlite: - docker build --build-arg DATABASE=sqlite -t fosrl/pangolin:latest . + @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ + docker build \ + --build-arg DATABASE=sqlite \ + --build-arg VERSION=dev \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ + -t fosrl/pangolin:latest . dev-build-pg: - docker build --build-arg DATABASE=pg -t fosrl/pangolin:postgresql-latest . + @CREATED=$$(date -u +"%Y-%m-%dT%H:%M:%SZ"); \ + REVISION=$$(git rev-parse HEAD 2>/dev/null || echo "unknown"); \ + docker build \ + --build-arg DATABASE=pg \ + --build-arg VERSION=dev \ + --build-arg REVISION=$$REVISION \ + --build-arg CREATED=$$CREATED \ + --build-arg IMAGE_TITLE="Pangolin" \ + --build-arg IMAGE_DESCRIPTION="Identity-aware VPN and proxy for remote access to anything, anywhere" \ + -t fosrl/pangolin:postgresql-latest . test: docker run -it -p 3000:3000 -p 3001:3001 -p 3002:3002 -v ./config:/app/config fosrl/pangolin:latest diff --git a/README.md b/README.md index 27105c70d..bac7b7e56 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,15 @@ +

+ + We're Hiring! + +

+

- Start testing Pangolin at app.pangolin.net + Get started with Pangolin at app.pangolin.net

@@ -54,9 +60,9 @@ Pangolin is an open-source, identity-based remote access platform built on WireG | | Description | |-----------------|--------------| +| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing — no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/understanding-nodes) and connect to our control plane. | | **Self-Host: Community Edition** | Free, open source, and licensed under AGPL-3. | | **Self-Host: Enterprise Edition** | Licensed under Fossorial Commercial License. Free for personal and hobbyist use, and for businesses earning under \$100K USD annually. | -| **Pangolin Cloud** | Fully managed service with instant setup and pay-as-you-go pricing — no infrastructure required. Or, self-host your own [remote node](https://docs.pangolin.net/manage/remote-node/nodes) and connect to our control plane. | ## Key Features @@ -74,20 +80,21 @@ 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) +- [iOS](https://pangolin.net/downloads/ios) +- [Android](https://pangolin.net/downloads/android) ## Get Started +### Sign up now + +Create an account at [app.pangolin.net](https://app.pangolin.net) to get started with Pangolin Cloud. A generous free tier is available. + ### Check out the docs We encourage everyone to read the full documentation first, which is available at [docs.pangolin.net](https://docs.pangolin.net). This README provides only a very brief subset of the docs to illustrate some basic ideas. -### Sign up and try now - -For Pangolin's managed service, you will first need to create an account at -[app.pangolin.net](https://app.pangolin.net). We have a generous free tier to get started. - ## Licensing Pangolin is dual licensed under the AGPL-3 and the [Fossorial Commercial License](https://pangolin.net/fcl.html). For inquiries about commercial licensing, please contact us at [contact@pangolin.net](mailto:contact@pangolin.net). diff --git a/blueprint.py b/blueprint.py deleted file mode 100644 index 9fd764125..000000000 --- a/blueprint.py +++ /dev/null @@ -1,72 +0,0 @@ -import requests -import yaml -import json -import base64 - -# The file path for the YAML file to be read -# You can change this to the path of your YAML file -YAML_FILE_PATH = 'blueprint.yaml' - -# The API endpoint and headers from the curl request -API_URL = 'http://api.pangolin.net/v1/org/test/blueprint' -HEADERS = { - 'accept': '*/*', - 'Authorization': 'Bearer ', - 'Content-Type': 'application/json' -} - -def convert_and_send(file_path, url, headers): - """ - Reads a YAML file, converts its content to a JSON payload, - and sends it via a PUT request to a specified URL. - """ - try: - # Read the YAML file content - with open(file_path, 'r') as file: - yaml_content = file.read() - - # Parse the YAML string to a Python dictionary - # This will be used to ensure the YAML is valid before sending - parsed_yaml = yaml.safe_load(yaml_content) - - # convert the parsed YAML to a JSON string - json_payload = json.dumps(parsed_yaml) - print("Converted JSON payload:") - print(json_payload) - - # Encode the JSON string to Base64 - encoded_json = base64.b64encode(json_payload.encode('utf-8')).decode('utf-8') - - # Create the final payload with the base64 encoded data - final_payload = { - "blueprint": encoded_json - } - - print("Sending the following Base64 encoded JSON payload:") - print(final_payload) - print("-" * 20) - - # Make the PUT request with the base64 encoded payload - response = requests.put(url, headers=headers, json=final_payload) - - # Print the API response for debugging - print(f"API Response Status Code: {response.status_code}") - print("API Response Content:") - print(response.text) - - # Raise an exception for bad status codes (4xx or 5xx) - response.raise_for_status() - - except FileNotFoundError: - print(f"Error: The file '{file_path}' was not found.") - except yaml.YAMLError as e: - print(f"Error parsing YAML file: {e}") - except requests.exceptions.RequestException as e: - print(f"An error occurred during the API request: {e}") - except Exception as e: - print(f"An unexpected error occurred: {e}") - -# Run the function -if __name__ == "__main__": - convert_and_send(YAML_FILE_PATH, API_URL, HEADERS) - diff --git a/blueprint.yaml b/blueprint.yaml deleted file mode 100644 index adc250556..000000000 --- a/blueprint.yaml +++ /dev/null @@ -1,70 +0,0 @@ -client-resources: - client-resource-nice-id-uno: - name: this is my resource - protocol: tcp - proxy-port: 3001 - hostname: localhost - internal-port: 3000 - site: lively-yosemite-toad - client-resource-nice-id-duce: - name: this is my resource - protocol: udp - proxy-port: 3000 - hostname: localhost - internal-port: 3000 - site: lively-yosemite-toad - -proxy-resources: - resource-nice-id-uno: - name: this is my resource - protocol: http - full-domain: duce.test.example.com - host-header: example.com - tls-server-name: example.com - # auth: - # pincode: 123456 - # password: sadfasdfadsf - # sso-enabled: true - # sso-roles: - # - Member - # sso-users: - # - owen@pangolin.net - # whitelist-users: - # - owen@pangolin.net - # auto-login-idp: 1 - headers: - - name: X-Example-Header - value: example-value - - name: X-Another-Header - value: another-value - rules: - - action: allow - match: ip - value: 1.1.1.1 - - action: deny - match: cidr - value: 2.2.2.2/32 - - action: pass - match: path - value: /admin - targets: - - site: lively-yosemite-toad - path: /path - pathMatchType: prefix - hostname: localhost - method: http - port: 8000 - - site: slim-alpine-chipmunk - hostname: localhost - path: /yoman - pathMatchType: exact - method: http - port: 8001 - resource-nice-id-duce: - name: this is other resource - protocol: tcp - proxy-port: 3000 - targets: - - site: lively-yosemite-toad - hostname: localhost - port: 3000 \ No newline at end of file diff --git a/cli/commands/clearExitNodes.ts b/cli/commands/clearExitNodes.ts new file mode 100644 index 000000000..2a2832039 --- /dev/null +++ b/cli/commands/clearExitNodes.ts @@ -0,0 +1,36 @@ +import { CommandModule } from "yargs"; +import { db, exitNodes } from "@server/db"; +import { eq } from "drizzle-orm"; + +type ClearExitNodesArgs = { }; + +export const clearExitNodes: CommandModule< + {}, + ClearExitNodesArgs +> = { + command: "clear-exit-nodes", + describe: + "Clear all exit nodes from the database", + // no args + builder: (yargs) => { + return yargs; + }, + handler: async (argv: {}) => { + try { + + console.log(`Clearing all exit nodes from the database`); + + // Delete all exit nodes + const deletedCount = await db + .delete(exitNodes) + .where(eq(exitNodes.exitNodeId, exitNodes.exitNodeId)) .returning();; // delete all + + console.log(`Deleted ${deletedCount.length} exit node(s) from the database`); + + process.exit(0); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } + } +}; diff --git a/cli/commands/clearLicenseKeys.ts b/cli/commands/clearLicenseKeys.ts new file mode 100644 index 000000000..704641d32 --- /dev/null +++ b/cli/commands/clearLicenseKeys.ts @@ -0,0 +1,36 @@ +import { CommandModule } from "yargs"; +import { db, licenseKey } from "@server/db"; +import { eq } from "drizzle-orm"; + +type ClearLicenseKeysArgs = { }; + +export const clearLicenseKeys: CommandModule< + {}, + ClearLicenseKeysArgs +> = { + command: "clear-license-keys", + describe: + "Clear all license keys from the database", + // no args + builder: (yargs) => { + return yargs; + }, + handler: async (argv: {}) => { + try { + + console.log(`Clearing all license keys from the database`); + + // Delete all license keys + const deletedCount = await db + .delete(licenseKey) + .where(eq(licenseKey.licenseKeyId, licenseKey.licenseKeyId)) .returning();; // delete all + + console.log(`Deleted ${deletedCount.length} license key(s) from the database`); + + process.exit(0); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } + } +}; diff --git a/cli/commands/deleteClient.ts b/cli/commands/deleteClient.ts new file mode 100644 index 000000000..28fef50df --- /dev/null +++ b/cli/commands/deleteClient.ts @@ -0,0 +1,123 @@ +import { CommandModule } from "yargs"; +import { db, clients, olms, currentFingerprint, userClients, approvals } from "@server/db"; +import { eq, and, inArray } from "drizzle-orm"; + +type DeleteClientArgs = { + orgId: string; + niceId: string; +}; + +export const deleteClient: CommandModule<{}, DeleteClientArgs> = { + command: "delete-client", + describe: + "Delete a client and all associated data (OLMs, current fingerprint, userClients, approvals). Snapshots are preserved.", + builder: (yargs) => { + return yargs + .option("orgId", { + type: "string", + demandOption: true, + describe: "The organization ID" + }) + .option("niceId", { + type: "string", + demandOption: true, + describe: "The client niceId (identifier)" + }); + }, + handler: async (argv: { orgId: string; niceId: string }) => { + try { + const { orgId, niceId } = argv; + + console.log( + `Deleting client with orgId: ${orgId}, niceId: ${niceId}...` + ); + + // Find the client + const [client] = await db + .select() + .from(clients) + .where(and(eq(clients.orgId, orgId), eq(clients.niceId, niceId))) + .limit(1); + + if (!client) { + console.error( + `Error: Client with orgId "${orgId}" and niceId "${niceId}" not found.` + ); + process.exit(1); + } + + const clientId = client.clientId; + console.log(`Found client with clientId: ${clientId}`); + + // Find all OLMs associated with this client + const associatedOlms = await db + .select() + .from(olms) + .where(eq(olms.clientId, clientId)); + + console.log(`Found ${associatedOlms.length} OLM(s) associated with this client`); + + // Delete in a transaction to ensure atomicity + await db.transaction(async (trx) => { + // Delete currentFingerprint entries for the associated OLMs + // Note: We delete these explicitly before deleting OLMs to ensure + // we have control, even though cascade would handle it + let fingerprintCount = 0; + if (associatedOlms.length > 0) { + const olmIds = associatedOlms.map((olm) => olm.olmId); + const deletedFingerprints = await trx + .delete(currentFingerprint) + .where(inArray(currentFingerprint.olmId, olmIds)) + .returning(); + fingerprintCount = deletedFingerprints.length; + } + console.log(`Deleted ${fingerprintCount} current fingerprint(s)`); + + // Delete OLMs + // Note: OLMs have onDelete: "set null" for clientId, so we need to delete them explicitly + const deletedOlms = await trx + .delete(olms) + .where(eq(olms.clientId, clientId)) + .returning(); + console.log(`Deleted ${deletedOlms.length} OLM(s)`); + + // Delete approvals + // Note: Approvals have onDelete: "cascade" but we delete explicitly for clarity + const deletedApprovals = await trx + .delete(approvals) + .where(eq(approvals.clientId, clientId)) + .returning(); + console.log(`Deleted ${deletedApprovals.length} approval(s)`); + + // Delete userClients + // Note: userClients have onDelete: "cascade" but we delete explicitly for clarity + const deletedUserClients = await trx + .delete(userClients) + .where(eq(userClients.clientId, clientId)) + .returning(); + console.log(`Deleted ${deletedUserClients.length} userClient association(s)`); + + // Finally, delete the client itself + const deletedClients = await trx + .delete(clients) + .where(eq(clients.clientId, clientId)) + .returning(); + console.log(`Deleted client: ${deletedClients[0]?.name || niceId}`); + }); + + console.log("\nClient deletion completed successfully!"); + console.log("\nSummary:"); + console.log(` - Client: ${niceId} (clientId: ${clientId})`); + console.log(` - Olm(s): ${associatedOlms.length}`); + console.log(` - Current fingerprints: deleted`); + console.log(` - Approvals: deleted`); + console.log(` - UserClients: deleted`); + console.log(` - Snapshots: preserved (not deleted)`); + + process.exit(0); + } catch (error) { + console.error("Error deleting client:", error); + process.exit(1); + } + } +}; diff --git a/cli/commands/generateOrgCaKeys.ts b/cli/commands/generateOrgCaKeys.ts new file mode 100644 index 000000000..fe38e0c56 --- /dev/null +++ b/cli/commands/generateOrgCaKeys.ts @@ -0,0 +1,121 @@ +import { CommandModule } from "yargs"; +import { db, orgs } from "@server/db"; +import { eq } from "drizzle-orm"; +import { encrypt } from "@server/lib/crypto"; +import { configFilePath1, configFilePath2 } from "@server/lib/consts"; +import { generateCA } from "@server/lib/sshCA"; +import fs from "fs"; +import yaml from "js-yaml"; + +type GenerateOrgCaKeysArgs = { + orgId: string; + secret?: string; + force?: boolean; +}; + +export const generateOrgCaKeys: CommandModule<{}, GenerateOrgCaKeysArgs> = { + command: "generate-org-ca-keys", + describe: + "Generate SSH CA public/private key pair for an organization and store them in the database (private key encrypted with server secret)", + builder: (yargs) => { + return yargs + .option("orgId", { + type: "string", + demandOption: true, + describe: "The organization ID" + }) + .option("secret", { + type: "string", + describe: + "Server secret used to encrypt the CA private key. If omitted, read from config file (config.yml or config.yaml)." + }) + .option("force", { + type: "boolean", + default: false, + describe: + "Overwrite existing CA keys for the org if they already exist" + }); + }, + handler: async (argv: { + orgId: string; + secret?: string; + force?: boolean; + }) => { + try { + const { orgId, force } = argv; + let secret = argv.secret; + + if (!secret) { + const configPath = fs.existsSync(configFilePath1) + ? configFilePath1 + : fs.existsSync(configFilePath2) + ? configFilePath2 + : null; + + if (!configPath) { + console.error( + "Error: No server secret provided and config file not found. " + + "Expected config.yml or config.yaml in the config directory, or pass --secret." + ); + process.exit(1); + } + + const configContent = fs.readFileSync(configPath, "utf8"); + const config = yaml.load(configContent) as { + server?: { secret?: string }; + }; + + if (!config?.server?.secret) { + console.error( + "Error: No server.secret in config file. Pass --secret or set server.secret in config." + ); + process.exit(1); + } + secret = config.server.secret; + } + + const [org] = await db + .select({ + orgId: orgs.orgId, + sshCaPrivateKey: orgs.sshCaPrivateKey, + sshCaPublicKey: orgs.sshCaPublicKey + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + console.error(`Error: Organization with orgId "${orgId}" not found.`); + process.exit(1); + } + + if (org.sshCaPrivateKey != null || org.sshCaPublicKey != null) { + if (!force) { + console.error( + "Error: This organization already has CA keys. Use --force to overwrite." + ); + process.exit(1); + } + } + + const ca = generateCA(`pangolin-ssh-ca-${orgId}`); + const encryptedPrivateKey = encrypt(ca.privateKeyPem, secret); + + await db + .update(orgs) + .set({ + sshCaPrivateKey: encryptedPrivateKey, + sshCaPublicKey: ca.publicKeyOpenSSH + }) + .where(eq(orgs.orgId, orgId)); + + console.log("SSH CA keys generated and stored for org:", orgId); + console.log("\nPublic key (OpenSSH format):"); + console.log(ca.publicKeyOpenSSH); + process.exit(0); + } catch (error) { + console.error("Error generating org CA keys:", error); + process.exit(1); + } + } +}; diff --git a/cli/commands/rotateServerSecret.ts b/cli/commands/rotateServerSecret.ts new file mode 100644 index 000000000..d3828f0e5 --- /dev/null +++ b/cli/commands/rotateServerSecret.ts @@ -0,0 +1,284 @@ +import { CommandModule } from "yargs"; +import { db, idpOidcConfig, licenseKey } from "@server/db"; +import { encrypt, decrypt } from "@server/lib/crypto"; +import { configFilePath1, configFilePath2 } from "@server/lib/consts"; +import { eq } from "drizzle-orm"; +import fs from "fs"; +import yaml from "js-yaml"; + +type RotateServerSecretArgs = { + "old-secret": string; + "new-secret": string; + force?: boolean; +}; + +export const rotateServerSecret: CommandModule< + {}, + RotateServerSecretArgs +> = { + command: "rotate-server-secret", + describe: + "Rotate the server secret by decrypting all encrypted values with the old secret and re-encrypting with a new secret", + builder: (yargs) => { + return yargs + .option("old-secret", { + type: "string", + demandOption: true, + describe: "The current server secret (for verification)" + }) + .option("new-secret", { + type: "string", + demandOption: true, + describe: "The new server secret to use" + }) + .option("force", { + type: "boolean", + default: false, + describe: + "Force rotation even if the old secret doesn't match the config file. " + + "Use this if you know the old secret is correct but the config file is out of sync. " + + "WARNING: This will attempt to decrypt all values with the provided old secret. " + + "If the old secret is incorrect, the rotation will fail or corrupt data." + }); + }, + handler: async (argv: { + "old-secret": string; + "new-secret": string; + force?: boolean; + }) => { + try { + // Determine which config file exists + const configPath = fs.existsSync(configFilePath1) + ? configFilePath1 + : fs.existsSync(configFilePath2) + ? configFilePath2 + : null; + + if (!configPath) { + console.error( + "Error: Config file not found. Expected config.yml or config.yaml in the config directory." + ); + process.exit(1); + } + + // Read current config + const configContent = fs.readFileSync(configPath, "utf8"); + const config = yaml.load(configContent) as any; + + if (!config?.server?.secret) { + console.error( + "Error: No server secret found in config file. Cannot rotate." + ); + process.exit(1); + } + + const configSecret = config.server.secret; + const oldSecret = argv["old-secret"]; + const newSecret = argv["new-secret"]; + const force = argv.force || false; + + // Verify that the provided old secret matches the one in config + if (configSecret !== oldSecret) { + if (!force) { + console.error( + "Error: The provided old secret does not match the secret in the config file." + ); + console.error( + "\nIf you are certain the old secret is correct and the config file is out of sync," + ); + console.error( + "you can use the --force flag to bypass this check." + ); + console.error( + "\nWARNING: Using --force with an incorrect old secret will cause the rotation to fail" + ); + console.error( + "or corrupt encrypted data. Only use --force if you are absolutely certain." + ); + process.exit(1); + } else { + console.warn( + "\nWARNING: Using --force flag. Bypassing old secret verification." + ); + console.warn( + "The provided old secret does not match the config file, but proceeding anyway." + ); + console.warn( + "If the old secret is incorrect, this operation will fail or corrupt data.\n" + ); + } + } + + // Validate new secret + if (newSecret.length < 8) { + console.error( + "Error: New secret must be at least 8 characters long" + ); + process.exit(1); + } + + if (oldSecret === newSecret) { + console.error("Error: New secret must be different from old secret"); + process.exit(1); + } + + console.log("Starting server secret rotation..."); + console.log("This will decrypt and re-encrypt all encrypted values in the database."); + + // Read all data first + console.log("\nReading encrypted data from database..."); + const idpConfigs = await db.select().from(idpOidcConfig); + const licenseKeys = await db.select().from(licenseKey); + + console.log(`Found ${idpConfigs.length} OIDC IdP configuration(s)`); + console.log(`Found ${licenseKeys.length} license key(s)`); + + // Prepare all decrypted and re-encrypted values + console.log("\nDecrypting and re-encrypting values..."); + + type IdpUpdate = { + idpOauthConfigId: number; + encryptedClientId: string; + encryptedClientSecret: string; + }; + + type LicenseKeyUpdate = { + oldLicenseKeyId: string; + newLicenseKeyId: string; + encryptedToken: string; + encryptedInstanceId: string; + }; + + const idpUpdates: IdpUpdate[] = []; + const licenseKeyUpdates: LicenseKeyUpdate[] = []; + + // Process idpOidcConfig entries + for (const idpConfig of idpConfigs) { + try { + // Decrypt with old secret + const decryptedClientId = decrypt(idpConfig.clientId, oldSecret); + const decryptedClientSecret = decrypt( + idpConfig.clientSecret, + oldSecret + ); + + // Re-encrypt with new secret + const encryptedClientId = encrypt(decryptedClientId, newSecret); + const encryptedClientSecret = encrypt( + decryptedClientSecret, + newSecret + ); + + idpUpdates.push({ + idpOauthConfigId: idpConfig.idpOauthConfigId, + encryptedClientId, + encryptedClientSecret + }); + } catch (error) { + console.error( + `Error processing IdP config ${idpConfig.idpOauthConfigId}:`, + error + ); + throw error; + } + } + + // Process licenseKey entries + for (const key of licenseKeys) { + try { + // Decrypt with old secret + const decryptedLicenseKeyId = decrypt(key.licenseKeyId, oldSecret); + const decryptedToken = decrypt(key.token, oldSecret); + const decryptedInstanceId = decrypt(key.instanceId, oldSecret); + + // Re-encrypt with new secret + const encryptedLicenseKeyId = encrypt( + decryptedLicenseKeyId, + newSecret + ); + const encryptedToken = encrypt(decryptedToken, newSecret); + const encryptedInstanceId = encrypt( + decryptedInstanceId, + newSecret + ); + + licenseKeyUpdates.push({ + oldLicenseKeyId: key.licenseKeyId, + newLicenseKeyId: encryptedLicenseKeyId, + encryptedToken, + encryptedInstanceId + }); + } catch (error) { + console.error( + `Error processing license key ${key.licenseKeyId}:`, + error + ); + throw error; + } + } + + // Perform all database updates in a single transaction + console.log("\nUpdating database in transaction..."); + await db.transaction(async (trx) => { + // Update idpOidcConfig entries + for (const update of idpUpdates) { + await trx + .update(idpOidcConfig) + .set({ + clientId: update.encryptedClientId, + clientSecret: update.encryptedClientSecret + }) + .where( + eq( + idpOidcConfig.idpOauthConfigId, + update.idpOauthConfigId + ) + ); + } + + // Update licenseKey entries (delete old, insert new) + for (const update of licenseKeyUpdates) { + // Delete old entry + await trx + .delete(licenseKey) + .where(eq(licenseKey.licenseKeyId, update.oldLicenseKeyId)); + + // Insert new entry with re-encrypted values + await trx.insert(licenseKey).values({ + licenseKeyId: update.newLicenseKeyId, + token: update.encryptedToken, + instanceId: update.encryptedInstanceId + }); + } + }); + + console.log(`Rotated ${idpUpdates.length} OIDC IdP configuration(s)`); + console.log(`Rotated ${licenseKeyUpdates.length} license key(s)`); + + // Update config file with new secret + console.log("\nUpdating config file..."); + config.server.secret = newSecret; + const newConfigContent = yaml.dump(config, { + indent: 2, + lineWidth: -1 + }); + fs.writeFileSync(configPath, newConfigContent, "utf8"); + + console.log(`Updated config file: ${configPath}`); + + console.log("\nServer secret rotation completed successfully!"); + console.log(`\nSummary:`); + console.log(` - OIDC IdP configurations: ${idpUpdates.length}`); + console.log(` - License keys: ${licenseKeyUpdates.length}`); + console.log( + `\n IMPORTANT: Restart the server for the new secret to take effect.` + ); + + process.exit(0); + } catch (error) { + console.error("Error rotating server secret:", error); + process.exit(1); + } + } +}; + diff --git a/cli/index.ts b/cli/index.ts index f9e884ccf..7605904ee 100644 --- a/cli/index.ts +++ b/cli/index.ts @@ -4,10 +4,20 @@ import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { setAdminCredentials } from "@cli/commands/setAdminCredentials"; import { resetUserSecurityKeys } from "@cli/commands/resetUserSecurityKeys"; +import { clearExitNodes } from "./commands/clearExitNodes"; +import { rotateServerSecret } from "./commands/rotateServerSecret"; +import { clearLicenseKeys } from "./commands/clearLicenseKeys"; +import { deleteClient } from "./commands/deleteClient"; +import { generateOrgCaKeys } from "./commands/generateOrgCaKeys"; yargs(hideBin(process.argv)) .scriptName("pangctl") .command(setAdminCredentials) .command(resetUserSecurityKeys) + .command(clearExitNodes) + .command(rotateServerSecret) + .command(clearLicenseKeys) + .command(deleteClient) + .command(generateOrgCaKeys) .demandCommand() .help().argv; diff --git a/config/config.example.yml b/config/config.example.yml index 7eeebf81a..896113bb2 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -1,27 +1,30 @@ # To see all available options, please visit the docs: -# https://docs.pangolin.net/self-host/advanced/config-file - -app: - dashboard_url: http://localhost:3002 - log_level: debug - -domains: - domain1: - base_domain: example.com - -server: - secret: my_secret_key +# https://docs.pangolin.net/ gerbil: - base_endpoint: example.com + start_port: 51820 + base_endpoint: "{{.DashboardDomain}}" -orgs: - block_size: 24 - subnet_group: 100.90.137.0/20 +app: + dashboard_url: "https://{{.DashboardDomain}}" + log_level: "info" + telemetry: + anonymous_usage: true + +domains: + domain1: + base_domain: "{{.BaseDomain}}" + +server: + secret: "{{.Secret}}" + cors: + origins: ["https://{{.DashboardDomain}}"] + methods: ["GET", "POST", "PUT", "DELETE", "PATCH"] + allowed_headers: ["X-CSRF-Token", "Content-Type"] + credentials: false flags: - require_email_verification: false - disable_signup_without_invite: true - disable_user_create_org: true - allow_raw_resources: true - enable_integration_api: true + require_email_verification: false + disable_signup_without_invite: true + disable_user_create_org: false + allow_raw_resources: true diff --git a/config/traefik/dynamic_config.yml b/config/traefik/dynamic_config.yml index 8465a9cf0..6e7f0fdd9 100644 --- a/config/traefik/dynamic_config.yml +++ b/config/traefik/dynamic_config.yml @@ -1,5 +1,9 @@ http: middlewares: + badger: + plugin: + badger: + disableForwardAuth: true redirect-to-https: redirectScheme: scheme: https @@ -13,14 +17,16 @@ http: - web middlewares: - redirect-to-https + - badger # Next.js router (handles everything except API and WebSocket paths) next-router: - rule: "Host(`{{.DashboardDomain}}`)" + rule: "Host(`{{.DashboardDomain}}`) && !PathPrefix(`/api/v1`)" service: next-service - priority: 10 entryPoints: - websecure + middlewares: + - badger tls: certResolver: letsencrypt @@ -28,9 +34,10 @@ http: api-router: rule: "Host(`{{.DashboardDomain}}`) && PathPrefix(`/api/v1`)" service: api-service - priority: 100 entryPoints: - websecure + middlewares: + - badger tls: certResolver: letsencrypt @@ -44,3 +51,12 @@ http: loadBalancer: servers: - url: "http://pangolin:3000" # API/WebSocket server + +tcp: + serversTransports: + pp-transport-v1: + proxyProtocol: + version: 1 + pp-transport-v2: + proxyProtocol: + version: 2 diff --git a/config/traefik/traefik_config.yml b/config/traefik/traefik_config.yml index 43ea97be4..0709b4611 100644 --- a/config/traefik/traefik_config.yml +++ b/config/traefik/traefik_config.yml @@ -3,32 +3,52 @@ api: dashboard: true providers: + http: + endpoint: "http://pangolin:3001/api/v1/traefik-config" + pollInterval: "5s" file: - directory: "/var/dynamic" - watch: true + filename: "/etc/traefik/dynamic_config.yml" experimental: plugins: badger: moduleName: "github.com/fosrl/badger" - version: "v1.2.0" + version: "{{.BadgerVersion}}" log: - level: "DEBUG" + level: "INFO" format: "common" maxSize: 100 maxBackups: 3 maxAge: 3 compress: true +certificatesResolvers: + letsencrypt: + acme: + httpChallenge: + entryPoint: web + email: "{{.LetsEncryptEmail}}" + storage: "/letsencrypt/acme.json" + caServer: "https://acme-v02.api.letsencrypt.org/directory" + entryPoints: web: address: ":80" websecure: - address: ":9443" + address: ":443" transport: respondingTimeouts: readTimeout: "30m" + http: + tls: + certResolver: "letsencrypt" + encodedCharacters: + allowEncodedSlash: true + allowEncodedQuestionMark: true serversTransport: insecureSkipVerify: true + +ping: + entryPoint: "web" diff --git a/docker-compose.example.yml b/docker-compose.example.yml index 84a5140b4..50cb1bcc1 100644 --- a/docker-compose.example.yml +++ b/docker-compose.example.yml @@ -4,6 +4,12 @@ services: image: fosrl/pangolin:latest container_name: pangolin restart: unless-stopped + deploy: + resources: + limits: + memory: 1g + reservations: + memory: 256m volumes: - ./config:/app/config healthcheck: diff --git a/docker-compose.pgr.yml b/docker-compose.pgr.yml index 764c09150..9e6b2c5af 100644 --- a/docker-compose.pgr.yml +++ b/docker-compose.pgr.yml @@ -7,8 +7,8 @@ services: POSTGRES_DB: postgres # Default database name POSTGRES_USER: postgres # Default user POSTGRES_PASSWORD: password # Default password (change for production!) - volumes: - - ./config/postgres:/var/lib/postgresql/data + # volumes: + # - ./config/postgres:/var/lib/postgresql/data ports: - "5432:5432" # Map host port 5432 to container port 5432 restart: no diff --git a/esbuild.mjs b/esbuild.mjs index 0157c34ac..03697d4fd 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -6,6 +6,12 @@ import path from "path"; import fs from "fs"; // import { glob } from "glob"; +// Read default build type from server/build.ts +let build = "oss"; +const buildFile = fs.readFileSync(path.resolve("server/build.ts"), "utf8"); +const m = buildFile.match(/export\s+const\s+build\s*=\s*["'](oss|saas|enterprise)["']/); +if (m) build = m[1]; + const banner = ` // patch __dirname // import { fileURLToPath } from "url"; @@ -37,7 +43,7 @@ const argv = yargs(hideBin(process.argv)) describe: "Build type (oss, saas, enterprise)", type: "string", choices: ["oss", "saas", "enterprise"], - default: "oss" + default: build }) .help() .alias("help", "h").argv; @@ -275,7 +281,7 @@ esbuild }) ], sourcemap: "inline", - target: "node22" + target: "node24" }) .then((result) => { // Check if there were any errors in the build result diff --git a/install/Makefile b/install/Makefile index 53365f509..8a836b77e 100644 --- a/install/Makefile +++ b/install/Makefile @@ -1,41 +1,24 @@ -all: update-versions go-build-release put-back -dev-all: dev-update-versions dev-build dev-clean +all: go-build-release + +# Build with version injection via ldflags +# Versions can be passed via: make go-build-release PANGOLIN_VERSION=x.x.x GERBIL_VERSION=x.x.x BADGER_VERSION=x.x.x +# Or fetched automatically if not provided (requires curl and jq) + +PANGOLIN_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name') +GERBIL_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') +BADGER_VERSION ?= $(shell curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') + +LDFLAGS = -X main.pangolinVersion=$(PANGOLIN_VERSION) \ + -X main.gerbilVersion=$(GERBIL_VERSION) \ + -X main.badgerVersion=$(BADGER_VERSION) go-build-release: - CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o bin/installer_linux_amd64 - CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o bin/installer_linux_arm64 + @echo "Building with versions - Pangolin: $(PANGOLIN_VERSION), Gerbil: $(GERBIL_VERSION), Badger: $(BADGER_VERSION)" + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags "$(LDFLAGS)" -o bin/installer_linux_amd64 + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags "$(LDFLAGS)" -o bin/installer_linux_arm64 clean: rm -f bin/installer_linux_amd64 rm -f bin/installer_linux_arm64 -update-versions: - @echo "Fetching latest versions..." - cp main.go main.go.bak && \ - $(MAKE) dev-update-versions - -put-back: - mv main.go.bak main.go - -dev-update-versions: - if [ -z "$(tag)" ]; then \ - PANGOLIN_VERSION=$$(curl -s https://api.github.com/repos/fosrl/pangolin/tags | jq -r '.[0].name'); \ - else \ - PANGOLIN_VERSION=$(tag); \ - fi && \ - GERBIL_VERSION=$$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') && \ - BADGER_VERSION=$$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') && \ - echo "Latest versions - Pangolin: $$PANGOLIN_VERSION, Gerbil: $$GERBIL_VERSION, Badger: $$BADGER_VERSION" && \ - sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"$$PANGOLIN_VERSION\"/" main.go && \ - sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"$$GERBIL_VERSION\"/" main.go && \ - sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"$$BADGER_VERSION\"/" main.go && \ - echo "Updated main.go with latest versions" - -dev-build: go-build-release - -dev-clean: - @echo "Restoring version values ..." - sed -i "s/config.PangolinVersion = \".*\"/config.PangolinVersion = \"replaceme\"/" main.go && \ - sed -i "s/config.GerbilVersion = \".*\"/config.GerbilVersion = \"replaceme\"/" main.go && \ - sed -i "s/config.BadgerVersion = \".*\"/config.BadgerVersion = \"replaceme\"/" main.go - @echo "Restored version strings in main.go" +.PHONY: all go-build-release clean diff --git a/install/config.go b/install/config.go index e75dd50dd..548e2ab33 100644 --- a/install/config.go +++ b/install/config.go @@ -118,19 +118,19 @@ func copyDockerService(sourceFile, destFile, serviceName string) error { } // Parse source Docker Compose YAML - var sourceCompose map[string]interface{} + var sourceCompose map[string]any if err := yaml.Unmarshal(sourceData, &sourceCompose); err != nil { return fmt.Errorf("error parsing source Docker Compose file: %w", err) } // Parse destination Docker Compose YAML - var destCompose map[string]interface{} + var destCompose map[string]any if err := yaml.Unmarshal(destData, &destCompose); err != nil { return fmt.Errorf("error parsing destination Docker Compose file: %w", err) } // Get services section from source - sourceServices, ok := sourceCompose["services"].(map[string]interface{}) + sourceServices, ok := sourceCompose["services"].(map[string]any) if !ok { return fmt.Errorf("services section not found in source file or has invalid format") } @@ -142,10 +142,10 @@ func copyDockerService(sourceFile, destFile, serviceName string) error { } // Get or create services section in destination - destServices, ok := destCompose["services"].(map[string]interface{}) + destServices, ok := destCompose["services"].(map[string]any) if !ok { // If services section doesn't exist, create it - destServices = make(map[string]interface{}) + destServices = make(map[string]any) destCompose["services"] = destServices } @@ -187,13 +187,12 @@ func backupConfig() error { return nil } -func MarshalYAMLWithIndent(data interface{}, indent int) ([]byte, error) { +func MarshalYAMLWithIndent(data any, indent int) ([]byte, error) { buffer := new(bytes.Buffer) encoder := yaml.NewEncoder(buffer) encoder.SetIndent(indent) - err := encoder.Encode(data) - if err != nil { + if err := encoder.Encode(data); err != nil { return nil, err } @@ -209,7 +208,7 @@ func replaceInFile(filepath, oldStr, newStr string) error { } // Replace the string - newContent := strings.Replace(string(content), oldStr, newStr, -1) + newContent := strings.ReplaceAll(string(content), oldStr, newStr) // Write the modified content back to the file err = os.WriteFile(filepath, []byte(newContent), 0644) @@ -228,28 +227,28 @@ func CheckAndAddTraefikLogVolume(composePath string) error { } // Parse YAML into a generic map - var compose map[string]interface{} + var compose map[string]any if err := yaml.Unmarshal(data, &compose); err != nil { return fmt.Errorf("error parsing compose file: %w", err) } // Get services section - services, ok := compose["services"].(map[string]interface{}) + services, ok := compose["services"].(map[string]any) if !ok { return fmt.Errorf("services section not found or invalid") } // Get traefik service - traefik, ok := services["traefik"].(map[string]interface{}) + traefik, ok := services["traefik"].(map[string]any) if !ok { return fmt.Errorf("traefik service not found or invalid") } // Check volumes logVolume := "./config/traefik/logs:/var/log/traefik" - var volumes []interface{} + var volumes []any - if existingVolumes, ok := traefik["volumes"].([]interface{}); ok { + if existingVolumes, ok := traefik["volumes"].([]any); ok { // Check if volume already exists for _, v := range existingVolumes { if v.(string) == logVolume { @@ -295,13 +294,13 @@ func MergeYAML(baseFile, overlayFile string) error { } // Parse base YAML into a map - var baseMap map[string]interface{} + var baseMap map[string]any if err := yaml.Unmarshal(baseContent, &baseMap); err != nil { return fmt.Errorf("error parsing base YAML: %v", err) } // Parse overlay YAML into a map - var overlayMap map[string]interface{} + var overlayMap map[string]any if err := yaml.Unmarshal(overlayContent, &overlayMap); err != nil { return fmt.Errorf("error parsing overlay YAML: %v", err) } @@ -324,8 +323,8 @@ func MergeYAML(baseFile, overlayFile string) error { } // mergeMap recursively merges two maps -func mergeMap(base, overlay map[string]interface{}) map[string]interface{} { - result := make(map[string]interface{}) +func mergeMap(base, overlay map[string]any) map[string]any { + result := make(map[string]any) // Copy all key-values from base map for k, v := range base { @@ -336,8 +335,8 @@ func mergeMap(base, overlay map[string]interface{}) map[string]interface{} { for k, v := range overlay { // If both maps have the same key and both values are maps, merge recursively if baseVal, ok := base[k]; ok { - if baseMap, isBaseMap := baseVal.(map[string]interface{}); isBaseMap { - if overlayMap, isOverlayMap := v.(map[string]interface{}); isOverlayMap { + if baseMap, isBaseMap := baseVal.(map[string]any); isBaseMap { + if overlayMap, isOverlayMap := v.(map[string]any); isOverlayMap { result[k] = mergeMap(baseMap, overlayMap) continue } diff --git a/install/config/crowdsec/dynamic_config.yml b/install/config/crowdsec/dynamic_config.yml index c58d56701..6136e4b62 100644 --- a/install/config/crowdsec/dynamic_config.yml +++ b/install/config/crowdsec/dynamic_config.yml @@ -1,5 +1,9 @@ http: middlewares: + badger: + plugin: + badger: + disableForwardAuth: true redirect-to-https: redirectScheme: scheme: https @@ -63,6 +67,7 @@ http: - web middlewares: - redirect-to-https + - badger # Next.js router (handles everything except API and WebSocket paths) next-router: @@ -72,6 +77,7 @@ http: - websecure middlewares: - security-headers # Add security headers middleware + - badger tls: certResolver: letsencrypt @@ -83,6 +89,7 @@ http: - websecure middlewares: - security-headers # Add security headers middleware + - badger tls: certResolver: letsencrypt @@ -94,6 +101,7 @@ http: - websecure middlewares: - security-headers # Add security headers middleware + - badger tls: certResolver: letsencrypt diff --git a/install/config/docker-compose.yml b/install/config/docker-compose.yml index 90613b2aa..c0206e5bf 100644 --- a/install/config/docker-compose.yml +++ b/install/config/docker-compose.yml @@ -1,9 +1,15 @@ name: pangolin services: pangolin: - image: docker.io/fosrl/pangolin:{{.PangolinVersion}} + image: docker.io/fosrl/pangolin:{{if .IsEnterprise}}ee-{{end}}{{.PangolinVersion}} container_name: pangolin restart: unless-stopped + deploy: + resources: + limits: + memory: 1g + reservations: + memory: 256m volumes: - ./config:/app/config healthcheck: @@ -38,9 +44,7 @@ services: image: docker.io/traefik:v3.6 container_name: traefik restart: unless-stopped -{{if .InstallGerbil}} - network_mode: service:gerbil # Ports appear on the gerbil service -{{end}}{{if not .InstallGerbil}} +{{if .InstallGerbil}} network_mode: service:gerbil # Ports appear on the gerbil service{{end}}{{if not .InstallGerbil}} ports: - 443:443 - 80:80 diff --git a/install/config/traefik/dynamic_config.yml b/install/config/traefik/dynamic_config.yml index f795016be..0829924a4 100644 --- a/install/config/traefik/dynamic_config.yml +++ b/install/config/traefik/dynamic_config.yml @@ -1,5 +1,9 @@ http: middlewares: + badger: + plugin: + badger: + disableForwardAuth: true redirect-to-https: redirectScheme: scheme: https @@ -13,6 +17,7 @@ http: - web middlewares: - redirect-to-https + - badger # Next.js router (handles everything except API and WebSocket paths) next-router: @@ -20,6 +25,8 @@ http: service: next-service entryPoints: - websecure + middlewares: + - badger tls: certResolver: letsencrypt @@ -29,6 +36,8 @@ http: service: api-service entryPoints: - websecure + middlewares: + - badger tls: certResolver: letsencrypt @@ -38,6 +47,8 @@ http: service: api-service entryPoints: - websecure + middlewares: + - badger tls: certResolver: letsencrypt @@ -59,4 +70,4 @@ tcp: version: 1 pp-transport-v2: proxyProtocol: - version: 2 \ No newline at end of file + version: 2 diff --git a/install/config/traefik/traefik_config.yml b/install/config/traefik/traefik_config.yml index a9693ce6a..0709b4611 100644 --- a/install/config/traefik/traefik_config.yml +++ b/install/config/traefik/traefik_config.yml @@ -43,9 +43,12 @@ entryPoints: http: tls: certResolver: "letsencrypt" + encodedCharacters: + allowEncodedSlash: true + allowEncodedQuestionMark: true serversTransport: insecureSkipVerify: true ping: - entryPoint: "web" \ No newline at end of file + entryPoint: "web" diff --git a/install/containers.go b/install/containers.go index 9993e117d..b5d18423b 100644 --- a/install/containers.go +++ b/install/containers.go @@ -73,7 +73,7 @@ func installDocker() error { case strings.Contains(osRelease, "ID=ubuntu"): installCmd = exec.Command("bash", "-c", fmt.Sprintf(` apt-get update && - apt-get install -y apt-transport-https ca-certificates curl && + apt-get install -y apt-transport-https ca-certificates curl gpg && curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && apt-get update && @@ -82,7 +82,7 @@ func installDocker() error { case strings.Contains(osRelease, "ID=debian"): installCmd = exec.Command("bash", "-c", fmt.Sprintf(` apt-get update && - apt-get install -y apt-transport-https ca-certificates curl && + apt-get install -y apt-transport-https ca-certificates curl gpg && curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg && echo "deb [arch=%s signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian $(lsb_release -cs) stable" > /etc/apt/sources.list.d/docker.list && apt-get update && @@ -144,12 +144,13 @@ func installDocker() error { } func startDockerService() error { - if runtime.GOOS == "linux" { + switch runtime.GOOS { + case "linux": cmd := exec.Command("systemctl", "enable", "--now", "docker") cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr return cmd.Run() - } else if runtime.GOOS == "darwin" { + case "darwin": // On macOS, Docker is usually started via the Docker Desktop application fmt.Println("Please start Docker Desktop manually on macOS.") return nil @@ -210,6 +211,47 @@ func isDockerRunning() bool { return true } +func isPodmanRunning() bool { + cmd := exec.Command("podman", "info") + if err := cmd.Run(); err != nil { + return false + } + return true +} + +// detectContainerType detects whether the system is currently using Docker or Podman +// by checking which container runtime is running and has containers +func detectContainerType() SupportedContainer { + // Check if we have running containers with podman + if isPodmanRunning() { + cmd := exec.Command("podman", "ps", "-q") + output, err := cmd.Output() + if err == nil && len(strings.TrimSpace(string(output))) > 0 { + return Podman + } + } + + // Check if we have running containers with docker + if isDockerRunning() { + cmd := exec.Command("docker", "ps", "-q") + output, err := cmd.Output() + if err == nil && len(strings.TrimSpace(string(output))) > 0 { + return Docker + } + } + + // If no containers are running, check which one is installed and running + if isPodmanRunning() && isPodmanInstalled() { + return Podman + } + + if isDockerRunning() && isDockerInstalled() { + return Docker + } + + return Undefined +} + // executeDockerComposeCommandWithArgs executes the appropriate docker command with arguments supplied func executeDockerComposeCommandWithArgs(args ...string) error { var cmd *exec.Cmd @@ -261,7 +303,7 @@ func pullContainers(containerType SupportedContainer) error { return nil } - return fmt.Errorf("Unsupported container type: %s", containerType) + return fmt.Errorf("unsupported container type: %s", containerType) } // startContainers starts the containers using the appropriate command. @@ -284,7 +326,7 @@ func startContainers(containerType SupportedContainer) error { return nil } - return fmt.Errorf("Unsupported container type: %s", containerType) + return fmt.Errorf("unsupported container type: %s", containerType) } // stopContainers stops the containers using the appropriate command. @@ -306,7 +348,7 @@ func stopContainers(containerType SupportedContainer) error { return nil } - return fmt.Errorf("Unsupported container type: %s", containerType) + return fmt.Errorf("unsupported container type: %s", containerType) } // restartContainer restarts a specific container using the appropriate command. @@ -328,5 +370,5 @@ func restartContainer(container string, containerType SupportedContainer) error return nil } - return fmt.Errorf("Unsupported container type: %s", containerType) + return fmt.Errorf("unsupported container type: %s", containerType) } diff --git a/install/crowdsec.go b/install/crowdsec.go index 2e388e925..c75dccf32 100644 --- a/install/crowdsec.go +++ b/install/crowdsec.go @@ -27,9 +27,18 @@ func installCrowdsec(config Config) error { os.Exit(1) } - os.MkdirAll("config/crowdsec/db", 0755) - os.MkdirAll("config/crowdsec/acquis.d", 0755) - os.MkdirAll("config/traefik/logs", 0755) + if err := os.MkdirAll("config/crowdsec/db", 0755); err != nil { + fmt.Printf("Error creating config files: %v\n", err) + os.Exit(1) + } + if err := os.MkdirAll("config/crowdsec/acquis.d", 0755); err != nil { + fmt.Printf("Error creating config files: %v\n", err) + os.Exit(1) + } + if err := os.MkdirAll("config/traefik/logs", 0755); err != nil { + fmt.Printf("Error creating config files: %v\n", err) + os.Exit(1) + } if err := copyDockerService("config/crowdsec/docker-compose.yml", "docker-compose.yml", "crowdsec"); err != nil { fmt.Printf("Error copying docker service: %v\n", err) @@ -93,7 +102,7 @@ func installCrowdsec(config Config) error { if checkIfTextInFile("config/traefik/dynamic_config.yml", "PUT_YOUR_BOUNCER_KEY_HERE_OR_IT_WILL_NOT_WORK") { fmt.Println("Failed to replace bouncer key! Please retrieve the key and replace it in the config/traefik/dynamic_config.yml file using the following command:") - fmt.Println(" docker exec crowdsec cscli bouncers add traefik-bouncer") + fmt.Printf(" %s exec crowdsec cscli bouncers add traefik-bouncer\n", config.InstallationContainerType) } return nil @@ -117,7 +126,7 @@ func GetCrowdSecAPIKey(containerType SupportedContainer) (string, error) { } // Execute the command to get the API key - cmd := exec.Command("docker", "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw") + cmd := exec.Command(string(containerType), "exec", "crowdsec", "cscli", "bouncers", "add", "traefik-bouncer", "-o", "raw") var out bytes.Buffer cmd.Stdout = &out @@ -153,34 +162,34 @@ func CheckAndAddCrowdsecDependency(composePath string) error { } // Parse YAML into a generic map - var compose map[string]interface{} + var compose map[string]any if err := yaml.Unmarshal(data, &compose); err != nil { return fmt.Errorf("error parsing compose file: %w", err) } // Get services section - services, ok := compose["services"].(map[string]interface{}) + services, ok := compose["services"].(map[string]any) if !ok { return fmt.Errorf("services section not found or invalid") } // Get traefik service - traefik, ok := services["traefik"].(map[string]interface{}) + traefik, ok := services["traefik"].(map[string]any) if !ok { return fmt.Errorf("traefik service not found or invalid") } // Get dependencies - dependsOn, ok := traefik["depends_on"].(map[string]interface{}) + dependsOn, ok := traefik["depends_on"].(map[string]any) if ok { // Append the new block for crowdsec - dependsOn["crowdsec"] = map[string]interface{}{ + dependsOn["crowdsec"] = map[string]any{ "condition": "service_healthy", } } else { // No dependencies exist, create it - traefik["depends_on"] = map[string]interface{}{ - "crowdsec": map[string]interface{}{ + traefik["depends_on"] = map[string]any{ + "crowdsec": map[string]any{ "condition": "service_healthy", }, } diff --git a/install/go.mod b/install/go.mod index bcd568966..da73eec0f 100644 --- a/install/go.mod +++ b/install/go.mod @@ -1,10 +1,38 @@ module installer -go 1.24.0 +go 1.25.0 require ( - golang.org/x/term v0.38.0 + github.com/charmbracelet/huh v0.8.0 + github.com/charmbracelet/lipgloss v1.1.0 + golang.org/x/term v0.41.0 gopkg.in/yaml.v3 v3.0.1 ) -require golang.org/x/sys v0.39.0 // indirect +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/catppuccin/go v0.3.0 // indirect + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect + github.com/charmbracelet/bubbletea v1.3.6 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.9.3 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13 // indirect + github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.15.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.23.0 // indirect +) diff --git a/install/go.sum b/install/go.sum index 5655d91a0..e0b2a6c5e 100644 --- a/install/go.sum +++ b/install/go.sum @@ -1,7 +1,80 @@ -golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= -golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= -golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= +github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= +github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY= +github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws= +github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw= +github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU= +github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/huh v0.8.0 h1:Xz/Pm2h64cXQZn/Jvele4J3r7DDiqFCNIVteYukxDvY= +github.com/charmbracelet/huh v0.8.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0= +github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k= +github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U= +github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA= +github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4= +github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI= +github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4= +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= +github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= +golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= +golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/install/input.go b/install/input.go index cf8fd7a39..8b444ecb9 100644 --- a/install/input.go +++ b/install/input.go @@ -1,74 +1,235 @@ package main import ( - "bufio" + "errors" "fmt" - "strings" - "syscall" + "os" + "strconv" + "github.com/charmbracelet/huh" "golang.org/x/term" ) -func readString(reader *bufio.Reader, prompt string, defaultValue string) string { +// pangolinTheme is the custom theme using brand colors +var pangolinTheme = ThemePangolin() + +// isAccessibleMode checks if we should use accessible mode (simple prompts) +// This is true for: non-TTY, TERM=dumb, or ACCESSIBLE env var set +func isAccessibleMode() bool { + // Check if stdin is not a terminal (piped input, CI, etc.) + if !term.IsTerminal(int(os.Stdin.Fd())) { + return true + } + // Check for dumb terminal + if os.Getenv("TERM") == "dumb" { + return true + } + // Check for explicit accessible mode request + if os.Getenv("ACCESSIBLE") != "" { + return true + } + return false +} + +// handleAbort checks if the error is a user abort (Ctrl+C) and exits if so +func handleAbort(err error) { + if err != nil && errors.Is(err, huh.ErrUserAborted) { + fmt.Println("\nInstallation cancelled.") + os.Exit(0) + } +} + +// runField runs a single field with the Pangolin theme, handling accessible mode +func runField(field huh.Field) error { + if isAccessibleMode() { + return field.RunAccessible(os.Stdout, os.Stdin) + } + form := huh.NewForm(huh.NewGroup(field)).WithTheme(pangolinTheme) + return form.Run() +} + +func readString(prompt string, defaultValue string) string { + var value string + + title := prompt if defaultValue != "" { - fmt.Printf("%s (default: %s): ", prompt, defaultValue) - } else { - fmt.Print(prompt + ": ") + title = fmt.Sprintf("%s (default: %s)", prompt, defaultValue) } - input, _ := reader.ReadString('\n') - input = strings.TrimSpace(input) - if input == "" { - return defaultValue + + input := huh.NewInput(). + Title(title). + Value(&value) + + // If no default value, this field is required + if defaultValue == "" { + input = input.Validate(func(s string) error { + if s == "" { + return fmt.Errorf("this field is required") + } + return nil + }) } - return input -} -func readStringNoDefault(reader *bufio.Reader, prompt string) string { - fmt.Print(prompt + ": ") - input, _ := reader.ReadString('\n') - return strings.TrimSpace(input) -} + err := runField(input) + handleAbort(err) -func readPassword(prompt string, reader *bufio.Reader) string { - if term.IsTerminal(int(syscall.Stdin)) { - fmt.Print(prompt + ": ") - // Read password without echo if we're in a terminal - password, err := term.ReadPassword(int(syscall.Stdin)) - fmt.Println() // Add a newline since ReadPassword doesn't add one - if err != nil { - return "" - } - input := strings.TrimSpace(string(password)) - if input == "" { - return readPassword(prompt, reader) - } - return input - } else { - // Fallback to reading from stdin if not in a terminal - return readString(reader, prompt, "") + if value == "" { + value = defaultValue } -} -func readBool(reader *bufio.Reader, prompt string, defaultValue bool) bool { - defaultStr := "no" - if defaultValue { - defaultStr = "yes" + // Print the answer so it remains visible in terminal history (skip in accessible mode as it already shows) + if !isAccessibleMode() { + fmt.Printf("%s: %s\n", prompt, value) } - input := readString(reader, prompt+" (yes/no)", defaultStr) - return strings.ToLower(input) == "yes" -} -func readBoolNoDefault(reader *bufio.Reader, prompt string) bool { - input := readStringNoDefault(reader, prompt+" (yes/no)") - return strings.ToLower(input) == "yes" -} - -func readInt(reader *bufio.Reader, prompt string, defaultValue int) int { - input := readString(reader, prompt, fmt.Sprintf("%d", defaultValue)) - if input == "" { - return defaultValue - } - value := defaultValue - fmt.Sscanf(input, "%d", &value) return value } + +func readStringNoDefault(prompt string) string { + var value string + + for { + input := huh.NewInput(). + Title(prompt). + Value(&value). + Validate(func(s string) error { + if s == "" { + return fmt.Errorf("this field is required") + } + return nil + }) + + err := runField(input) + handleAbort(err) + + if value != "" { + // Print the answer so it remains visible in terminal history + if !isAccessibleMode() { + fmt.Printf("%s: %s\n", prompt, value) + } + return value + } + } +} + +func readPassword(prompt string) string { + var value string + + for { + input := huh.NewInput(). + Title(prompt). + Value(&value). + EchoMode(huh.EchoModePassword). + Validate(func(s string) error { + if s == "" { + return fmt.Errorf("password is required") + } + return nil + }) + + err := runField(input) + handleAbort(err) + + if value != "" { + // Print confirmation without revealing the password + if !isAccessibleMode() { + fmt.Printf("%s: %s\n", prompt, "********") + } + return value + } + } +} + +func readBool(prompt string, defaultValue bool) bool { + var value = defaultValue + + confirm := huh.NewConfirm(). + Title(prompt). + Value(&value). + Affirmative("Yes"). + Negative("No") + + err := runField(confirm) + handleAbort(err) + + // Print the answer so it remains visible in terminal history + if !isAccessibleMode() { + answer := "No" + if value { + answer = "Yes" + } + fmt.Printf("%s: %s\n", prompt, answer) + } + + return value +} + +func readBoolNoDefault(prompt string) bool { + var value bool + + confirm := huh.NewConfirm(). + Title(prompt). + Value(&value). + Affirmative("Yes"). + Negative("No") + + err := runField(confirm) + handleAbort(err) + + // Print the answer so it remains visible in terminal history + if !isAccessibleMode() { + answer := "No" + if value { + answer = "Yes" + } + fmt.Printf("%s: %s\n", prompt, answer) + } + + return value +} + +func readInt(prompt string, defaultValue int) int { + var value string + + title := fmt.Sprintf("%s (default: %d)", prompt, defaultValue) + + input := huh.NewInput(). + Title(title). + Value(&value). + Validate(func(s string) error { + if s == "" { + return nil + } + _, err := strconv.Atoi(s) + if err != nil { + return fmt.Errorf("please enter a valid number") + } + return nil + }) + + err := runField(input) + handleAbort(err) + + if value == "" { + // Print the answer so it remains visible in terminal history + if !isAccessibleMode() { + fmt.Printf("%s: %d\n", prompt, defaultValue) + } + return defaultValue + } + + result, err := strconv.Atoi(value) + if err != nil { + if !isAccessibleMode() { + fmt.Printf("%s: %d\n", prompt, defaultValue) + } + return defaultValue + } + + // Print the answer so it remains visible in terminal history + if !isAccessibleMode() { + fmt.Printf("%s: %d\n", prompt, result) + } + + return result +} diff --git a/install/main.go b/install/main.go index e1994fc25..9de332b60 100644 --- a/install/main.go +++ b/install/main.go @@ -1,12 +1,12 @@ package main import ( - "bufio" + "crypto/rand" "embed" + "encoding/base64" "fmt" "io" "io/fs" - "math/rand" "net" "net/http" "net/url" @@ -19,11 +19,17 @@ import ( "time" ) -// DO NOT EDIT THIS FUNCTION; IT MATCHED BY REGEX IN CICD +// Version variables injected at build time via -ldflags +var ( + pangolinVersion string + gerbilVersion string + badgerVersion string +) + func loadVersions(config *Config) { - config.PangolinVersion = "replaceme" - config.GerbilVersion = "replaceme" - config.BadgerVersion = "replaceme" + config.PangolinVersion = pangolinVersion + config.GerbilVersion = gerbilVersion + config.BadgerVersion = badgerVersion } //go:embed config/* @@ -49,6 +55,7 @@ type Config struct { DoCrowdsecInstall bool EnableGeoblocking bool Secret string + IsEnterprise bool } type SupportedContainer string @@ -80,14 +87,12 @@ func main() { } } - reader := bufio.NewReader(os.Stdin) - var config Config var alreadyInstalled = false // check if there is already a config file if _, err := os.Stat("config/config.yml"); err != nil { - config = collectUserInput(reader) + config = collectUserInput() loadVersions(&config) config.DoCrowdsecInstall = false @@ -100,7 +105,10 @@ func main() { os.Exit(1) } - moveFile("config/docker-compose.yml", "docker-compose.yml") + if err := moveFile("config/docker-compose.yml", "docker-compose.yml"); err != nil { + fmt.Printf("Error moving docker-compose.yml: %v\n", err) + os.Exit(1) + } fmt.Println("\nConfiguration files created successfully!") @@ -115,13 +123,17 @@ func main() { fmt.Println("\n=== Starting installation ===") - if readBool(reader, "Would you like to install and start the containers?", true) { + if readBool("Would you like to install and start the containers?", true) { - config.InstallationContainerType = podmanOrDocker(reader) + config.InstallationContainerType = podmanOrDocker() if !isDockerInstalled() && runtime.GOOS == "linux" && config.InstallationContainerType == Docker { - if readBool(reader, "Docker is not installed. Would you like to install it?", true) { - installDocker() + if readBool("Docker is not installed. Would you like to install it?", true) { + if err := installDocker(); err != nil { + fmt.Printf("Error installing Docker: %v\n", err) + return + } + // try to start docker service but ignore errors if err := startDockerService(); err != nil { fmt.Println("Error starting Docker service:", err) @@ -130,7 +142,7 @@ func main() { } // wait 10 seconds for docker to start checking if docker is running every 2 seconds fmt.Println("Waiting for Docker to start...") - for i := 0; i < 5; i++ { + for range 5 { if isDockerRunning() { fmt.Println("Docker is running!") break @@ -165,7 +177,7 @@ func main() { fmt.Println("\n=== MaxMind Database Update ===") if _, err := os.Stat("config/GeoLite2-Country.mmdb"); err == nil { fmt.Println("MaxMind GeoLite2 Country database found.") - if readBool(reader, "Would you like to update the MaxMind database to the latest version?", false) { + if readBool("Would you like to update the MaxMind database to the latest version?", false) { if err := downloadMaxMindDatabase(); err != nil { fmt.Printf("Error updating MaxMind database: %v\n", err) fmt.Println("You can try updating it manually later if needed.") @@ -173,13 +185,13 @@ func main() { } } else { fmt.Println("MaxMind GeoLite2 Country database not found.") - if readBool(reader, "Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) { + if readBool("Would you like to download the MaxMind GeoLite2 database for geoblocking functionality?", false) { if err := downloadMaxMindDatabase(); err != nil { fmt.Printf("Error downloading MaxMind database: %v\n", err) fmt.Println("You can try downloading it manually later if needed.") } // Now you need to update your config file accordingly to enable geoblocking - fmt.Println("Please remember to update your config/config.yml file to enable geoblocking! \n") + fmt.Print("Please remember to update your config/config.yml file to enable geoblocking! \n\n") // add maxmind_db_path: "./config/GeoLite2-Country.mmdb" under server fmt.Println("Add the following line under the 'server' section:") fmt.Println(" maxmind_db_path: \"./config/GeoLite2-Country.mmdb\"") @@ -190,11 +202,11 @@ func main() { if !checkIsCrowdsecInstalledInCompose() { fmt.Println("\n=== CrowdSec Install ===") // check if crowdsec is installed - if readBool(reader, "Would you like to install CrowdSec?", false) { + if readBool("Would you like to install CrowdSec?", false) { fmt.Println("This installer constitutes a minimal viable CrowdSec deployment. CrowdSec will add extra complexity to your Pangolin installation and may not work to the best of its abilities out of the box. Users are expected to implement configuration adjustments on their own to achieve the best security posture. Consult the CrowdSec documentation for detailed configuration instructions.") // BUG: crowdsec installation will be skipped if the user chooses to install on the first installation. - if readBool(reader, "Are you willing to manage CrowdSec?", false) { + if readBool("Are you willing to manage CrowdSec?", false) { if config.DashboardDomain == "" { traefikConfig, err := ReadTraefikConfig("config/traefik/traefik_config.yml") if err != nil { @@ -223,12 +235,21 @@ func main() { fmt.Printf("Let's Encrypt Email: %s\n", config.LetsEncryptEmail) fmt.Printf("Badger Version: %s\n", config.BadgerVersion) - if !readBool(reader, "Are these values correct?", true) { - config = collectUserInput(reader) + if !readBool("Are these values correct?", true) { + config = collectUserInput() } } - config.InstallationContainerType = podmanOrDocker(reader) + // Try to detect container type from existing installation + detectedType := detectContainerType() + if detectedType == Undefined { + // If detection fails, prompt the user + fmt.Println("Unable to detect container type from existing installation.") + config.InstallationContainerType = podmanOrDocker() + } else { + config.InstallationContainerType = detectedType + fmt.Printf("Detected container type: %s\n", config.InstallationContainerType) + } config.DoCrowdsecInstall = true err := installCrowdsec(config) @@ -266,8 +287,8 @@ func main() { fmt.Printf("\nTo complete the initial setup, please visit:\nhttps://%s/auth/initial-setup\n", config.DashboardDomain) } -func podmanOrDocker(reader *bufio.Reader) SupportedContainer { - inputContainer := readString(reader, "Would you like to run Pangolin as Docker or Podman containers?", "docker") +func podmanOrDocker() SupportedContainer { + inputContainer := readString("Would you like to run Pangolin as Docker or Podman containers?", "docker") chosenContainer := Docker if strings.EqualFold(inputContainer, "docker") { @@ -279,16 +300,17 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer { os.Exit(1) } - if chosenContainer == Podman { + switch chosenContainer { + case Podman: if !isPodmanInstalled() { fmt.Println("Podman or podman-compose is not installed. Please install both manually. Automated installation will be available in a later release.") os.Exit(1) } - if err := exec.Command("bash", "-c", "cat /etc/sysctl.conf | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil { + if err := exec.Command("bash", "-c", "cat /etc/sysctl.d/99-podman.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start=' || cat /etc/sysctl.conf 2>/dev/null | grep 'net.ipv4.ip_unprivileged_port_start='").Run(); err != nil { fmt.Println("Would you like to configure ports >= 80 as unprivileged ports? This enables podman containers to listen on low-range ports.") fmt.Println("Pangolin will experience startup issues if this is not configured, because it needs to listen on port 80/443 by default.") - approved := readBool(reader, "The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p\". Approve?", true) + approved := readBool("The installer is about to execute \"echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system\". Approve?", true) if approved { if os.Geteuid() != 0 { fmt.Println("You need to run the installer as root for such a configuration.") @@ -299,8 +321,8 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer { // container low-range ports as unprivileged ports. // Linux only. - if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' >> /etc/sysctl.conf && sysctl -p"); err != nil { - fmt.Sprintf("failed to configure unprivileged ports: %v.\n", err) + if err := run("bash", "-c", "echo 'net.ipv4.ip_unprivileged_port_start=80' > /etc/sysctl.d/99-podman.conf && sysctl --system"); err != nil { + fmt.Printf("Error configuring unprivileged ports: %v\n", err) os.Exit(1) } } else { @@ -310,7 +332,7 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer { fmt.Println("Unprivileged ports have been configured.") } - } else if chosenContainer == Docker { + case Docker: // check if docker is not installed and the user is root if !isDockerInstalled() { if os.Geteuid() != 0 { @@ -325,7 +347,7 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer { fmt.Println("The installer will not be able to run docker commands without running it as root.") os.Exit(1) } - } else { + default: // This shouldn't happen unless there's a third container runtime. os.Exit(1) } @@ -333,33 +355,35 @@ func podmanOrDocker(reader *bufio.Reader) SupportedContainer { return chosenContainer } -func collectUserInput(reader *bufio.Reader) Config { +func collectUserInput() Config { config := Config{} // Basic configuration fmt.Println("\n=== Basic Configuration ===") - config.BaseDomain = readString(reader, "Enter your base domain (no subdomain e.g. example.com)", "") + config.IsEnterprise = readBoolNoDefault("Do you want to install the Enterprise version of Pangolin? The EE is free for personal use or for businesses making less than 100k USD annually.") + + config.BaseDomain = readString("Enter your base domain (no subdomain e.g. example.com)", "") // Set default dashboard domain after base domain is collected defaultDashboardDomain := "" if config.BaseDomain != "" { defaultDashboardDomain = "pangolin." + config.BaseDomain } - config.DashboardDomain = readString(reader, "Enter the domain for the Pangolin dashboard", defaultDashboardDomain) - config.LetsEncryptEmail = readString(reader, "Enter email for Let's Encrypt certificates", "") - config.InstallGerbil = readBool(reader, "Do you want to use Gerbil to allow tunneled connections", true) + config.DashboardDomain = readString("Enter the domain for the Pangolin dashboard", defaultDashboardDomain) + config.LetsEncryptEmail = readString("Enter email for Let's Encrypt certificates", "") + config.InstallGerbil = readBool("Do you want to use Gerbil to allow tunneled connections", true) // Email configuration fmt.Println("\n=== Email Configuration ===") - config.EnableEmail = readBool(reader, "Enable email functionality (SMTP)", false) + config.EnableEmail = readBool("Enable email functionality (SMTP)", false) if config.EnableEmail { - config.EmailSMTPHost = readString(reader, "Enter SMTP host", "") - config.EmailSMTPPort = readInt(reader, "Enter SMTP port (default 587)", 587) - config.EmailSMTPUser = readString(reader, "Enter SMTP username", "") - config.EmailSMTPPass = readString(reader, "Enter SMTP password", "") // Should this be readPassword? - config.EmailNoReply = readString(reader, "Enter no-reply email address (often the same as SMTP username)", "") + config.EmailSMTPHost = readString("Enter SMTP host", "") + config.EmailSMTPPort = readInt("Enter SMTP port (default 587)", 587) + config.EmailSMTPUser = readString("Enter SMTP username", "") + config.EmailSMTPPass = readPassword("Enter SMTP password") + config.EmailNoReply = readString("Enter no-reply email address (often the same as SMTP username)", "") } // Validate required fields @@ -380,8 +404,8 @@ func collectUserInput(reader *bufio.Reader) Config { fmt.Println("\n=== Advanced Configuration ===") - config.EnableIPv6 = readBool(reader, "Is your server IPv6 capable?", true) - config.EnableGeoblocking = readBool(reader, "Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true) + config.EnableIPv6 = readBool("Is your server IPv6 capable?", true) + config.EnableGeoblocking = readBool("Do you want to download the MaxMind GeoLite2 database for geoblocking functionality?", true) if config.DashboardDomain == "" { fmt.Println("Error: Dashboard Domain name is required") @@ -392,10 +416,18 @@ func collectUserInput(reader *bufio.Reader) Config { } func createConfigFiles(config Config) error { - os.MkdirAll("config", 0755) - os.MkdirAll("config/letsencrypt", 0755) - os.MkdirAll("config/db", 0755) - os.MkdirAll("config/logs", 0755) + if err := os.MkdirAll("config", 0755); err != nil { + return fmt.Errorf("failed to create config directory: %v", err) + } + if err := os.MkdirAll("config/letsencrypt", 0755); err != nil { + return fmt.Errorf("failed to create letsencrypt directory: %v", err) + } + if err := os.MkdirAll("config/db", 0755); err != nil { + return fmt.Errorf("failed to create db directory: %v", err) + } + if err := os.MkdirAll("config/logs", 0755); err != nil { + return fmt.Errorf("failed to create logs directory: %v", err) + } // Walk through all embedded files err := fs.WalkDir(configFiles, "config", func(path string, d fs.DirEntry, err error) error { @@ -549,22 +581,24 @@ func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomai fmt.Println("To get your setup token, you need to:") fmt.Println("") fmt.Println("1. Start the containers") - if containerType == Docker { + switch containerType { + case Docker: fmt.Println(" docker compose up -d") - } else if containerType == Podman { + case Podman: fmt.Println(" podman-compose up -d") - } else { } + fmt.Println("") fmt.Println("2. Wait for the Pangolin container to start and generate the token") fmt.Println("") fmt.Println("3. Check the container logs for the setup token") - if containerType == Docker { + switch containerType { + case Docker: fmt.Println(" docker logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'") - } else if containerType == Podman { + case Podman: fmt.Println(" podman logs pangolin | grep -A 2 -B 2 'SETUP TOKEN'") - } else { } + fmt.Println("") fmt.Println("4. Look for output like") fmt.Println(" === SETUP TOKEN GENERATED ===") @@ -580,17 +614,12 @@ func showSetupTokenInstructions(containerType SupportedContainer, dashboardDomai } func generateRandomSecretKey() string { - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - const length = 32 - - var seededRand *rand.Rand = rand.New( - rand.NewSource(time.Now().UnixNano())) - - b := make([]byte, length) - for i := range b { - b[i] = charset[seededRand.Intn(len(charset))] + secret := make([]byte, 32) + _, err := rand.Read(secret) + if err != nil { + panic(fmt.Sprintf("Failed to generate random secret key: %v", err)) } - return string(b) + return base64.StdEncoding.EncodeToString(secret) } func getPublicIP() string { @@ -631,10 +660,7 @@ func checkPortsAvailable(port int) error { addr := fmt.Sprintf(":%d", port) ln, err := net.Listen("tcp", addr) if err != nil { - return fmt.Errorf( - "ERROR: port %d is occupied or cannot be bound: %w\n\n", - port, err, - ) + return fmt.Errorf("ERROR: port %d is occupied or cannot be bound: %w", port, err) } if closeErr := ln.Close(); closeErr != nil { fmt.Fprintf(os.Stderr, diff --git a/install/theme.go b/install/theme.go new file mode 100644 index 000000000..61247cf1a --- /dev/null +++ b/install/theme.go @@ -0,0 +1,51 @@ +package main + +import ( + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +// Pangolin brand colors (converted from oklch to hex) +var ( + // Primary orange/amber - oklch(0.6717 0.1946 41.93) + primaryColor = lipgloss.AdaptiveColor{Light: "#D97706", Dark: "#F59E0B"} + // Muted foreground + mutedColor = lipgloss.AdaptiveColor{Light: "#737373", Dark: "#A3A3A3"} + // Success green + successColor = lipgloss.AdaptiveColor{Light: "#16A34A", Dark: "#22C55E"} + // Error red - oklch(0.577 0.245 27.325) + errorColor = lipgloss.AdaptiveColor{Light: "#DC2626", Dark: "#EF4444"} + // Normal text + normalFg = lipgloss.AdaptiveColor{Light: "#171717", Dark: "#FAFAFA"} +) + +// ThemePangolin returns a huh theme using Pangolin brand colors +func ThemePangolin() *huh.Theme { + t := huh.ThemeBase() + + // Focused state styles + t.Focused.Base = t.Focused.Base.BorderForeground(primaryColor) + t.Focused.Title = t.Focused.Title.Foreground(primaryColor).Bold(true) + t.Focused.Description = t.Focused.Description.Foreground(mutedColor) + t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(errorColor) + t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(errorColor) + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(primaryColor) + t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(primaryColor) + t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(primaryColor) + t.Focused.Option = t.Focused.Option.Foreground(normalFg) + t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(primaryColor) + t.Focused.SelectedPrefix = lipgloss.NewStyle().Foreground(successColor).SetString("✓ ") + t.Focused.UnselectedPrefix = lipgloss.NewStyle().Foreground(mutedColor).SetString(" ") + t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(lipgloss.Color("#FFFFFF")).Background(primaryColor) + t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(normalFg).Background(lipgloss.AdaptiveColor{Light: "#E5E5E5", Dark: "#404040"}) + t.Focused.TextInput.Cursor = t.Focused.TextInput.Cursor.Foreground(primaryColor) + t.Focused.TextInput.Prompt = t.Focused.TextInput.Prompt.Foreground(primaryColor) + + // Blurred state inherits from focused but with hidden border + t.Blurred = t.Focused + t.Blurred.Base = t.Focused.Base.BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.Title = t.Blurred.Title.Foreground(mutedColor).Bold(false) + t.Blurred.TextInput.Prompt = t.Blurred.TextInput.Prompt.Foreground(mutedColor) + + return t +} diff --git a/license.py b/license.py new file mode 100644 index 000000000..865dfad7a --- /dev/null +++ b/license.py @@ -0,0 +1,115 @@ +import os +import sys + +# --- Configuration --- +# The header text to be added to the files. +HEADER_TEXT = """/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ +""" + +def should_add_header(file_path): + """ + Checks if a file should receive the commercial license header. + Returns True if 'private' is in the path or file content. + """ + # Check if 'private' is in the file path (case-insensitive) + if 'server/private' in file_path.lower(): + return True + + # Check if 'private' is in the file content (case-insensitive) + # try: + # with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + # content = f.read() + # if 'private' in content.lower(): + # return True + # except Exception as e: + # print(f"Could not read file {file_path}: {e}") + + return False + +def process_directory(root_dir): + """ + Recursively scans a directory and adds headers to qualifying .ts or .tsx files, + skipping any 'node_modules' directories. + """ + print(f"Scanning directory: {root_dir}") + files_processed = 0 + headers_added = 0 + + for root, dirs, files in os.walk(root_dir): + # --- MODIFICATION --- + # Exclude 'node_modules' directories from the scan to improve performance. + if 'node_modules' in dirs: + dirs.remove('node_modules') + + for file in files: + if file.endswith('.ts') or file.endswith('.tsx'): + file_path = os.path.join(root, file) + files_processed += 1 + + try: + with open(file_path, 'r+', encoding='utf-8') as f: + original_content = f.read() + has_header = original_content.startswith(HEADER_TEXT.strip()) + + if should_add_header(file_path): + # Add header only if it's not already there + if not has_header: + f.seek(0, 0) # Go to the beginning of the file + f.write(HEADER_TEXT.strip() + '\n\n' + original_content) + print(f"Added header to: {file_path}") + headers_added += 1 + else: + print(f"Header already exists in: {file_path}") + else: + # Remove header if it exists but shouldn't be there + if has_header: + # Find the end of the header and remove it (including following newlines) + header_with_newlines = HEADER_TEXT.strip() + '\n\n' + if original_content.startswith(header_with_newlines): + content_without_header = original_content[len(header_with_newlines):] + else: + # Handle case where there might be different newline patterns + header_end = len(HEADER_TEXT.strip()) + # Skip any newlines after the header + while header_end < len(original_content) and original_content[header_end] in '\n\r': + header_end += 1 + content_without_header = original_content[header_end:] + + f.seek(0) + f.write(content_without_header) + f.truncate() + print(f"Removed header from: {file_path}") + headers_added += 1 # Reusing counter for modifications + + except Exception as e: + print(f"Error processing file {file_path}: {e}") + + print("\n--- Scan Complete ---") + print(f"Total .ts or .tsx files found: {files_processed}") + print(f"Files modified (headers added/removed): {headers_added}") + + +if __name__ == "__main__": + # Get the target directory from the command line arguments. + # If no directory is provided, it uses the current directory ('.'). + if len(sys.argv) > 1: + target_directory = sys.argv[1] + else: + target_directory = '.' # Default to current directory + + if not os.path.isdir(target_directory): + print(f"Error: Directory '{target_directory}' not found.") + sys.exit(1) + + process_directory(os.path.abspath(target_directory)) diff --git a/messages/bg-BG.json b/messages/bg-BG.json index 5deb59e3c..10b83f38d 100644 --- a/messages/bg-BG.json +++ b/messages/bg-BG.json @@ -1,5 +1,7 @@ { "setupCreate": "Създайте организацията, сайта и ресурсите", + "headerAuthCompatibilityInfo": "Активирайте това, за да принудите отговор '401 Неупълномощено', когато липсва токен за автентификация. Това е необходимо за браузъри или специфични HTTP библиотеки, които не изпращат идентификационни данни без сървърно предизвикателство.", + "headerAuthCompatibility": "Разширена съвместимост.", "setupNewOrg": "Нова организация", "setupCreateOrg": "Създаване на организация", "setupCreateResources": "Създаване на ресурси", @@ -16,6 +18,8 @@ "componentsMember": "Вие сте част от {count, plural, =0 {нула организации} one {една организация} other {# организации}}.", "componentsInvalidKey": "Засечен е невалиден или изтекъл лиценз. Проверете лицензионните условия, за да се възползвате от всички функционалности.", "dismiss": "Отхвърляне", + "subscriptionViolationMessage": "Превишихте ограничението на текущия си план. Коригирайте проблема, като премахнете сайтове, потребители или други ресурси, за да оставате в рамките на плана си.", + "subscriptionViolationViewBilling": "Преглед на фактурирането", "componentsLicenseViolation": "Нарушение на лиценза: Сървърът използва {usedSites} сайта, което надвишава лицензионния лимит от {maxSites} сайта. Проверете лицензионните условия, за да се възползвате от всички функционалности.", "componentsSupporterMessage": "Благодарим ви, че подкрепяте Pangolin като {tier}!", "inviteErrorNotValid": "Съжаляваме, но изглежда, че поканата, до която се опитвате да получите достъп, не е приета или вече не е валидна.", @@ -33,7 +37,7 @@ "password": "Парола", "confirmPassword": "Потвърждение на паролата", "createAccount": "Създаване на профил", - "viewSettings": "Преглед на настройките", + "viewSettings": "Преглед на настройките.", "delete": "Изтриване", "name": "Име", "online": "На линия", @@ -51,6 +55,12 @@ "siteQuestionRemove": "Сигурни ли сте, че искате да премахнете сайта от организацията?", "siteManageSites": "Управление на сайтове", "siteDescription": "Създайте и управлявайте сайтове, за да осигурите свързаност със частни мрежи", + "sitesBannerTitle": "Свържете се с мрежа.", + "sitesBannerDescription": "Сайтът е връзка с отдалечена мрежа, която позволява на Pangolin да предоставя достъп до ресурси, било то публични или частни, на потребители навсякъде. Инсталирайте мрежовия конектор на сайта (Newt) навсякъде, където можете да стартирате бинарен или контейнер, за да създадете връзката.", + "sitesBannerButtonText": "Инсталиране на сайт.", + "approvalsBannerTitle": "Одобрете или откажете достъп до устройство", + "approvalsBannerDescription": "Прегледайте и одобрите или откажете искания за достъп до устройства от потребители. Когато се изисква одобрение на устройства, потребителите трябва да получат администраторско одобрение, преди техните устройства да могат да се свържат с ресурсите на вашата организация.", + "approvalsBannerButtonText": "Научете повече", "siteCreate": "Създайте сайт", "siteCreateDescription2": "Следвайте стъпките по-долу, за да създадете и свържете нов сайт", "siteCreateDescription": "Създайте нов сайт, за да започнете да свързвате ресурси", @@ -100,6 +110,7 @@ "siteTunnelDescription": "Определете как искате да се свържете със сайта", "siteNewtCredentials": "Пълномощия", "siteNewtCredentialsDescription": "Това е как сайтът ще се удостоверява с сървъра", + "remoteNodeCredentialsDescription": "Това е начинът, по който отдалеченият възел ще се автентифицира със сървъра.", "siteCredentialsSave": "Запазете Пълномощията", "siteCredentialsSaveDescription": "Ще можете да виждате това само веднъж. Уверете се да го копирате на сигурно място.", "siteInfo": "Информация за сайта", @@ -146,8 +157,12 @@ "shareErrorSelectResource": "Моля, изберете ресурс", "proxyResourceTitle": "Управление на обществени ресурси", "proxyResourceDescription": "Създайте и управлявайте ресурси, които са общодостъпни чрез уеб браузър.", + "proxyResourcesBannerTitle": "Публичен достъп чрез уеб.", + "proxyResourcesBannerDescription": "Публичните ресурси са HTTPS или TCP/UDP проксита, достъпни за всеки в интернет чрез уеб браузър. За разлика от частните ресурси, те не изискват софтуер от страна на клиента и могат да включват издентити и контексто-осъзнати политики за достъп.", "clientResourceTitle": "Управление на частни ресурси", "clientResourceDescription": "Създайте и управлявайте ресурси, които са достъпни само чрез свързан клиент.", + "privateResourcesBannerTitle": "Достъп до частни ресурси с нулево доверие.", + "privateResourcesBannerDescription": "Частните ресурси използват сигурност с нулево доверие, осигурявайки че потребителите и машините могат да имат само достъп до ресурси, които вие изрично предоставяте. Свържете потребителските устройства или машинните клиенти, за да получите достъп до тези ресурси чрез сигурна виртуална частна мрежа.", "resourcesSearch": "Търсене на ресурси...", "resourceAdd": "Добавете ресурс", "resourceErrorDelte": "Грешка при изтриване на ресурс", @@ -157,9 +172,10 @@ "resourceMessageRemove": "След като се премахне, ресурсът няма повече да бъде достъпен. Всички цели, свързани с ресурса, също ще бъдат премахнати.", "resourceQuestionRemove": "Сигурни ли сте, че искате да премахнете ресурса от организацията?", "resourceHTTP": "HTTPS ресурс", - "resourceHTTPDescription": "Прокси заяви към приложението по HTTPS използвайки поддомейн или базов домейн.", + "resourceHTTPDescription": "Прокси заявки чрез HTTPS, използвайки напълно квалифицирано име на домейн.", "resourceRaw": "Суров TCP/UDP ресурс", - "resourceRawDescription": "Прокси заяви към приложението по TCP/UDP използвайки номер на порт. Това работи само когато сайтовете са свързани към възли.", + "resourceRawDescription": "Прокси заявки чрез сурови TCP/UDP, използвайки порт номер.", + "resourceRawDescriptionCloud": "Получавайте заявки чрез суров TCP/UDP с използване на портен номер. Изисква се сайтовете да се свързват към отдалечен възел.", "resourceCreate": "Създайте ресурс", "resourceCreateDescription": "Следвайте стъпките по-долу, за да създадете нов ресурс", "resourceSeeAll": "Вижте всички ресурси", @@ -186,6 +202,7 @@ "protocolSelect": "Изберете протокол", "resourcePortNumber": "Номер на порт", "resourcePortNumberDescription": "Външен номер на порт за прокси заявки.", + "back": "Назад", "cancel": "Отмяна", "resourceConfig": "Конфигурационни фрагменти", "resourceConfigDescription": "Копирайте и поставете тези конфигурационни отрязъци, за да настроите TCP/UDP ресурса", @@ -231,6 +248,17 @@ "orgErrorDeleteMessage": "Възникна грешка при изтриването на организацията.", "orgDeleted": "Организацията е изтрита", "orgDeletedMessage": "Организацията и нейните данни са изтрити.", + "deleteAccount": "Изтриване на профил", + "deleteAccountDescription": "Перманентно изтрийте своя профил, всички организации, които притежавате, и всички данни в тези организации. Това не може да бъде отменено.", + "deleteAccountButton": "Изтриване на профил", + "deleteAccountConfirmTitle": "Изтрий профила", + "deleteAccountConfirmMessage": "Това ще изтрие перманентно вашия профил, всички организации, които притежавате, и всички данни в тези организации. Това не може да бъде отменено.", + "deleteAccountConfirmString": "изтриване на профил", + "deleteAccountSuccess": "Профилът е изтрит", + "deleteAccountSuccessMessage": "Вашият профил е изтрит.", + "deleteAccountError": "Неуспешно изтриване на профил", + "deleteAccountPreviewAccount": "Вашият профил", + "deleteAccountPreviewOrgs": "Организации, които притежавате (и всички техни данни)", "orgMissing": "Липсва идентификатор на организация", "orgMissingMessage": "Невъзможност за регенериране на покана без идентификатор на организация.", "accessUsersManage": "Управление на потребители", @@ -247,6 +275,8 @@ "accessRolesSearch": "Търсене на роли...", "accessRolesAdd": "Добавете роля", "accessRoleDelete": "Изтриване на роля", + "accessApprovalsManage": "Управление на одобрения", + "accessApprovalsDescription": "Прегледайте и управлявайте чакащи одобрения за достъп до тази организация", "description": "Описание", "inviteTitle": "Отворени покани", "inviteDescription": "Управлявайте покани за други потребители да се присъединят към организацията", @@ -419,7 +449,7 @@ "userErrorExistsDescription": "Този потребител вече е член на организацията.", "inviteError": "Неуспешно поканване на потребител", "inviteErrorDescription": "Възникна грешка при поканването на потребителя", - "userInvited": "Потребителят е поканен", + "userInvited": "Потребителят е поканен.", "userInvitedDescription": "Потребителят беше успешно поканен.", "userErrorCreate": "Неуспешно създаване на потребител", "userErrorCreateDescription": "Възникна грешка при създаване на потребителя", @@ -440,6 +470,20 @@ "selectDuration": "Изберете продължителност", "selectResource": "Изберете Ресурс", "filterByResource": "Филтрирай По Ресурс", + "selectApprovalState": "Изберете състояние на одобрение", + "filterByApprovalState": "Филтрирайте по състояние на одобрение", + "approvalListEmpty": "Няма одобрения", + "approvalState": "Състояние на одобрение", + "approvalLoadMore": "Заредете още", + "loadingApprovals": "Зарежда се одобрение", + "approve": "Одобряване", + "approved": "Одобрен", + "denied": "Отказан", + "deniedApproval": "Одобрение е отказано", + "all": "Всички", + "deny": "Откажете", + "viewDetails": "Разгледай подробности", + "requestingNewDeviceApproval": "поискана нова устройство", "resetFilters": "Нулиране на Филтрите", "totalBlocked": "Заявки Блокирани От Pangolin", "totalRequests": "Общо Заявки", @@ -607,6 +651,7 @@ "resourcesErrorUpdate": "Неуспешно превключване на ресурса", "resourcesErrorUpdateDescription": "Възникна грешка при актуализиране на ресурса", "access": "Достъп", + "accessControl": "Контрол на достъпа", "shareLink": "{resource} Сподели връзка", "resourceSelect": "Изберете ресурс", "shareLinks": "Споделени връзки", @@ -687,7 +732,7 @@ "resourceRoleDescription": "Администраторите винаги могат да имат достъп до този ресурс.", "resourceUsersRoles": "Контроли за достъп", "resourceUsersRolesDescription": "Конфигурирайте кои потребители и роли могат да посещават този ресурс", - "resourceUsersRolesSubmit": "Запазете потребители и роли", + "resourceUsersRolesSubmit": "Запазване на управлението на достъп.", "resourceWhitelistSave": "Успешно запазено", "resourceWhitelistSaveDescription": "Настройките на белия списък са запазени", "ssoUse": "Използвай платформен SSO", @@ -719,22 +764,35 @@ "countries": "Държави", "accessRoleCreate": "Създайте роля", "accessRoleCreateDescription": "Създайте нова роля за групиране на потребители и управление на техните разрешения.", + "accessRoleEdit": "Редактиране на роля", + "accessRoleEditDescription": "Редактирайте информацията за ролята.", "accessRoleCreateSubmit": "Създайте роля", "accessRoleCreated": "Ролята е създадена", "accessRoleCreatedDescription": "Ролята беше успешно създадена.", "accessRoleErrorCreate": "Неуспешно създаване на роля", "accessRoleErrorCreateDescription": "Възникна грешка при създаването на ролята.", + "accessRoleUpdateSubmit": "Обновете роля", + "accessRoleUpdated": "Ролята е актуализирана", + "accessRoleUpdatedDescription": "Ролята беше успешно актуализирана.", + "accessApprovalUpdated": "Одобрението е обработено", + "accessApprovalApprovedDescription": "Задайте решение на заявка за одобрение да бъде одобрено.", + "accessApprovalDeniedDescription": "Задайте решение на заявка за одобрение да бъде отказано.", + "accessRoleErrorUpdate": "Неуспешно актуализиране на ролята", + "accessRoleErrorUpdateDescription": "Възникна грешка при актуализиране на ролята.", + "accessApprovalErrorUpdate": "Неуспешно обработване на одобрение", + "accessApprovalErrorUpdateDescription": "Възникна грешка при обработване на одобрението.", "accessRoleErrorNewRequired": "Нова роля е необходима", "accessRoleErrorRemove": "Неуспешно премахване на роля", "accessRoleErrorRemoveDescription": "Възникна грешка при премахването на роля.", "accessRoleName": "Име на роля", - "accessRoleQuestionRemove": "Ще изтриете ролята {name}. Не можете да отмените това действие.", + "accessRoleQuestionRemove": "Ще изтриете ролята `{name}`. Не можете да отмените това действие.", "accessRoleRemove": "Премахни роля", "accessRoleRemoveDescription": "Премахни роля от организацията", "accessRoleRemoveSubmit": "Премахни роля", "accessRoleRemoved": "Ролята е премахната", "accessRoleRemovedDescription": "Ролята беше успешно премахната.", "accessRoleRequiredRemove": "Преди да изтриете тази роля, моля изберете нова роля, към която да прехвърлите настоящите членове.", + "network": "Мрежа", "manage": "Управление", "sitesNotFound": "Няма намерени сайтове.", "pangolinServerAdmin": "Администратор на сървър - Панголин", @@ -750,6 +808,9 @@ "sitestCountIncrease": "Увеличаване на броя на сайтовете", "idpManage": "Управление на доставчици на идентичност", "idpManageDescription": "Прегледайте и управлявайте доставчици на идентичност в системата", + "idpGlobalModeBanner": "Доставчиците на идентичност (IdPs) за всяка организация са деактивирани на този сървър. Използват се глобални IdPs (споделени между всички организации). Управлявайте глобалните IdPs в администраторския панел. За да активирате IdPs за всяка организация, редактирайте конфигурацията на сървъра и задайте режима на IdP към org. Вижте документацията. Ако желаете да продължите да използвате глобалните IdPs и да премахнете това от настройките на организацията, изрично задайте режима на global в конфигурацията.", + "idpGlobalModeBannerUpgradeRequired": "Доставчиците на идентичност (IdPs) за всяка организация са деактивирани на този сървър. Използват се глобални IdPs (споделени между всички организации). Управлявайте глобалните IdPs в администраторския панел. За да използвате доставчици на идентичност за всяка организация, трябва да надстроите до изданието Enterprise.", + "idpGlobalModeBannerLicenseRequired": "Доставчиците на идентичност (IdPs) за всяка организация са деактивирани на този сървър. Използват се глобални IdPs (споделени между всички организации). Управлявайте глобалните IdPs в администраторския панел. За да използвате доставчици на идентичност за всяка организация, е необходим лиценз за изданието Enterprise.", "idpDeletedDescription": "Доставчик на идентичност успешно изтрит", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Сигурни ли сте, че искате да изтриете доставчика за идентичност?", @@ -840,6 +901,7 @@ "orgPolicyConfig": "Конфигуриране на достъп за организация", "idpUpdatedDescription": "Идентификационният доставчик беше актуализиран успешно", "redirectUrl": "URL за пренасочване", + "orgIdpRedirectUrls": "URL адреси за пренасочване", "redirectUrlAbout": "За URL за пренасочване", "redirectUrlAboutDescription": "Това е URL адресът, към който потребителите ще бъдат пренасочени след удостоверяване. Трябва да конфигурирате този URL адрес в настройките на доставчика на идентичност.", "pangolinAuth": "Authent - Pangolin", @@ -863,7 +925,7 @@ "inviteAlready": "Изглежда, че сте били поканени!", "inviteAlreadyDescription": "За да приемете поканата, трябва да влезете или да създадете акаунт.", "signupQuestion": "Вече имате акаунт?", - "login": "Влизане", + "login": "Вход", "resourceNotFound": "Ресурсът не е намерен", "resourceNotFoundDescription": "Ресурсът, който се опитвате да достъпите, не съществува.", "pincodeRequirementsLength": "ПИН трябва да бъде точно 6 цифри", @@ -943,13 +1005,13 @@ "passwordExpiryDescription": "Тази организация изисква да сменяте паролата си на всеки {maxDays} дни.", "changePasswordNow": "Сменете паролата сега", "pincodeAuth": "Код на удостоверителя", - "pincodeSubmit2": "Изпрати код", + "pincodeSubmit2": "Изпратете кода", "passwordResetSubmit": "Заявка за нулиране", - "passwordResetAlreadyHaveCode": "Въведете код за нулиране на парола", + "passwordResetAlreadyHaveCode": "Въведете код.", "passwordResetSmtpRequired": "Моля, свържете се с вашия администратор", "passwordResetSmtpRequiredDescription": "Кодът за нулиране на парола е задължителен за нулиране на паролата ви. Моля, свържете се с вашия администратор за помощ.", "passwordBack": "Назад към Парола", - "loginBack": "Връщане към вход", + "loginBack": "Върнете се на главната страница за вход", "signup": "Регистрация", "loginStart": "Влезте, за да започнете", "idpOidcTokenValidating": "Валидиране на OIDC токен", @@ -972,12 +1034,12 @@ "pangolinSetup": "Настройка - Pangolin", "orgNameRequired": "Името на организацията е задължително", "orgIdRequired": "ID на организацията е задължително", + "orgIdMaxLength": "ID на организация трябва да бъде най-много 32 символа", "orgErrorCreate": "Възникна грешка при създаване на организация", "pageNotFound": "Страницата не е намерена", "pageNotFoundDescription": "О, не! Страницата, която търсите, не съществува.", "overview": "Общ преглед", "home": "Начало", - "accessControl": "Контрол на достъпа", "settings": "Настройки", "usersAll": "Всички потребители", "license": "Лиценз", @@ -1035,15 +1097,24 @@ "updateOrgUser": "Актуализиране на Организационна Потребител", "createOrgUser": "Създаване на Организационна Потребител", "actionUpdateOrg": "Актуализиране на организацията", + "actionRemoveInvitation": "Премахване на поканата.", "actionUpdateUser": "Актуализиране на потребител", "actionGetUser": "Получаване на потребител", "actionGetOrgUser": "Вземете потребител на организация", "actionListOrgDomains": "Изброяване на домейни на организация", + "actionGetDomain": "Вземи домейн", + "actionCreateOrgDomain": "Създай домейн", + "actionUpdateOrgDomain": "Актуализирай домейн", + "actionDeleteOrgDomain": "Изтрий домейн", + "actionGetDNSRecords": "Вземи DNS записи", + "actionRestartOrgDomain": "Рестартирай домейн", "actionCreateSite": "Създаване на сайт", "actionDeleteSite": "Изтриване на сайта", "actionGetSite": "Вземете сайт", "actionListSites": "Изброяване на сайтове", "actionApplyBlueprint": "Приложи Чернова", + "actionListBlueprints": "Списък с планове.", + "actionGetBlueprint": "Вземи план.", "setupToken": "Конфигурация на токен", "setupTokenDescription": "Въведете конфигурационния токен от сървърната конзола.", "setupTokenRequired": "Необходим е конфигурационен токен", @@ -1077,6 +1148,7 @@ "actionRemoveUser": "Изтрийте потребител", "actionListUsers": "Изброяване на потребители", "actionAddUserRole": "Добавяне на роля на потребител", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Генериране на токен за достъп", "actionDeleteAccessToken": "Изтриване на токен за достъп", "actionListAccessTokens": "Изброяване на токени за достъп", @@ -1104,6 +1176,10 @@ "actionUpdateIdpOrg": "Актуализиране на IdP организация", "actionCreateClient": "Създаване на клиент", "actionDeleteClient": "Изтриване на клиент", + "actionArchiveClient": "Архивиране на клиента", + "actionUnarchiveClient": "Разархивиране на клиента", + "actionBlockClient": "Блокиране на клиента", + "actionUnblockClient": "Деблокиране на клиента", "actionUpdateClient": "Актуализиране на клиент", "actionListClients": "Списък с клиенти", "actionGetClient": "Получаване на клиент", @@ -1117,17 +1193,18 @@ "actionViewLogs": "Преглед на дневници", "noneSelected": "Нищо не е избрано", "orgNotFound2": "Няма намерени организации.", - "searchProgress": "Търсене...", + "searchPlaceholder": "Търсене...", + "emptySearchOptions": "Няма намерени опции", "create": "Създаване", "orgs": "Организации", - "loginError": "Възникна грешка при влизане", - "loginRequiredForDevice": "Необходим е вход за удостоверяване на вашето устройство.", + "loginError": "Възникна неочаквана грешка. Моля, опитайте отново.", + "loginRequiredForDevice": "Необходим е вход за вашето устройство.", "passwordForgot": "Забравена парола?", "otpAuth": "Двуфакторно удостоверяване", "otpAuthDescription": "Въведете кода от приложението за удостоверяване или един от вашите резервни кодове за еднократна употреба.", "otpAuthSubmit": "Изпрати код", "idpContinue": "Или продължете със", - "otpAuthBack": "Назад към Вход", + "otpAuthBack": "Назад към парола", "navbar": "Навигационно меню", "navbarDescription": "Главно навигационно меню за приложението", "navbarDocsLink": "Документация", @@ -1175,11 +1252,13 @@ "sidebarOverview": "Общ преглед", "sidebarHome": "Начало", "sidebarSites": "Сайтове", + "sidebarApprovals": "Заявки за одобрение", "sidebarResources": "Ресурси", "sidebarProxyResources": "Публично", "sidebarClientResources": "Частно", "sidebarAccessControl": "Контрол на достъпа", "sidebarLogsAndAnalytics": "Дневници и анализи", + "sidebarTeam": "Екип", "sidebarUsers": "Потребители", "sidebarAdmin": "Администратор", "sidebarInvitations": "Покани", @@ -1191,13 +1270,15 @@ "sidebarIdentityProviders": "Идентификационни доставчици", "sidebarLicense": "Лиценз", "sidebarClients": "Клиенти", - "sidebarUserDevices": "Потребители", + "sidebarUserDevices": "Устройства на потребителя", "sidebarMachineClients": "Машини", "sidebarDomains": "Домейни", - "sidebarGeneral": "Общи", + "sidebarGeneral": "Управление.", "sidebarLogAndAnalytics": "Лог & Анализи", "sidebarBluePrints": "Чертежи", "sidebarOrganization": "Организация", + "sidebarManagement": "Управление", + "sidebarBillingAndLicenses": "Фактуриране & Лицензи", "sidebarLogsAnalytics": "Анализи", "blueprints": "Чертежи", "blueprintsDescription": "Прилагайте декларативни конфигурации и преглеждайте предишни изпълнения", @@ -1219,7 +1300,6 @@ "parsedContents": "Парсирано съдържание (само за четене)", "enableDockerSocket": "Активиране на Docker Чернова", "enableDockerSocketDescription": "Активиране на Docker Socket маркировка за изтегляне на етикети на чернова. Пътят на гнездото трябва да бъде предоставен на Newt.", - "enableDockerSocketLink": "Научете повече", "viewDockerContainers": "Преглед на Docker контейнери", "containersIn": "Контейнери в {siteName}", "selectContainerDescription": "Изберете контейнер, който да ползвате като име на хост за целта. Натиснете порт, за да ползвате порт", @@ -1263,6 +1343,7 @@ "setupErrorCreateAdmin": "Възникна грешка при създаване на админ акаунт.", "certificateStatus": "Статус на сертификата", "loading": "Зареждане", + "loadingAnalytics": "Зареждане на анализи", "restart": "Рестарт", "domains": "Домейни", "domainsDescription": "Създайте и управлявайте наличните домейни в организацията", @@ -1290,6 +1371,7 @@ "refreshError": "Неуспешно обновяване на данни", "verified": "Потвърдено", "pending": "Чакащо", + "pendingApproval": "Очаква одобрение", "sidebarBilling": "Фактуриране", "billing": "Фактуриране", "orgBillingDescription": "Управлявайте информацията за плащане и абонаментите", @@ -1308,8 +1390,11 @@ "accountSetupSuccess": "Настройката на профила завърши успешно! Добре дошли в Pangolin!", "documentation": "Документация", "saveAllSettings": "Запазване на всички настройки", + "saveResourceTargets": "Запазване на целеви ресурси.", + "saveResourceHttp": "Запазване на прокси настройките.", + "saveProxyProtocol": "Запазване на настройките на прокси протокола.", "settingsUpdated": "Настройките са обновени", - "settingsUpdatedDescription": "Всички настройки са успешно обновени", + "settingsUpdatedDescription": "Настройките са успешно актуализирани.", "settingsErrorUpdate": "Неуспешно обновяване на настройките", "settingsErrorUpdateDescription": "Възникна грешка при обновяване на настройките", "sidebarCollapse": "Свиване", @@ -1342,6 +1427,7 @@ "domainPickerNamespace": "Име на пространство: {namespace}", "domainPickerShowMore": "Покажи повече", "regionSelectorTitle": "Избор на регион", + "domainPickerRemoteExitNodeWarning": "Предоставените домейни не се поддържат, когато сайтовете се свързват към отдалечени крайни възли. За да бъдат ресурсите налични на отдалечени възли, използвайте персонализиран домейн вместо това.", "regionSelectorInfo": "Изборът на регион ни помага да предоставим по-добра производителност за вашето местоположение. Не е необходимо да сте в същия регион като сървъра.", "regionSelectorPlaceholder": "Изберете регион", "regionSelectorComingSoon": "Очаква се скоро", @@ -1351,10 +1437,11 @@ "billingUsageLimitsOverview": "Преглед на лимитите за използване", "billingMonitorUsage": "Следете своята употреба спрямо конфигурираните лимити. Ако имате нужда от увеличаване на лимитите, моля свържете се с нас support@pangolin.net.", "billingDataUsage": "Използване на данни", - "billingOnlineTime": "Време на работа на сайта", - "billingUsers": "Активни потребители", - "billingDomains": "Активни домейни", - "billingRemoteExitNodes": "Активни самостоятелно хоствани възли", + "billingSites": "Сайтове", + "billingUsers": "Потребители", + "billingDomains": "Домейни", + "billingOrganizations": "Организации", + "billingRemoteExitNodes": "Дистанционни възли", "billingNoLimitConfigured": "Няма конфигуриран лимит", "billingEstimatedPeriod": "Очакван период на фактуриране", "billingIncludedUsage": "Включено използване", @@ -1379,15 +1466,24 @@ "billingFailedToGetPortalUrl": "Неуспех при получаване на URL на портала", "billingPortalError": "Грешка в портала", "billingDataUsageInfo": "Таксува се за всички данни, прехвърляни през вашите защитени тунели, когато сте свързани към облака. Това включва както входящия, така и изходящия трафик за всички ваши сайтове. Когато достигнете лимита си, вашите сайтове ще бъдат прекъснати, докато не надстроите плана или не намалите използването. Данните не се таксуват при използване на възли.", - "billingOnlineTimeInfo": "Таксува се на база колко време вашите сайтове остават свързани с облака. Пример: 44,640 минути се равняват на един сайт работещ 24/7 за цял месец. Когато достигнете лимита си, вашите сайтове ще бъдат прекъснати, докато не надстроите плана или не намалите използването. Времето не се таксува при използване на възли.", - "billingUsersInfo": "Таксува се всеки потребител в организацията. Таксуването се изчислява ежедневно въз основа на броя на активните потребителски акаунти във вашата организация.", - "billingDomainInfo": "Таксува се всеки домейн в организацията. Таксуването се изчислява ежедневно въз основа на броя на активните домейн акаунти във вашата организация.", - "billingRemoteExitNodesInfo": "Таксува се всеки управляван възел в организацията. Таксуването се изчислява ежедневно въз основа на броя на активните управлявани възли във вашата организация.", + "billingSInfo": "Колко сайта можете да използвате", + "billingUsersInfo": "Колко потребители можете да използвате", + "billingDomainInfo": "Колко домейни можете да използвате", + "billingRemoteExitNodesInfo": "Колко дистанционни възли можете да използвате", + "billingLicenseKeys": "Лицензионни ключове", + "billingLicenseKeysDescription": "Управлявайте вашите абонаменти за лицензионни ключове", + "billingLicenseSubscription": "Абонамент за лиценз", + "billingInactive": "Неактивен", + "billingLicenseItem": "Лицензионен елемент", + "billingQuantity": "Количество", + "billingTotal": "общо", + "billingModifyLicenses": "Промяна на абонамента за лиценз", "domainNotFound": "Домейнът не е намерен", "domainNotFoundDescription": "Този ресурс е деактивиран, защото домейнът вече не съществува в нашата система. Моля, задайте нов домейн за този ресурс.", "failed": "Неуспешно", "createNewOrgDescription": "Създайте нова организация", "organization": "Организация", + "primary": "Основно", "port": "Порт", "securityKeyManage": "Управление на ключове за защита", "securityKeyDescription": "Добавяне или премахване на ключове за защита за удостоверяване без парола", @@ -1403,7 +1499,7 @@ "securityKeyRemoveSuccess": "Ключът за защита е премахнат успешно", "securityKeyRemoveError": "Неуспешно премахване на ключ за защита", "securityKeyLoadError": "Неуспешно зареждане на ключове за защита", - "securityKeyLogin": "Продължете с ключа за сигурност", + "securityKeyLogin": "Използвайте ключ за защита", "securityKeyAuthError": "Неуспешно удостоверяване с ключ за сигурност", "securityKeyRecommendation": "Регистрирайте резервен ключ за безопасност на друго устройство, за да сте сигурни, че винаги ще имате достъп до профила си", "registering": "Регистрация...", @@ -1459,11 +1555,47 @@ "resourcePortRequired": "Номерът на порта е задължителен за не-HTTP ресурси", "resourcePortNotAllowed": "Номерът на порта не трябва да бъде задаван за HTTP ресурси", "billingPricingCalculatorLink": "Калкулатор на цените", + "billingYourPlan": "Вашият план", + "billingViewOrModifyPlan": "Преглед или промяна на текущия ви план", + "billingViewPlanDetails": "Преглед на подробности за плана", + "billingUsageAndLimits": "Използване и граници", + "billingViewUsageAndLimits": "Преглед на ограниченията на плана и текущото използване", + "billingCurrentUsage": "Текущо използване", + "billingMaximumLimits": "Максимални граници", + "billingRemoteNodes": "Дистанционни възли", + "billingUnlimited": "Неограничено", + "billingPaidLicenseKeys": "Платени лицензионни ключове", + "billingManageLicenseSubscription": "Управлявайте абонамента си за платени самостоятелно хоствани лицензионни ключове", + "billingCurrentKeys": "Текущи ключове", + "billingModifyCurrentPlan": "Промяна на текущия план", + "billingConfirmUpgrade": "Потвърдете повишаването", + "billingConfirmDowngrade": "Потвърдете понижението", + "billingConfirmUpgradeDescription": "Предстои ви да повишите плана си. Прегледайте новите ограничения и цени по-долу.", + "billingConfirmDowngradeDescription": "Предстои ви да понижите плана си. Прегледайте новите ограничения и цени по-долу.", + "billingPlanIncludes": "Планът включва", + "billingProcessing": "Процесиране...", + "billingConfirmUpgradeButton": "Потвърдете повишаването", + "billingConfirmDowngradeButton": "Потвърдете понижението", + "billingLimitViolationWarning": "Използването надвишава новите планови ограничения", + "billingLimitViolationDescription": "Текущото ви използване надвишава ограниченията на този план. След понижаване, всички действия ще бъдат деактивирани, докато не намалите използването в рамките на новите ограничения. Моля, прегледайте по-долу функциите, които в момента са извън ограниченията. Ограничения в нарушение:", + "billingFeatureLossWarning": "Уведомление за наличност на функциите", + "billingFeatureLossDescription": "Чрез понижението на плана, функциите, недостъпни в новия план, ще бъдат автоматично деактивирани. Някои настройки и конфигурации може да бъдат загубени. Моля, прегледайте ценовата матрица, за да разберете кои функции вече няма да са на разположение.", + "billingUsageExceedsLimit": "Текущото използване ({current}) надвишава ограничението ({limit})", + "billingPastDueTitle": "Плащането е просрочено", + "billingPastDueDescription": "Вашето плащане е просрочено. Моля, актуализирайте метода на плащане, за да продължите да използвате настоящия си план. Ако проблемът не бъде разрешен, абонаментът ви ще бъде прекратен и ще бъдете прехвърлени на безплатния план.", + "billingUnpaidTitle": "Абонаментът не е платен", + "billingUnpaidDescription": "Вашият абонамент не е платен и сте прехвърлени на безплатния план. Моля, актуализирайте метода на плащане, за да възстановите вашия абонамент.", + "billingIncompleteTitle": "Плащането е непълно", + "billingIncompleteDescription": "Вашето плащане е непълно. Моля, завършете процеса на плащане, за да активирате вашия абонамент.", + "billingIncompleteExpiredTitle": "Плащането е изтекло", + "billingIncompleteExpiredDescription": "Вашето плащане никога не е завършено и е изтекло. Прехвърлени сте на безплатния план. Моля, абонирайте се отново, за да възстановите достъпа до платените функции.", + "billingManageSubscription": "Управлявайте вашия абонамент", + "billingResolvePaymentIssue": "Моля, разрешете проблема с плащането преди да извършите надграждане или понижение", "signUpTerms": { "IAgreeToThe": "Съгласен съм с", "termsOfService": "условията за ползване", "and": "и", - "privacyPolicy": "политиката за поверителност" + "privacyPolicy": "политика за поверителност." }, "signUpMarketing": { "keepMeInTheLoop": "Дръж ме в течение с новини, актуализации и нови функции чрез имейл." @@ -1508,6 +1640,7 @@ "addNewTarget": "Добави нова цел", "targetsList": "Списък с цели", "advancedMode": "Разширен режим", + "advancedSettings": "Разширени настройки.", "targetErrorDuplicateTargetFound": "Дублирана цел намерена", "healthCheckHealthy": "Здрав", "healthCheckUnhealthy": "Нездрав", @@ -1529,6 +1662,26 @@ "IntervalSeconds": "Интервал за здраве", "timeoutSeconds": "Време за изчакване (сек)", "timeIsInSeconds": "Времето е в секунди", + "requireDeviceApproval": "Изискват одобрение на устройства", + "requireDeviceApprovalDescription": "Потребители с тази роля трябва да имат нови устройства одобрени от администратор преди да могат да се свържат и да имат достъп до ресурси.", + "sshAccess": "SSH достъп", + "roleAllowSsh": "Разреши SSH", + "roleAllowSshAllow": "Разреши", + "roleAllowSshDisallow": "Забрани", + "roleAllowSshDescription": "Разреши на потребителите с тази роля да се свързват с ресурси чрез SSH. Когато е деактивирано, ролята не може да използва SSH достъп.", + "sshSudoMode": "Sudo достъп", + "sshSudoModeNone": "Няма", + "sshSudoModeNoneDescription": "Потребителят не може да изпълнява команди с sudo.", + "sshSudoModeFull": "Пълен Sudo", + "sshSudoModeFullDescription": "Потребителят може да изпълнява всяка команда с sudo.", + "sshSudoModeCommands": "Команди", + "sshSudoModeCommandsDescription": "Потребителят може да изпълнява само определени команди с sudo.", + "sshSudo": "Разреши sudo", + "sshSudoCommands": "Sudo команди", + "sshSudoCommandsDescription": "Списък, разделен със запетаи, с команди, които потребителят е позволено да изпълнява с sudo.", + "sshCreateHomeDir": "Създай начална директория", + "sshUnixGroups": "Unix групи", + "sshUnixGroupsDescription": "Списък, разделен със запетаи, с Unix групи, към които да се добави потребителят на целевия хост.", "retryAttempts": "Опити за повторно", "expectedResponseCodes": "Очаквани кодове за отговор", "expectedResponseCodesDescription": "HTTP статус код, указващ здравословно състояние. Ако бъде оставено празно, между 200-300 се счита за здравословно.", @@ -1569,6 +1722,8 @@ "resourcesTableNoInternalResourcesFound": "Не са намерени вътрешни ресурси.", "resourcesTableDestination": "Дестинация", "resourcesTableAlias": "Псевдоним", + "resourcesTableAliasAddress": "Адрес на псевдоним.", + "resourcesTableAliasAddressInfo": "Този адрес е част от подсистемата на организацията. Използва се за разрешаване на псевдонимни записи чрез вътрешно DNS разрешаване.", "resourcesTableClients": "Клиенти", "resourcesTableAndOnlyAccessibleInternally": "и са достъпни само вътрешно при свързване с клиент.", "resourcesTableNoTargets": "Без цели", @@ -1616,9 +1771,8 @@ "createInternalResourceDialogResourceProperties": "Свойства на ресурса", "createInternalResourceDialogName": "Име", "createInternalResourceDialogSite": "Сайт", - "createInternalResourceDialogSelectSite": "Изберете сайт...", - "createInternalResourceDialogSearchSites": "Търсене на сайтове...", - "createInternalResourceDialogNoSitesFound": "Не са намерени сайтове.", + "selectSite": "Изберете сайт...", + "noSitesFound": "Не са намерени сайтове.", "createInternalResourceDialogProtocol": "Протокол", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", @@ -1658,7 +1812,7 @@ "siteAddressDescription": "Вътрешният адрес на сайта. Трябва да пада в подмрежата на организацията.", "siteNameDescription": "Показваното име на сайта, което може да се промени по-късно.", "autoLoginExternalIdp": "Автоматично влизане с Външен IDP", - "autoLoginExternalIdpDescription": "Незабавно пренасочете потребителя към външния IDP за удостоверяване.", + "autoLoginExternalIdpDescription": "Незабавно пренасочване на потребителя към външния доставчик на идентичност за автентификация.", "selectIdp": "Изберете IDP", "selectIdpPlaceholder": "Изберете IDP...", "selectIdpRequired": "Моля, изберете IDP, когато автоматичното влизане е разрешено.", @@ -1670,7 +1824,7 @@ "autoLoginErrorNoRedirectUrl": "Не е получен URL за пренасочване от доставчика на идентификационни данни.", "autoLoginErrorGeneratingUrl": "Неуспешно генериране на URL за удостоверяване.", "remoteExitNodeManageRemoteExitNodes": "Отдалечени възли", - "remoteExitNodeDescription": "Самостоятелно хоствайте един или повече отдалечени възли, за да разширите мрежовата свързаност и да намалите зависимостта от облака", + "remoteExitNodeDescription": "Хоствайте вашите собствени отдалечени ретранслатори и прокси сървърни възли.", "remoteExitNodes": "Възли", "searchRemoteExitNodes": "Търсене на възли...", "remoteExitNodeAdd": "Добавяне на възел", @@ -1680,20 +1834,22 @@ "remoteExitNodeConfirmDelete": "Потвърдете изтриването на възела (\"Confirm Delete Site\" match)", "remoteExitNodeDelete": "Изтрийте възела (\"Delete Site\" match)", "sidebarRemoteExitNodes": "Отдалечени възли", + "remoteExitNodeId": "ID.", + "remoteExitNodeSecretKey": "Секретен ключ.", "remoteExitNodeCreate": { - "title": "Създаване на възел", - "description": "Създайте нов възел, за да разширите мрежовата свързаност", + "title": "Създаване на отдалечен възел.", + "description": "Създайте нов самохостнал отдалечен ретранслатор и прокси сървърен възел.", "viewAllButton": "Вижте всички възли", "strategy": { "title": "Стратегия на създаване", - "description": "Изберете това, за да конфигурирате ръчно възела или да генерирате нови идентификационни данни.", + "description": "Изберете как искате да създадете отдалечения възел.", "adopt": { "title": "Осиновете възел", "description": "Изберете това, ако вече имате кредити за възела." }, "generate": { "title": "Генериране на ключове", - "description": "Изберете това, ако искате да генерирате нови ключове за възела" + "description": "Изберете това, ако искате да генерирате нови ключове за възела." } }, "adopt": { @@ -1806,9 +1962,33 @@ "idpAzureDescription": "Microsoft Azure OAuth2/OIDC доставчик", "subnet": "Подмрежа", "subnetDescription": "Подмрежата за конфигурацията на мрежата на тази организация.", - "authPage": "Страница за удостоверяване", - "authPageDescription": "Конфигурирайте страницата за автентикация за организацията", + "customDomain": "Персонализиран домейн.", + "authPage": "Страници за автентификация.", + "authPageDescription": "Задайте персонализиран домейн за страниците за автентификация на организацията.", "authPageDomain": "Домен на страницата за удостоверяване", + "authPageBranding": "Персонализиран брандинг.", + "authPageBrandingDescription": "Конфигурирайте брандинга, който се появява на страниците за автентификация за тази организация.", + "authPageBrandingUpdated": "Брандингът на страницата за автентификация е актуализиран успешно.", + "authPageBrandingRemoved": "Брандингът на страницата за автентификация е премахнат успешно.", + "authPageBrandingRemoveTitle": "Премахване на брандинга на страницата за автентификация.", + "authPageBrandingQuestionRemove": "Сигурни ли сте, че искате да премахнете брандинга за страниците за автентификация?", + "authPageBrandingDeleteConfirm": "Потвърждение на изтриване на брандинга.", + "brandingLogoURL": "URL адрес на логото.", + "brandingLogoURLOrPath": "URL или Път към лого", + "brandingLogoPathDescription": "Въведете URL или локален път.", + "brandingLogoURLDescription": "Въведете публично достъпен URL към вашето лого изображение.", + "brandingPrimaryColor": "Основен цвят.", + "brandingLogoWidth": "Ширина (px).", + "brandingLogoHeight": "Височина (px).", + "brandingOrgTitle": "Заглавие за страницата за автентификация на организацията.", + "brandingOrgDescription": "{orgName} ще бъде заменено с името на организацията.", + "brandingOrgSubtitle": "Подзаглавие за страницата за автентификация на организацията.", + "brandingResourceTitle": "Заглавие за страницата за автентификация на ресурса.", + "brandingResourceSubtitle": "Подзаглавие за страницата за автентификация на ресурса.", + "brandingResourceDescription": "{resourceName} ще бъде заменено с името на организацията.", + "saveAuthPageDomain": "Запазване на домейна.", + "saveAuthPageBranding": "Запазване на брандинга.", + "removeAuthPageBranding": "Премахване на брандинга.", "noDomainSet": "Няма зададен домейн", "changeDomain": "Смяна на домейн", "selectDomain": "Избор на домейн", @@ -1817,7 +1997,7 @@ "setAuthPageDomain": "Задаване на домейн на страницата за удостоверяване", "failedToFetchCertificate": "Неуспех при извличане на сертификат", "failedToRestartCertificate": "Неуспех при рестартиране на сертификат", - "addDomainToEnableCustomAuthPages": "Добавете домейн за да активирате персонализирани страници за автентикация за организацията", + "addDomainToEnableCustomAuthPages": "Потребителите ще имат достъп до страницата за вход на организацията и ще завършат автентификацията на ресурси, като използват този домейн.", "selectDomainForOrgAuthPage": "Изберете домейн за страницата за удостоверяване на организацията", "domainPickerProvidedDomain": "Предоставен домейн", "domainPickerFreeProvidedDomain": "Безплатен предоставен домейн", @@ -1832,11 +2012,27 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" не може да се направи валиден за {domain}.", "domainPickerSubdomainSanitized": "Поддомен пречистен", "domainPickerSubdomainCorrected": "\"{sub}\" беше коригиран на \"{sanitized}\"", - "orgAuthSignInTitle": "Влезте в организацията", + "orgAuthSignInTitle": "Вход в организация.", "orgAuthChooseIdpDescription": "Изберете своя доставчик на идентичност, за да продължите", "orgAuthNoIdpConfigured": "Тази организация няма конфигурирани доставчици на идентичност. Можете да влезете с вашата Pangolin идентичност.", "orgAuthSignInWithPangolin": "Впишете се с Pangolin", + "orgAuthSignInToOrg": "Влезте в организация", + "orgAuthSelectOrgTitle": "Вход в организация.", + "orgAuthSelectOrgDescription": "Въведете идентификатора на вашата организация, за да продължите.", + "orgAuthOrgIdPlaceholder": "вашата-организация", + "orgAuthOrgIdHelp": "Въведете уникалния идентификатор на вашата организация.", + "orgAuthSelectOrgHelp": "След като въведете идентификатора на организацията си, ще бъдете насочени към страницата за вход на вашата организация, където можете да използвате SSO или вашите организационни удостоверения.", + "orgAuthRememberOrgId": "Запомнете този идентификатор на организацията.", + "orgAuthBackToSignIn": "Назад към стандартния вход.", + "orgAuthNoAccount": "Нямате профил?", "subscriptionRequiredToUse": "Необходим е абонамент, за да използвате тази функция.", + "mustUpgradeToUse": "Трябва да повишите своя абонамент, за да използвате тази функция.", + "subscriptionRequiredTierToUse": "Тази функция изисква {tier} или по-висок план.", + "upgradeToTierToUse": "Повишете до {tier} или по-висок план, за да използвате тази функция.", + "subscriptionTierTier1": "Домашен", + "subscriptionTierTier2": "Екип", + "subscriptionTierTier3": "Бизнес", + "subscriptionTierEnterprise": "Предприятие", "idpDisabled": "Доставчиците на идентичност са деактивирани.", "orgAuthPageDisabled": "Страницата за удостоверяване на организацията е деактивирана.", "domainRestartedDescription": "Проверка на домейна е рестартирана успешно", @@ -1850,6 +2046,8 @@ "enableTwoFactorAuthentication": "Активирайте двуфакторното удостоверяване", "completeSecuritySteps": "Завършете стъпките за сигурност", "securitySettings": "Настройки за сигурност", + "dangerSection": "Зона на опасност.", + "dangerSectionDescription": "Премахване на всички данни, свързани с тази организация.", "securitySettingsDescription": "Конфигурирайте политики за сигурност за организацията", "requireTwoFactorForAllUsers": "Изисквайте двуфакторно удостоверяване за всички потребители", "requireTwoFactorDescription": "Когато е активирано, всички вътрешни потребители в организацията трябва да имат активирано двуфакторно удостоверяване, за да имат достъп до организацията.", @@ -1887,7 +2085,7 @@ "securityPolicyChangeWarningText": "Това ще засегне всички потребители в организацията", "authPageErrorUpdateMessage": "Възникна грешка при актуализирането на настройките на страницата за удостоверяване", "authPageErrorUpdate": "Неуспешно актуализиране на страницата за удостоверяване", - "authPageUpdated": "Страницата за удостоверяване е актуализирана успешно", + "authPageDomainUpdated": "Домейнът на страницата за автентификация е актуализиран успешно.", "healthCheckNotAvailable": "Локална", "rewritePath": "Пренапиши път", "rewritePathDescription": "По избор пренапиши пътя преди пренасочване към целта.", @@ -1915,8 +2113,15 @@ "beta": "Бета", "manageUserDevices": "Потребителски устройства", "manageUserDevicesDescription": "Прегледайте и управлявайте устройства, които потребителите използват за поверително свързване към ресурси", + "downloadClientBannerTitle": "Изтеглете Pangolin клиент.", + "downloadClientBannerDescription": "Изтеглете Pangolin клиента за вашата система, за да се свържете към мрежата Pangolin и да получите достъп до ресурси частно.", "manageMachineClients": "Управлявайте машинни клиенти", "manageMachineClientsDescription": "Създавайте и управлявайте клиенти, които сървърите и системите използват за поверително свързване към ресурси", + "machineClientsBannerTitle": "Сървъри и автоматизирани системи.", + "machineClientsBannerDescription": "Машинните клиенти са за сървъри и автоматизирани системи, които не са свързани с конкретен потребител. Те се автентифицират с ID и секретен ключ и могат да работят с Pangolin CLI, Olm CLI или Olm като контейнер.", + "machineClientsBannerPangolinCLI": "Pangolin CLI.", + "machineClientsBannerOlmCLI": "Olm CLI.", + "machineClientsBannerOlmContainer": "Olm Контейнер.", "clientsTableUserClients": "Потребител", "clientsTableMachineClients": "Машина", "licenseTableValidUntil": "Валиден до", @@ -2015,6 +2220,32 @@ } } }, + "newPricingLicenseForm": { + "title": "Получаване на лиценз", + "description": "Изберете план и ни кажете как планирате да използвате Pangolin.", + "chooseTier": "Изберете вашия план", + "viewPricingLink": "Вижте цените, функциите и ограниченията", + "tiers": { + "starter": { + "title": "Стартов", + "description": "Предприятие, 25 потребители, 25 сайта и общностна поддръжка." + }, + "scale": { + "title": "Скала", + "description": "Предприятие, 50 потребители, 50 сайта и приоритетна поддръжка." + } + }, + "personalUseOnly": "Само за лична употреба (безплатен лиценз — без плащане)", + "buttons": { + "continueToCheckout": "Продължете към плащане" + }, + "toasts": { + "checkoutError": { + "title": "Грешка при плащането", + "description": "Не можа да се започне плащането. Моля, опитайте отново." + } + } + }, "priority": "Приоритет", "priorityDescription": "По-високите приоритетни маршрути се оценяват първи. Приоритет = 100 означава автоматично подреждане (системата решава). Използвайте друго число, за да наложите ръчен приоритет.", "instanceName": "Име на инстанция", @@ -2060,13 +2291,15 @@ "request": "Изискване", "requests": "Заявки", "logs": "Логове", - "logsSettingsDescription": "Следете логовете, събрани от тази организация", + "logsSettingsDescription": "Мониторинг на събраните от тази организация дневници.", "searchLogs": "Търсете в логовете...", "action": "Действие", "actor": "Извършващ", "timestamp": "Отбелязано време", "accessLogs": "Достъп до логове", "exportCsv": "Експортиране в CSV", + "exportError": "Неизвестна грешка при експортиране на CSV.", + "exportCsvTooltip": "В рамките на времевия диапазон.", "actorId": "ID на извършващия", "allowedByRule": "Разрешено от правило", "allowedNoAuth": "Разрешено без удостоверение", @@ -2111,7 +2344,8 @@ "logRetentionEndOfFollowingYear": "Край на следващата година", "actionLogsDescription": "Прегледайте историята на действията, извършени в тази организация", "accessLogsDescription": "Прегледайте заявките за удостоверяване на достъпа до ресурсите в тази организация", - "licenseRequiredToUse": "Необходим е лиценз Enterprise, за да се използва тази функция.", + "licenseRequiredToUse": "Изисква се лиценз за Enterprise Edition или Pangolin Cloud за използване на тази функция. Резервирайте демонстрация или пробен POC.", + "ossEnterpriseEditionRequired": "Enterprise Edition е необходим за използване на тази функция. Тази функция също е налична в Pangolin Cloud. Резервирайте демонстрация или пробен POC.", "certResolver": "Решавач на сертификати", "certResolverDescription": "Изберете решавач на сертификати за използване за този ресурс.", "selectCertResolver": "Изберете решавач на сертификати", @@ -2120,7 +2354,7 @@ "unverified": "Невалидиран", "domainSetting": "Настройки на домейните", "domainSettingDescription": "Конфигурирайте настройките за домейна", - "preferWildcardCertDescription": "Опит за генериране на универсален сертификат (изисква правилно конфигуриран решавач на сертификати).", + "preferWildcardCertDescription": "Опитайте да генерирате универсален сертификат (изисква правилно конфигуриран разрешител на сертификати).", "recordName": "Име на запис", "auto": "Автоматично", "TTL": "TTL", @@ -2172,6 +2406,8 @@ "deviceCodeInvalidFormat": "Кодът трябва да бъде 9 символа (напр. A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Невалиден или изтекъл код", "deviceCodeVerifyFailed": "Неуспешна проверка на кода на устройството", + "deviceCodeValidating": "Валидиране на кода на устройството...", + "deviceCodeVerifying": "Проверка на оторизацията на устройството...", "signedInAs": "Вписан като", "deviceCodeEnterPrompt": "Въведете кода, показан на устройството", "continue": "Продължете", @@ -2184,7 +2420,7 @@ "deviceOrganizationsAccess": "Достъп до всички организации, до които има достъп акаунтът ви", "deviceAuthorize": "Разрешете {applicationName}", "deviceConnected": "Устройството е свързано!", - "deviceAuthorizedMessage": "Устройството е разрешено да има достъп до вашия акаунт.", + "deviceAuthorizedMessage": "Устройството е оторизирано да има достъп до акаунта ви. Моля, върнете се към клиентското приложение.", "pangolinCloud": "Pangolin Cloud", "viewDevices": "Преглед на устройствата", "viewDevicesDescription": "Управлявайте свързаните си устройства", @@ -2246,6 +2482,7 @@ "identifier": "Идентификатор", "deviceLoginUseDifferentAccount": "Не сте вие? Използвайте друг акаунт.", "deviceLoginDeviceRequestingAccessToAccount": "Устройство запитващо достъп до този акаунт.", + "loginSelectAuthenticationMethod": "Изберете метод на удостоверяване, за да продължите.", "noData": "Няма Данни", "machineClients": "Машинни клиенти", "install": "Инсталирай", @@ -2255,6 +2492,8 @@ "setupFailedToFetchSubnet": "Неуспешно извличане на подмрежа по подразбиране", "setupSubnetAdvanced": "Подмрежа (Разширено)", "setupSubnetDescription": "Подмрежата за вътрешната мрежа на тази организация.", + "setupUtilitySubnet": "Помощен подсегмент (Напреднало).", + "setupUtilitySubnetDescription": "Подсегментът за псевдонимите на тази организация и DNS сървъра.", "siteRegenerateAndDisconnect": "Генериране и прекъсване на връзката", "siteRegenerateAndDisconnectConfirmation": "Сигурни ли сте, че искате да генерирате нови удостоверителни данни и да прекъснете тази връзка?", "siteRegenerateAndDisconnectWarning": "Това ще генерира нови удостоверителни данни и незабавно ще прекъсне връзката. На сайта ще трябва да се рестартира с новите удостоверителни данни.", @@ -2270,5 +2509,179 @@ "remoteExitNodeRegenerateAndDisconnectWarning": "Това ще генерира нови удостоверителни данни и незабавно ще прекъсне връзката на отдалечения възел. Отдалеченият възел ще трябва да се рестартира с новите удостоверителни данни.", "remoteExitNodeRegenerateCredentialsConfirmation": "Сигурни ли сте, че искате да генерирате новите удостоверителни данни за този отдалечен възел?", "remoteExitNodeRegenerateCredentialsWarning": "Това ще генерира нови удостоверителни данни. Отдалеченият възел ще остане свързан, докато не го рестартирате ръчно и използвате новите удостоверителни данни.", - "agent": "Агент" + "agent": "Агент", + "personalUseOnly": "Само за лична употреба.", + "loginPageLicenseWatermark": "Тази инстанция е лицензирана само за лична употреба.", + "instanceIsUnlicensed": "Тази инстанция е без лиценз.", + "portRestrictions": "Ограничения на портовете.", + "allPorts": "Всички.", + "custom": "Персонализирано.", + "allPortsAllowed": "Всички портове са разрешени.", + "allPortsBlocked": "Всички портове са блокирани.", + "tcpPortsDescription": "Посочете кои TCP портове са разрешени за този ресурс. Използвайте '*' за всички портове, оставете празно, за да блокирате всички, или въведете списък от отделени с запетая портове и диапазони (например: 80,443, 8000-9000).", + "udpPortsDescription": "Посочете кои UDP портове са разрешени за този ресурс. Използвайте '*' за всички портове, оставете празно, за да блокирате всички, или въведете списък от отделени с запетая портове и диапазони (например: 53,123, 500-600).", + "organizationLoginPageTitle": "Страница за вход на организацията.", + "organizationLoginPageDescription": "Персонализирайте страницата за влизане за тази организация.", + "resourceLoginPageTitle": "Страница за вход на ресурса.", + "resourceLoginPageDescription": "Персонализирайте страницата за вход за конкретни ресурси.", + "enterConfirmation": "Въведете потвърждение.", + "blueprintViewDetails": "Подробности.", + "defaultIdentityProvider": "По подразбиране доставчик на идентичност.", + "defaultIdentityProviderDescription": "Когато е избран основен доставчик на идентичност, потребителят ще бъде автоматично пренасочен към доставчика за удостоверяване.", + "editInternalResourceDialogNetworkSettings": "Мрежови настройки.", + "editInternalResourceDialogAccessPolicy": "Политика за достъп.", + "editInternalResourceDialogAddRoles": "Добавяне на роли.", + "editInternalResourceDialogAddUsers": "Добавяне на потребители.", + "editInternalResourceDialogAddClients": "Добавяне на клиенти.", + "editInternalResourceDialogDestinationLabel": "Дестинация.", + "editInternalResourceDialogDestinationDescription": "Посочете адреса дестинация за вътрешния ресурс. Това може да бъде име на хост, IP адрес или CIDR обхват в зависимост от избрания режим. По избор настройте вътрешен DNS алиас за по-лесно идентифициране.", + "editInternalResourceDialogPortRestrictionsDescription": "Ограничете достъпа до конкретни TCP/UDP портове или позволете/блокирайте всички портове.", + "editInternalResourceDialogTcp": "TCP.", + "editInternalResourceDialogUdp": "UDP.", + "editInternalResourceDialogIcmp": "ICMP.", + "editInternalResourceDialogAccessControl": "Контрол на достъпа.", + "editInternalResourceDialogAccessControlDescription": "Контролирайте кои роли, потребители и клиентски машини имат достъп до този ресурс, когато са свързани. Администраторите винаги имат достъп.", + "editInternalResourceDialogPortRangeValidationError": "Обхватът на портовете трябва да е \"*\" за всички портове или списък от разделени със запетая портове и диапазони (например: \"80,443,8000-9000\"). Портовете трябва да са между 1 и 65535.", + "internalResourceAuthDaemonStrategy": "Локация на SSH Auth Daemon", + "internalResourceAuthDaemonStrategyDescription": "Изберете къде ще работи демонът за SSH удостоверение: на сайта (Newt) или на отдалечен хост.", + "internalResourceAuthDaemonDescription": "Демонът за SSH удостоверение управлява подписването на SSH ключове и PAM удостоверение за този ресурс. Изберете дали да работи на сайта (Newt) или на отделен отдалечен хост. Вижте документацията за повече информация.", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "Изберете стратегия", + "internalResourceAuthDaemonStrategyLabel": "Местоположение", + "internalResourceAuthDaemonSite": "На сайта", + "internalResourceAuthDaemonSiteDescription": "Демонът за удостоверение работи на сайта (Newt).", + "internalResourceAuthDaemonRemote": "Отдалечен хост", + "internalResourceAuthDaemonRemoteDescription": "Демонът за удостоверение работи на хост, който не е сайтът.", + "internalResourceAuthDaemonPort": "Порт на демона (незадължителен)", + "orgAuthWhatsThis": "Къде мога да намеря идентификатора на организацията си?", + "learnMore": "Научете повече.", + "backToHome": "Връщане към началната страница.", + "needToSignInToOrg": "Трябва ли да използвате доставчика на идентичност на организацията си?", + "maintenanceMode": "Режим на поддръжка.", + "maintenanceModeDescription": "Показване на страницата за поддръжка на посетители.", + "maintenanceModeType": "Тип режим на поддръжка.", + "showMaintenancePage": "Показване на страницата за поддръжка на посетители.", + "enableMaintenanceMode": "Активиране на режим на поддръжка.", + "automatic": "Автоматично.", + "automaticModeDescription": "Показване на страницата за поддръжка само когато всички целеви подсистеми са неработоспособни или в лошо състояние. Вашият ресурс продължава да работи нормално, докато поне един целеви подсистемен елемент е в здравия диапазон.", + "forced": "Наложително.", + "forcedModeDescription": "Винаги показвайте страницата за поддръжка, без значение от състоянието на подсистемите. Използвайте това за планирана поддръжка, когато искате да предотвратите всякакъв достъпен достъп.", + "warning:": "Предупреждение:", + "forcedeModeWarning": "Целият трафик ще бъде пренасочен към страницата за поддръжка. Вашите подсистемни ресурси няма да получат никакви заявки.", + "pageTitle": "Заглавие на страницата.", + "pageTitleDescription": "Основното заглавие, показвано на страницата за поддръжка.", + "maintenancePageMessage": "Съобщение за поддръжка.", + "maintenancePageMessagePlaceholder": "Ще се върнем скоро! Нашият сайт понастоящем е в процес на планирана поддръжка.", + "maintenancePageMessageDescription": "Подробно съобщение, обясняващо поддръжката.", + "maintenancePageTimeTitle": "Очаквано време за завършване (по избор).", + "maintenanceTime": "например, 2 часа, 1 ноември в 17:00.", + "maintenanceEstimatedTimeDescription": "Кога очаквате поддръжката да бъде завършена?", + "editDomain": "Редактиране на домейна.", + "editDomainDescription": "Изберете домейн за вашия ресурс.", + "maintenanceModeDisabledTooltip": "Тази функция изисква валиден лиценз за активиране.", + "maintenanceScreenTitle": "Услугата временно недостъпна.", + "maintenanceScreenMessage": "В момента срещаме технически затруднения. Моля, проверете отново скоро.", + "maintenanceScreenEstimatedCompletion": "Прогнозно завършване:", + "createInternalResourceDialogDestinationRequired": "Дестинацията е задължителна.", + "available": "Налично", + "archived": "Архивирано", + "noArchivedDevices": "Не са намерени архивирани устройства.", + "deviceArchived": "Устройството е архивирано.", + "deviceArchivedDescription": "Устройството беше успешно архивирано.", + "errorArchivingDevice": "Грешка при архивиране на устройството.", + "failedToArchiveDevice": "Неуспех при архивиране на устройството.", + "deviceQuestionArchive": "Сигурни ли сте, че искате да архивирате това устройство?", + "deviceMessageArchive": "Устройството ще бъде архивирано и премахнато от вашия списък с активни устройства.", + "deviceArchiveConfirm": "Архивиране на устройството", + "archiveDevice": "Архивиране на устройство", + "archive": "Архив", + "deviceUnarchived": "Устройството е разархивирано.", + "deviceUnarchivedDescription": "Устройството беше успешно разархивирано.", + "errorUnarchivingDevice": "Грешка при разархивиране на устройството.", + "failedToUnarchiveDevice": "Неуспешно разархивиране на устройството.", + "unarchive": "Разархивиране", + "archiveClient": "Архивиране на клиента", + "archiveClientQuestion": "Сигурни ли сте, че искате да архивирате този клиент?", + "archiveClientMessage": "Клиентът ще бъде архивиран и премахнат от вашия списък с активни клиенти.", + "archiveClientConfirm": "Архивиране на клиента", + "blockClient": "Блокиране на клиента", + "blockClientQuestion": "Сигурни ли сте, че искате да блокирате този клиент?", + "blockClientMessage": "Устройството ще бъде принудено да прекъсне, ако е в момента свързано. Можете да го отблокирате по-късно.", + "blockClientConfirm": "Блокиране на клиента", + "active": "Активно", + "usernameOrEmail": "Потребителско име или имейл", + "selectYourOrganization": "Изберете вашата организация", + "signInTo": "Влезте в", + "signInWithPassword": "Продължете с парола", + "noAuthMethodsAvailable": "Няма налични методи за удостоверяване за тази организация.", + "enterPassword": "Въведете вашата парола", + "enterMfaCode": "Въведете кода от вашето приложение за удостоверяване", + "securityKeyRequired": "Моля, използвайте ключа за сигурност, за да влезете.", + "needToUseAnotherAccount": "Трябва ли да използвате различен акаунт?", + "loginLegalDisclaimer": "С натискането на бутоните по-долу, потвърждавате, че сте прочели, разбирате и се съгласявате с Условията за ползване и Политиката за поверителност.", + "termsOfService": "Условия за ползване", + "privacyPolicy": "Политика за поверителност", + "userNotFoundWithUsername": "Не е намерен потребител с това потребителско име.", + "verify": "Потвърждение", + "signIn": "Вход", + "forgotPassword": "Забравена парола?", + "orgSignInTip": "Ако сте влизали преди, можете да въведете вашето потребителско име или имейл по-горе, за да се удостовери с идентификатора на вашата организация. Лесно е!", + "continueAnyway": "Продължете въпреки това", + "dontShowAgain": "Не показвайте повече", + "orgSignInNotice": "Знаете ли?", + "signupOrgNotice": "Опитвате се да влезете?", + "signupOrgTip": "Опитвате ли се да влезете чрез идентификационния доставчик на вашата организация?", + "signupOrgLink": "Влезте или се регистрирайте с вашата организация вместо това.", + "verifyEmailLogInWithDifferentAccount": "Използвайте различен акаунт", + "logIn": "Вход", + "deviceInformation": "Информация за устройството", + "deviceInformationDescription": "Информация за устройството и агента", + "deviceSecurity": "Защита на устройството.", + "deviceSecurityDescription": "Информация за състоянието на защитата на устройството.", + "platform": "Платформа", + "macosVersion": "Версия на macOS", + "windowsVersion": "Версия на Windows", + "iosVersion": "Версия на iOS", + "androidVersion": "Версия на Android", + "osVersion": "Версия на ОС", + "kernelVersion": "Версия на ядрото", + "deviceModel": "Модел на устройството", + "serialNumber": "Сериен номер", + "hostname": "Име на хост", + "firstSeen": "Видян за първи път", + "lastSeen": "Последно видян", + "biometricsEnabled": "Активирани биометрични данни.", + "diskEncrypted": "Криптиран диск.", + "firewallEnabled": "Активирана защитна стена.", + "autoUpdatesEnabled": "Активирани автоматични актуализации.", + "tpmAvailable": "TPM е на разположение.", + "windowsAntivirusEnabled": "Активирана антивирусна програма", + "macosSipEnabled": "Protection на системната цялост (SIP).", + "macosGatekeeperEnabled": "Gatekeeper.", + "macosFirewallStealthMode": "Скрит режим на защитната стена.", + "linuxAppArmorEnabled": "AppArmor.", + "linuxSELinuxEnabled": "SELinux.", + "deviceSettingsDescription": "Разгледайте информация и настройки на устройството", + "devicePendingApprovalDescription": "Това устройство чака одобрение", + "deviceBlockedDescription": "Това устройство е в момента блокирано. Няма да може да се свърже с никакви ресурси, освен ако не бъде деблокирано.", + "unblockClient": "Деблокирайте клиента", + "unblockClientDescription": "Устройството е деблокирано", + "unarchiveClient": "Разархивиране на клиента", + "unarchiveClientDescription": "Устройството е разархивирано", + "block": "Блокирането", + "unblock": "Деблокиране", + "deviceActions": "Действия с устройствата", + "deviceActionsDescription": "Управлявайте състоянието и достъпа на устройството", + "devicePendingApprovalBannerDescription": "Това устройство чака одобрение. Няма да може да се свърже с ресурси, докато не бъде одобрено.", + "connected": "Свързан", + "disconnected": "Прекъснат", + "approvalsEmptyStateTitle": "Одобрения на устройство не са активирани", + "approvalsEmptyStateDescription": "Активирайте одобрения на устройства за роли, така че да изискват администраторско одобрение, преди потребителите да могат да свързват нови устройства.", + "approvalsEmptyStateStep1Title": "Отидете на роли", + "approvalsEmptyStateStep1Description": "Навигирайте до настройките на ролите на вашата организация, за да конфигурирате одобренията на устройства.", + "approvalsEmptyStateStep2Title": "Активирайте одобрения на устройства", + "approvalsEmptyStateStep2Description": "Редактирайте ролята и активирайте опцията 'Изискване на одобрения за устройства'. Потребители с тази роля ще трябва администраторско одобрение за нови устройства.", + "approvalsEmptyStatePreviewDescription": "Преглед: Когато е активирано, чакащите заявки за устройства ще се появят тук за преглед", + "approvalsEmptyStateButtonText": "Управлявайте роли", + "domainErrorTitle": "Имаме проблем с проверката на вашия домейн" } diff --git a/messages/cs-CZ.json b/messages/cs-CZ.json index be3d22329..ea12fe535 100644 --- a/messages/cs-CZ.json +++ b/messages/cs-CZ.json @@ -1,5 +1,7 @@ { "setupCreate": "Vytvořte organizaci, stránku a zdroje", + "headerAuthCompatibilityInfo": "Povolte toto, aby vyvolalo odpověď 401 Neoprávněné, když chybí autentizační token. Toto je potřeba pro prohlížeče nebo specifické HTTP knihovny, které neposílají přihlašovací údaje bez výzvy serveru.", + "headerAuthCompatibility": "Rozšířená kompatibilita", "setupNewOrg": "Nová organizace", "setupCreateOrg": "Vytvořit organizaci", "setupCreateResources": "Vytvořit zdroje", @@ -16,6 +18,8 @@ "componentsMember": "Jste členem {count, plural, =0 {0 organizací} one {1 organizace} other {# organizací}}.", "componentsInvalidKey": "Byly nalezeny neplatné nebo propadlé licenční klíče. Pokud chcete nadále používat všechny funkce, postupujte podle licenčních podmínek.", "dismiss": "Zavřít", + "subscriptionViolationMessage": "Jste za hranicemi vašeho aktuálního plánu. Opravte problém odstraněním webů, uživatelů nebo jiných zdrojů, abyste zůstali ve vašem tarifu.", + "subscriptionViolationViewBilling": "Zobrazit fakturaci", "componentsLicenseViolation": "Porušení licenčních podmínek: Tento server používá {usedSites} stránek, což překračuje limit {maxSites} licencovaných stránek. Pokud chcete nadále používat všechny funkce, postupujte podle licenčních podmínek.", "componentsSupporterMessage": "Děkujeme, že podporujete Pangolin jako {tier}!", "inviteErrorNotValid": "Je nám líto, ale vypadá to, že pozvánka, ke které se snažíte získat přístup, nebyla přijata nebo již není platná.", @@ -51,6 +55,12 @@ "siteQuestionRemove": "Jste si jisti, že chcete odstranit tuto stránku z organizace?", "siteManageSites": "Správa lokalit", "siteDescription": "Vytvořte a spravujte stránky pro povolení připojení k soukromým sítím", + "sitesBannerTitle": "Připojit jakoukoli síť", + "sitesBannerDescription": "Lokalita je připojení k vzdálené síti, která umožňuje Pangolinu poskytovat přístup k prostředkům, ať už veřejným nebo soukromým, uživatelům kdekoli. Nainstalujte síťový konektor (Newt) kamkoli, kam můžete spustit binární soubor nebo kontejner, aby bylo možné připojení navázat.", + "sitesBannerButtonText": "Nainstalovat lokalitu", + "approvalsBannerTitle": "Schválit nebo zakázat přístup k zařízení", + "approvalsBannerDescription": "Zkontrolovat a schválit nebo zakázat žádosti uživatelů o přístup k zařízení. Pokud jsou vyžadována schválení zařízení, musí být uživatelé oprávněni před tím, než se jejich zařízení mohou připojit k zdrojům vaší organizace.", + "approvalsBannerButtonText": "Zjistit více", "siteCreate": "Vytvořit lokalitu", "siteCreateDescription2": "Postupujte podle níže uvedených kroků, abyste vytvořili a připojili novou lokalitu", "siteCreateDescription": "Vytvořit nový web pro zahájení připojování zdrojů", @@ -100,6 +110,7 @@ "siteTunnelDescription": "Určete, jak se chcete připojit k webu", "siteNewtCredentials": "Pověření", "siteNewtCredentialsDescription": "Takto se bude stránka autentizovat se serverem", + "remoteNodeCredentialsDescription": "Takto se vzdálený uzel autentizuje s serverem", "siteCredentialsSave": "Uložit pověření", "siteCredentialsSaveDescription": "Toto nastavení uvidíte pouze jednou. Ujistěte se, že jej zkopírujete na bezpečné místo.", "siteInfo": "Údaje o lokalitě", @@ -146,8 +157,12 @@ "shareErrorSelectResource": "Zvolte prosím zdroj", "proxyResourceTitle": "Spravovat veřejné zdroje", "proxyResourceDescription": "Vytváření a správa zdrojů, které jsou veřejně přístupné prostřednictvím webového prohlížeče", + "proxyResourcesBannerTitle": "Veřejný přístup založený na webu", + "proxyResourcesBannerDescription": "Veřejné prostředky jsou HTTPS nebo TCP/UDP proxy, které jsou přístupné každému na internetu prostřednictvím webového prohlížeče. Na rozdíl od soukromých prostředků nevyžadují software na straně klienta a mohou zahrnovat politiky přístupu orientované na identitu a kontext.", "clientResourceTitle": "Spravovat soukromé zdroje", "clientResourceDescription": "Vytváření a správa zdrojů, které jsou přístupné pouze prostřednictvím připojeného klienta", + "privateResourcesBannerTitle": "Zero-Trust soukromý přístup", + "privateResourcesBannerDescription": "Soukromé prostředky používají zero-trust zabezpečení, což zajišťuje, že uživatelé a zařízení mohou získat přístup pouze k prostředkům, k nimž máte explicitní práva. Připojte zařízení uživatele nebo klientské stroje, abyste získali přístup k těmto prostředkům přes zabezpečenou virtuální soukromou síť.", "resourcesSearch": "Prohledat zdroje...", "resourceAdd": "Přidat zdroj", "resourceErrorDelte": "Chyba při odstraňování zdroje", @@ -157,9 +172,10 @@ "resourceMessageRemove": "Jakmile zdroj odstraníte, nebude dostupný. Všechny související služby a cíle budou také odstraněny.", "resourceQuestionRemove": "Jste si jisti, že chcete odstranit zdroj z organizace?", "resourceHTTP": "Zdroj HTTPS", - "resourceHTTPDescription": "Požadavky na proxy pro aplikaci přes HTTPS pomocí subdomény nebo základní domény.", + "resourceHTTPDescription": "Proxy požadavky přes HTTPS pomocí plně kvalifikovaného názvu domény.", "resourceRaw": "Surový TCP/UDP zdroj", - "resourceRawDescription": "Proxy požadavky na aplikaci přes TCP/UDP pomocí čísla portu. To funguje pouze v případě, že jsou stránky připojeny k uzlům.", + "resourceRawDescription": "Proxy požadavky přes nezpracovaný TCP/UDP pomocí čísla portu.", + "resourceRawDescriptionCloud": "Proxy požadavky na syrové TCP/UDP pomocí čísla portu. Vyžaduje připojení stránek ke vzdálenému uzlu.", "resourceCreate": "Vytvořit zdroj", "resourceCreateDescription": "Postupujte podle níže uvedených kroků, abyste vytvořili a připojili nový zdroj", "resourceSeeAll": "Zobrazit všechny zdroje", @@ -186,6 +202,7 @@ "protocolSelect": "Vybrat protokol", "resourcePortNumber": "Číslo portu", "resourcePortNumberDescription": "Externí port k požadavkům proxy serveru.", + "back": "Zpět", "cancel": "Zrušit", "resourceConfig": "Konfigurační snippety", "resourceConfigDescription": "Zkopírujte a vložte tyto konfigurační textové bloky pro nastavení TCP/UDP zdroje", @@ -231,6 +248,17 @@ "orgErrorDeleteMessage": "Došlo k chybě při odstraňování organizace.", "orgDeleted": "Organizace odstraněna", "orgDeletedMessage": "Organizace a její data byla smazána.", + "deleteAccount": "Odstranit účet", + "deleteAccountDescription": "Trvale smazat svůj účet, všechny organizace, které vlastníte, a všechna data těchto organizací. Tuto akci nelze vrátit zpět.", + "deleteAccountButton": "Odstranit účet", + "deleteAccountConfirmTitle": "Odstranit účet", + "deleteAccountConfirmMessage": "Toto trvale vymaže váš účet, všechny organizace, které vlastníte, a všechna data v rámci těchto organizací. Tuto akci nelze vrátit zpět.", + "deleteAccountConfirmString": "smazat účet", + "deleteAccountSuccess": "Účet odstraněn", + "deleteAccountSuccessMessage": "Váš účet byl odstraněn.", + "deleteAccountError": "Nepodařilo se odstranit účet", + "deleteAccountPreviewAccount": "Váš účet", + "deleteAccountPreviewOrgs": "Organizace, které vlastníte (a všechny jejich údaje)", "orgMissing": "Chybí ID organizace", "orgMissingMessage": "Nelze obnovit pozvánku bez ID organizace.", "accessUsersManage": "Spravovat uživatele", @@ -247,6 +275,8 @@ "accessRolesSearch": "Hledat role...", "accessRolesAdd": "Přidat roli", "accessRoleDelete": "Odstranit roli", + "accessApprovalsManage": "Spravovat schválení", + "accessApprovalsDescription": "Zobrazit a spravovat čekající oprávnění pro přístup k této organizaci", "description": "L 343, 22.12.2009, s. 1).", "inviteTitle": "Otevřít pozvánky", "inviteDescription": "Spravovat pozvánky pro ostatní uživatele do organizace", @@ -307,7 +337,7 @@ "userQuestionRemove": "Jste si jisti, že chcete trvale odstranit uživatele ze serveru?", "licenseKey": "Licenční klíč", "valid": "Valid", - "numberOfSites": "Počet stránek", + "numberOfSites": "Počet lokalit", "licenseKeySearch": "Hledat licenční klíče...", "licenseKeyAdd": "Přidat licenční klíč", "type": "Typ", @@ -440,6 +470,20 @@ "selectDuration": "Vyberte dobu trvání", "selectResource": "Vybrat dokument", "filterByResource": "Filtrovat podle zdroje", + "selectApprovalState": "Vyberte stát schválení", + "filterByApprovalState": "Filtrovat podle státu schválení", + "approvalListEmpty": "Žádná schválení", + "approvalState": "Země schválení", + "approvalLoadMore": "Načíst více", + "loadingApprovals": "Načítání schválení", + "approve": "Schválit", + "approved": "Schváleno", + "denied": "Zamítnuto", + "deniedApproval": "Odmítnuto schválení", + "all": "Vše", + "deny": "Zamítnout", + "viewDetails": "Zobrazit detaily", + "requestingNewDeviceApproval": "vyžádal si nové zařízení", "resetFilters": "Resetovat filtry", "totalBlocked": "Požadavky blokovány Pangolinem", "totalRequests": "Celkem požadavků", @@ -607,6 +651,7 @@ "resourcesErrorUpdate": "Nepodařilo se přepnout zdroj", "resourcesErrorUpdateDescription": "Došlo k chybě při aktualizaci zdroje", "access": "Přístup", + "accessControl": "Kontrola přístupu", "shareLink": "{resource} Sdílet odkaz", "resourceSelect": "Vyberte zdroj", "shareLinks": "Sdílet odkazy", @@ -687,7 +732,7 @@ "resourceRoleDescription": "Administrátoři mají vždy přístup k tomuto zdroji.", "resourceUsersRoles": "Kontrola přístupu", "resourceUsersRolesDescription": "Nastavení, kteří uživatelé a role mohou navštívit tento zdroj", - "resourceUsersRolesSubmit": "Uložit uživatele a role", + "resourceUsersRolesSubmit": "Uložit přístupové řízení", "resourceWhitelistSave": "Úspěšně uloženo", "resourceWhitelistSaveDescription": "Nastavení seznamu povolených položek bylo uloženo", "ssoUse": "Použít platformu SSO", @@ -719,22 +764,35 @@ "countries": "Země", "accessRoleCreate": "Vytvořit roli", "accessRoleCreateDescription": "Vytvořte novou roli pro seskupení uživatelů a spravujte jejich oprávnění.", + "accessRoleEdit": "Upravit roli", + "accessRoleEditDescription": "Upravit informace o roli.", "accessRoleCreateSubmit": "Vytvořit roli", "accessRoleCreated": "Role vytvořena", "accessRoleCreatedDescription": "Role byla úspěšně vytvořena.", "accessRoleErrorCreate": "Nepodařilo se vytvořit roli", "accessRoleErrorCreateDescription": "Došlo k chybě při vytváření role.", + "accessRoleUpdateSubmit": "Aktualizovat roli", + "accessRoleUpdated": "Role aktualizována", + "accessRoleUpdatedDescription": "Role byla úspěšně aktualizována.", + "accessApprovalUpdated": "Zpracovaná schválení", + "accessApprovalApprovedDescription": "Nastavit rozhodnutí o schválení žádosti o schválení.", + "accessApprovalDeniedDescription": "Nastavit žádost o schválení rozhodnutí o zamítnutí.", + "accessRoleErrorUpdate": "Nepodařilo se aktualizovat roli", + "accessRoleErrorUpdateDescription": "Došlo k chybě při aktualizaci role.", + "accessApprovalErrorUpdate": "Zpracování schválení se nezdařilo", + "accessApprovalErrorUpdateDescription": "Při zpracování schválení došlo k chybě.", "accessRoleErrorNewRequired": "Je vyžadována nová role", "accessRoleErrorRemove": "Nepodařilo se odstranit roli", "accessRoleErrorRemoveDescription": "Došlo k chybě při odstraňování role.", "accessRoleName": "Název role", - "accessRoleQuestionRemove": "Chystáte se odstranit {name} roli. Tuto akci nelze vrátit zpět.", + "accessRoleQuestionRemove": "Chystáte se odstranit roli `{name}`. Tuto akci nelze vrátit zpět.", "accessRoleRemove": "Odstranit roli", "accessRoleRemoveDescription": "Odebrat roli z organizace", "accessRoleRemoveSubmit": "Odstranit roli", "accessRoleRemoved": "Role odstraněna", "accessRoleRemovedDescription": "Role byla úspěšně odstraněna.", "accessRoleRequiredRemove": "Před odstraněním této role vyberte novou roli, do které chcete převést existující členy.", + "network": "Síť", "manage": "Spravovat", "sitesNotFound": "Nebyly nalezeny žádné stránky.", "pangolinServerAdmin": "Správce serveru - Pangolin", @@ -750,6 +808,9 @@ "sitestCountIncrease": "Zvýšit počet stránek", "idpManage": "Spravovat poskytovatele identity", "idpManageDescription": "Zobrazit a spravovat poskytovatele identity v systému", + "idpGlobalModeBanner": "Poskytovatelé identity (IdP) pro každou organizaci jsou na tomto serveru zakázáni. Používá globální IdP (sdílené napříč všemi organizacemi). Správa globálních IdP v admin panelu. Chcete-li povolit IdP pro každou organizaci, upravte konfiguraci serveru a nastavte IdP režim na org. Viz dokumentace. Pokud chcete pokračovat v používání globálních IdP a zmizet z nastavení organizace, explicitně nastavte režim na globální v konfiguraci.", + "idpGlobalModeBannerUpgradeRequired": "Poskytovatelé identity (IdP) pro každou organizaci jsou na tomto serveru zakázáni. Používá globální IdP (sdílené napříč všemi organizacemi). Spravujte globální IdP v admin panelu. Chcete-li použít poskytovatele identity pro každou organizaci, musíte přejít na Enterprise vydání.", + "idpGlobalModeBannerLicenseRequired": "Poskytovatelé identity (IdP) pro každou organizaci jsou na tomto serveru zakázáni. Používá globální IdP (sdílené napříč všemi organizacemi). Správa globálních IdP v admin panelu. Chcete-li použít poskytovatele identity pro každou organizaci, je vyžadována Enterprise licence.", "idpDeletedDescription": "Poskytovatel identity byl úspěšně odstraněn", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Jste si jisti, že chcete trvale odstranit poskytovatele identity?", @@ -840,6 +901,7 @@ "orgPolicyConfig": "Konfigurace přístupu pro organizaci", "idpUpdatedDescription": "Poskytovatel identity byl úspěšně aktualizován", "redirectUrl": "Přesměrovat URL", + "orgIdpRedirectUrls": "Přesměrovat URL", "redirectUrlAbout": "O přesměrování URL", "redirectUrlAboutDescription": "Toto je URL, na kterou budou uživatelé po ověření přesměrováni. Tuto URL je třeba nastavit v nastavení poskytovatele identity.", "pangolinAuth": "Auth - Pangolin", @@ -945,11 +1007,11 @@ "pincodeAuth": "Ověřovací kód", "pincodeSubmit2": "Odeslat kód", "passwordResetSubmit": "Žádost o obnovení", - "passwordResetAlreadyHaveCode": "Zadejte kód pro obnovení hesla", + "passwordResetAlreadyHaveCode": "Zadejte kód", "passwordResetSmtpRequired": "Obraťte se na správce", "passwordResetSmtpRequiredDescription": "Pro obnovení hesla je vyžadován kód pro obnovení hesla. Kontaktujte prosím svého administrátora.", "passwordBack": "Zpět na heslo", - "loginBack": "Přejít zpět na přihlášení", + "loginBack": "Přejít zpět na hlavní přihlašovací stránku", "signup": "Zaregistrovat se", "loginStart": "Přihlaste se a začněte", "idpOidcTokenValidating": "Ověřování OIDC tokenu", @@ -972,12 +1034,12 @@ "pangolinSetup": "Setup - Pangolin", "orgNameRequired": "Je vyžadován název organizace", "orgIdRequired": "Je vyžadováno ID organizace", + "orgIdMaxLength": "ID organizace musí mít nejvýše 32 znaků", "orgErrorCreate": "Při vytváření org došlo k chybě", "pageNotFound": "Stránka nenalezena", "pageNotFoundDescription": "Jejda! Stránka, kterou hledáte, neexistuje.", "overview": "Přehled", "home": "Domů", - "accessControl": "Kontrola přístupu", "settings": "Nastavení", "usersAll": "Všichni uživatelé", "license": "Licence", @@ -1035,15 +1097,24 @@ "updateOrgUser": "Aktualizovat Org uživatele", "createOrgUser": "Vytvořit Org uživatele", "actionUpdateOrg": "Aktualizovat organizaci", + "actionRemoveInvitation": "Odstranit pozvání", "actionUpdateUser": "Aktualizovat uživatele", "actionGetUser": "Získat uživatele", "actionGetOrgUser": "Získat uživatele organizace", "actionListOrgDomains": "Seznam domén organizace", + "actionGetDomain": "Získat doménu", + "actionCreateOrgDomain": "Vytvořit doménu", + "actionUpdateOrgDomain": "Aktualizovat doménu", + "actionDeleteOrgDomain": "Odstranit doménu", + "actionGetDNSRecords": "Získat záznamy DNS", + "actionRestartOrgDomain": "Restartovat doménu", "actionCreateSite": "Vytvořit lokalitu", "actionDeleteSite": "Odstranění lokality", "actionGetSite": "Získat web", "actionListSites": "Seznam stránek", "actionApplyBlueprint": "Použít plán", + "actionListBlueprints": "Seznam šablon", + "actionGetBlueprint": "Získat šablonu", "setupToken": "Nastavit token", "setupTokenDescription": "Zadejte nastavovací token z konzole serveru.", "setupTokenRequired": "Je vyžadován token nastavení", @@ -1077,6 +1148,7 @@ "actionRemoveUser": "Odstranit uživatele", "actionListUsers": "Seznam uživatelů", "actionAddUserRole": "Přidat uživatelskou roli", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Generovat přístupový token", "actionDeleteAccessToken": "Odstranit přístupový token", "actionListAccessTokens": "Seznam přístupových tokenů", @@ -1104,6 +1176,10 @@ "actionUpdateIdpOrg": "Aktualizovat IDP Org", "actionCreateClient": "Vytvořit klienta", "actionDeleteClient": "Odstranit klienta", + "actionArchiveClient": "Archivovat klienta", + "actionUnarchiveClient": "Zrušit archiv klienta", + "actionBlockClient": "Blokovat klienta", + "actionUnblockClient": "Odblokovat klienta", "actionUpdateClient": "Aktualizovat klienta", "actionListClients": "Seznam klientů", "actionGetClient": "Získat klienta", @@ -1117,17 +1193,18 @@ "actionViewLogs": "Zobrazit logy", "noneSelected": "Není vybráno", "orgNotFound2": "Nebyly nalezeny žádné organizace.", - "searchProgress": "Hledat...", + "searchPlaceholder": "Hledat...", + "emptySearchOptions": "Nebyly nalezeny žádné možnosti", "create": "Vytvořit", "orgs": "Organizace", - "loginError": "Při přihlášení došlo k chybě", - "loginRequiredForDevice": "Pro ověření vašeho zařízení je nutné se přihlásit.", + "loginError": "Došlo k neočekávané chybě. Zkuste to prosím znovu.", + "loginRequiredForDevice": "Přihlášení je vyžadováno pro vaše zařízení.", "passwordForgot": "Zapomněli jste heslo?", "otpAuth": "Dvoufaktorové ověření", "otpAuthDescription": "Zadejte kód z vaší autentizační aplikace nebo jeden z vlastních záložních kódů.", "otpAuthSubmit": "Odeslat kód", "idpContinue": "Nebo pokračovat s", - "otpAuthBack": "Zpět na přihlášení", + "otpAuthBack": "Zpět na heslo", "navbar": "Navigation Menu", "navbarDescription": "Hlavní navigační menu aplikace", "navbarDocsLink": "Dokumentace", @@ -1175,11 +1252,13 @@ "sidebarOverview": "Přehled", "sidebarHome": "Domů", "sidebarSites": "Stránky", + "sidebarApprovals": "Žádosti o schválení", "sidebarResources": "Zdroje", "sidebarProxyResources": "Veřejnost", "sidebarClientResources": "Soukromé", "sidebarAccessControl": "Kontrola přístupu", "sidebarLogsAndAnalytics": "Logy & Analytika", + "sidebarTeam": "Tým", "sidebarUsers": "Uživatelé", "sidebarAdmin": "Admin", "sidebarInvitations": "Pozvánky", @@ -1191,13 +1270,15 @@ "sidebarIdentityProviders": "Poskytovatelé identity", "sidebarLicense": "Licence", "sidebarClients": "Klienti", - "sidebarUserDevices": "Uživatelé", + "sidebarUserDevices": "Uživatelská zařízení", "sidebarMachineClients": "Stroje a přístroje", "sidebarDomains": "Domény", - "sidebarGeneral": "Obecná ustanovení", + "sidebarGeneral": "Spravovat", "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Plány", "sidebarOrganization": "Organizace", + "sidebarManagement": "Správa", + "sidebarBillingAndLicenses": "Fakturace a licence", "sidebarLogsAnalytics": "Analytici", "blueprints": "Plány", "blueprintsDescription": "Použít deklarativní konfigurace a zobrazit předchozí běhy", @@ -1219,7 +1300,6 @@ "parsedContents": "Parsed content (Pouze pro čtení)", "enableDockerSocket": "Povolit Docker plán", "enableDockerSocketDescription": "Povolte seškrábání štítků na Docker Socket pro popisky plánů. Nová cesta musí být k dispozici.", - "enableDockerSocketLink": "Zjistit více", "viewDockerContainers": "Zobrazit kontejnery Dockeru", "containersIn": "Kontejnery v {siteName}", "selectContainerDescription": "Vyberte jakýkoli kontejner pro použití jako název hostitele pro tento cíl. Klikněte na port pro použití portu.", @@ -1263,6 +1343,7 @@ "setupErrorCreateAdmin": "Došlo k chybě při vytváření účtu správce serveru.", "certificateStatus": "Stav certifikátu", "loading": "Načítání", + "loadingAnalytics": "Načítání analytiky", "restart": "Restartovat", "domains": "Domény", "domainsDescription": "Vytvořit a spravovat domény dostupné v organizaci", @@ -1290,6 +1371,7 @@ "refreshError": "Obnovení dat se nezdařilo", "verified": "Ověřeno", "pending": "Nevyřízeno", + "pendingApproval": "Čeká na schválení", "sidebarBilling": "Fakturace", "billing": "Fakturace", "orgBillingDescription": "Spravovat fakturační informace a předplatné", @@ -1308,8 +1390,11 @@ "accountSetupSuccess": "Nastavení účtu dokončeno! Vítejte v Pangolinu!", "documentation": "Dokumentace", "saveAllSettings": "Uložit všechna nastavení", + "saveResourceTargets": "Uložit cíle", + "saveResourceHttp": "Uložit nastavení proxy", + "saveProxyProtocol": "Uložit nastavení proxy protokolu", "settingsUpdated": "Nastavení aktualizováno", - "settingsUpdatedDescription": "Všechna nastavení byla úspěšně aktualizována", + "settingsUpdatedDescription": "Nastavení úspěšně aktualizována", "settingsErrorUpdate": "Aktualizace nastavení se nezdařila", "settingsErrorUpdateDescription": "Došlo k chybě při aktualizaci nastavení", "sidebarCollapse": "Sbalit", @@ -1342,6 +1427,7 @@ "domainPickerNamespace": "Jmenný prostor: {namespace}", "domainPickerShowMore": "Zobrazit více", "regionSelectorTitle": "Vybrat region", + "domainPickerRemoteExitNodeWarning": "Poskytnuté domény nejsou podporovány, když se stránky připojují k vzdáleným výstupním uzlům. Pro dostupné zdroje na vzdálených uzlech použijte vlastní doménu.", "regionSelectorInfo": "Výběr regionu nám pomáhá poskytovat lepší výkon pro vaši polohu. Nemusíte být ve stejném regionu jako váš server.", "regionSelectorPlaceholder": "Vyberte region", "regionSelectorComingSoon": "Již brzy", @@ -1351,10 +1437,11 @@ "billingUsageLimitsOverview": "Přehled omezení použití", "billingMonitorUsage": "Sledujte vaše využití pomocí nastavených limitů. Pokud potřebujete zvýšit limity, kontaktujte nás prosím support@pangolin.net.", "billingDataUsage": "Využití dat", - "billingOnlineTime": "Stránka online čas", - "billingUsers": "Aktivní uživatelé", - "billingDomains": "Aktivní domény", - "billingRemoteExitNodes": "Aktivní Samostatně hostované uzly", + "billingSites": "Stránky", + "billingUsers": "Uživatelé", + "billingDomains": "Domény", + "billingOrganizations": "Tělo", + "billingRemoteExitNodes": "Vzdálené uzly", "billingNoLimitConfigured": "Žádný limit nenastaven", "billingEstimatedPeriod": "Odhadované období fakturace", "billingIncludedUsage": "Zahrnuto využití", @@ -1379,15 +1466,24 @@ "billingFailedToGetPortalUrl": "Nepodařilo se získat URL portálu", "billingPortalError": "Chyba portálu", "billingDataUsageInfo": "Pokud jste připojeni k cloudu, jsou vám účtována všechna data přenášená prostřednictvím zabezpečených tunelů. To zahrnuje příchozí i odchozí provoz na všech vašich stránkách. Jakmile dosáhnete svého limitu, vaše stránky se odpojí, dokud neaktualizujete svůj tarif nebo nezmenšíte jeho používání. Data nejsou nabírána při používání uzlů.", - "billingOnlineTimeInfo": "Platíte na základě toho, jak dlouho budou vaše stránky připojeny k cloudu. Například, 44,640 minut se rovná jedné stránce 24/7 po celý měsíc. Jakmile dosáhnete svého limitu, vaše stránky se odpojí, dokud neaktualizujete svůj tarif nebo nezkrátíte jeho používání. Čas není vybírán při používání uzlů.", - "billingUsersInfo": "Každý uživatel v organizaci je účtován denně. Fakturace je počítána na základě počtu aktivních uživatelských účtů na Vašem org.", - "billingDomainInfo": "Objednávka je účtována za každou doménu v organizaci. Fakturace je počítána denně na základě počtu aktivních doménových účtů na Vašem org.", - "billingRemoteExitNodesInfo": "Za každý spravovaný uzel v organizaci se vám účtuje denně. Fakturace je vypočítávána na základě počtu aktivních spravovaných uzlů ve vašem org.", + "billingSInfo": "Kolik stránek můžete použít", + "billingUsersInfo": "Kolik uživatelů můžete použít", + "billingDomainInfo": "Kolik domén můžete použít", + "billingRemoteExitNodesInfo": "Kolik vzdálených uzlů můžete použít", + "billingLicenseKeys": "Licenční klíče", + "billingLicenseKeysDescription": "Spravovat předplatné licenčního klíče", + "billingLicenseSubscription": "Předplatné licence", + "billingInactive": "Neaktivní", + "billingLicenseItem": "Položka licence", + "billingQuantity": "Množství", + "billingTotal": "celkem", + "billingModifyLicenses": "Upravit předplatné licence", "domainNotFound": "Doména nenalezena", "domainNotFoundDescription": "Tento dokument je zakázán, protože doména již neexistuje náš systém. Nastavte prosím novou doménu pro tento dokument.", "failed": "Selhalo", "createNewOrgDescription": "Vytvořit novou organizaci", "organization": "Organizace", + "primary": "Primární", "port": "Přístav", "securityKeyManage": "Správa bezpečnostních klíčů", "securityKeyDescription": "Přidat nebo odebrat bezpečnostní klíče pro bezheslou autentizaci", @@ -1403,7 +1499,7 @@ "securityKeyRemoveSuccess": "Bezpečnostní klíč byl úspěšně odstraněn", "securityKeyRemoveError": "Odstranění bezpečnostního klíče se nezdařilo", "securityKeyLoadError": "Nepodařilo se načíst bezpečnostní klíče", - "securityKeyLogin": "Pokračovat s bezpečnostním klíčem", + "securityKeyLogin": "Použít bezpečnostní klíč", "securityKeyAuthError": "Ověření bezpečnostním klíčem se nezdařilo", "securityKeyRecommendation": "Registrujte záložní bezpečnostní klíč na jiném zařízení, abyste zajistili, že budete mít vždy přístup ke svému účtu.", "registering": "Registrace...", @@ -1459,11 +1555,47 @@ "resourcePortRequired": "Pro neHTTP zdroje je vyžadováno číslo portu", "resourcePortNotAllowed": "Číslo portu by nemělo být nastaveno pro HTTP zdroje", "billingPricingCalculatorLink": "Cenová kalkulačka", + "billingYourPlan": "Váš plán", + "billingViewOrModifyPlan": "Zobrazit nebo upravit aktuální tarif", + "billingViewPlanDetails": "Zobrazit detaily plánu", + "billingUsageAndLimits": "Limity a použití", + "billingViewUsageAndLimits": "Zobrazit limity vašeho plánu a aktuální využití", + "billingCurrentUsage": "Aktuální využití", + "billingMaximumLimits": "Maximální limity", + "billingRemoteNodes": "Vzdálené uzly", + "billingUnlimited": "Bez omezení", + "billingPaidLicenseKeys": "Placené licenční klíče", + "billingManageLicenseSubscription": "Spravujte své předplatné za placené samohostované licenční klíče", + "billingCurrentKeys": "Aktuální klíče", + "billingModifyCurrentPlan": "Upravit aktuální tarif", + "billingConfirmUpgrade": "Potvrdit aktualizaci", + "billingConfirmDowngrade": "Potvrdit downgrade", + "billingConfirmUpgradeDescription": "Chystáte se povýšit svůj tarif. Přečtěte si nové limity a ceny.", + "billingConfirmDowngradeDescription": "Chystáte se snížit svůj tarif. Přečtěte si nové limity a ceny níže.", + "billingPlanIncludes": "Plán zahrnuje", + "billingProcessing": "Zpracovávám...", + "billingConfirmUpgradeButton": "Potvrdit aktualizaci", + "billingConfirmDowngradeButton": "Potvrdit downgrade", + "billingLimitViolationWarning": "Využití překročilo limity nového plánu", + "billingLimitViolationDescription": "Vaše současné využití překračuje meze tohoto plánu. Po ponížení budou všechny akce zakázány, dokud nesnížíte využití v rámci nových limitů. Přečtěte si prosím níže uvedené funkce překračující limity. Limity při porušení:", + "billingFeatureLossWarning": "Upozornění na dostupnost funkce", + "billingFeatureLossDescription": "Po pomenutí budou funkce v novém plánu automaticky zakázány. Některá nastavení a konfigurace mohou být ztraceny. Zkontrolujte cenovou matrici, abyste pochopili, které funkce již nebudou k dispozici.", + "billingUsageExceedsLimit": "Aktuální využití ({current}) překračuje limit ({limit})", + "billingPastDueTitle": "Poslední splatnost platby", + "billingPastDueDescription": "Vaše platba je již splatná. Chcete-li pokračovat v používání aktuálních tarifů, aktualizujte prosím způsob platby. Pokud nebude vyřešeno, Vaše předplatné bude zrušeno a budete vráceno na úroveň zdarma.", + "billingUnpaidTitle": "Předplatné nezaplaceno", + "billingUnpaidDescription": "Vaše předplatné není zaplaceno a byli jste vráceni do bezplatné úrovně. Aktualizujte prosím svou platební metodu pro obnovení předplatného.", + "billingIncompleteTitle": "Platba nedokončena", + "billingIncompleteDescription": "Vaše platba je neúplná. Pro aktivaci předplatného prosím dokončete platební proces.", + "billingIncompleteExpiredTitle": "Platba vypršela", + "billingIncompleteExpiredDescription": "Vaše platba nebyla nikdy dokončena a vypršela. Byli jste vráceni na úroveň zdarma. Prosím, přihlašte se znovu pro obnovení přístupu k placeným funkcím.", + "billingManageSubscription": "Spravujte své předplatné", + "billingResolvePaymentIssue": "Vyřešte prosím problém s platbou před upgradem nebo upgradem", "signUpTerms": { "IAgreeToThe": "Souhlasím s", "termsOfService": "podmínky služby", "and": "a", - "privacyPolicy": "zásady ochrany osobních údajů" + "privacyPolicy": "zásady ochrany osobních údajů." }, "signUpMarketing": { "keepMeInTheLoop": "Udržujte mě ve smyčce s novinkami, aktualizacemi a novými funkcemi e-mailem." @@ -1508,6 +1640,7 @@ "addNewTarget": "Add New Target", "targetsList": "Seznam cílů", "advancedMode": "Pokročilý režim", + "advancedSettings": "Pokročilá nastavení", "targetErrorDuplicateTargetFound": "Byl nalezen duplicitní cíl", "healthCheckHealthy": "Zdravé", "healthCheckUnhealthy": "Nezdravé", @@ -1529,6 +1662,26 @@ "IntervalSeconds": "Interval zdraví", "timeoutSeconds": "Časový limit (sek)", "timeIsInSeconds": "Čas je v sekundách", + "requireDeviceApproval": "Vyžadovat schválení zařízení", + "requireDeviceApprovalDescription": "Uživatelé s touto rolí potřebují nová zařízení schválená správcem, než se mohou připojit a přistupovat ke zdrojům.", + "sshAccess": "SSH přístup", + "roleAllowSsh": "Povolit SSH", + "roleAllowSshAllow": "Povolit", + "roleAllowSshDisallow": "Zakázat", + "roleAllowSshDescription": "Povolit uživatelům s touto rolí připojení k zdrojům přes SSH. Je-li zakázáno, role nemůže používat přístup SSH.", + "sshSudoMode": "Súdánský přístup", + "sshSudoModeNone": "Nic", + "sshSudoModeNoneDescription": "Uživatel nemůže spouštět příkazy se sudo.", + "sshSudoModeFull": "Úplný Súdán", + "sshSudoModeFullDescription": "Uživatel může spustit libovolný příkaz se sudo.", + "sshSudoModeCommands": "Příkazy", + "sshSudoModeCommandsDescription": "Uživatel může spustit pouze zadané příkazy s sudo.", + "sshSudo": "Povolit sudo", + "sshSudoCommands": "Sudo příkazy", + "sshSudoCommandsDescription": "Čárkami oddělený seznam příkazů, které může uživatel spouštět s sudo.", + "sshCreateHomeDir": "Vytvořit domovský adresář", + "sshUnixGroups": "Unixové skupiny", + "sshUnixGroupsDescription": "Čárkou oddělené skupiny Unix přidají uživatele do cílového hostitele.", "retryAttempts": "Opakovat pokusy", "expectedResponseCodes": "Očekávané kódy odezvy", "expectedResponseCodesDescription": "HTTP kód stavu, který označuje zdravý stav. Ponecháte-li prázdné, 200-300 je považováno za zdravé.", @@ -1569,6 +1722,8 @@ "resourcesTableNoInternalResourcesFound": "Nebyly nalezeny žádné vnitřní zdroje.", "resourcesTableDestination": "Místo určení", "resourcesTableAlias": "Alias", + "resourcesTableAliasAddress": "Adresa aliasu", + "resourcesTableAliasAddressInfo": "Tato adresa je součástí subsítě veřejných služeb organizace. Používá se k řešení záznamů aliasů pomocí interního rozlišení DNS.", "resourcesTableClients": "Klienti", "resourcesTableAndOnlyAccessibleInternally": "a jsou interně přístupné pouze v případě, že jsou propojeni s klientem.", "resourcesTableNoTargets": "Žádné cíle", @@ -1616,9 +1771,8 @@ "createInternalResourceDialogResourceProperties": "Vlastnosti zdroje", "createInternalResourceDialogName": "Jméno", "createInternalResourceDialogSite": "Lokalita", - "createInternalResourceDialogSelectSite": "Vybrat lokalitu...", - "createInternalResourceDialogSearchSites": "Hledat lokality...", - "createInternalResourceDialogNoSitesFound": "Nebyly nalezeny žádné stránky.", + "selectSite": "Vybrat lokalitu...", + "noSitesFound": "Nebyly nalezeny žádné lokality.", "createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", @@ -1658,7 +1812,7 @@ "siteAddressDescription": "Interní adresa webu. Musí spadat do podsítě organizace.", "siteNameDescription": "Zobrazovaný název stránky, který lze později změnit.", "autoLoginExternalIdp": "Automatické přihlášení pomocí externího IDP", - "autoLoginExternalIdpDescription": "Okamžitě přesměrujte uživatele na externí IDP k ověření.", + "autoLoginExternalIdpDescription": "Ihned přesměrujte uživatele na externího poskytovatele identity pro autentifikaci.", "selectIdp": "Vybrat IDP", "selectIdpPlaceholder": "Vyberte IDP...", "selectIdpRequired": "Prosím vyberte IDP, když je povoleno automatické přihlášení.", @@ -1670,7 +1824,7 @@ "autoLoginErrorNoRedirectUrl": "Od poskytovatele identity nebyla obdržena žádná adresa URL.", "autoLoginErrorGeneratingUrl": "Nepodařilo se vygenerovat ověřovací URL.", "remoteExitNodeManageRemoteExitNodes": "Vzdálené uzly", - "remoteExitNodeDescription": "Samohostitelská jedna nebo více vzdálených uzlů pro rozšíření připojení k síti a snížení závislosti na cloudu", + "remoteExitNodeDescription": "Hostujte vlastní vzdálený relační a proxy server uzly", "remoteExitNodes": "Uzly", "searchRemoteExitNodes": "Hledat uzly...", "remoteExitNodeAdd": "Přidat uzel", @@ -1680,20 +1834,22 @@ "remoteExitNodeConfirmDelete": "Potvrdit odstranění uzlu", "remoteExitNodeDelete": "Odstranit uzel", "sidebarRemoteExitNodes": "Vzdálené uzly", + "remoteExitNodeId": "ID", + "remoteExitNodeSecretKey": "Tajný klíč", "remoteExitNodeCreate": { - "title": "Vytvořit uzel", - "description": "Vytvořit nový uzel pro rozšíření síťového připojení", + "title": "Vytvořit vzdálený uzel", + "description": "Vytvořte nový vlastní hostovaný vzdálený relační a proxy server uzel", "viewAllButton": "Zobrazit všechny uzly", "strategy": { "title": "Strategie tvorby", - "description": "Zvolte pro ruční nastavení uzlu nebo vygenerování nových pověření.", + "description": "Vyberte, jak chcete vytvořit vzdálený uzel", "adopt": { "title": "Přijmout uzel", "description": "Zvolte tuto možnost, pokud již máte přihlašovací údaje k uzlu." }, "generate": { "title": "Generovat klíče", - "description": "Vyberte tuto možnost, pokud chcete vygenerovat nové klíče pro uzel" + "description": "Vyberte tuto možnost, pokud chcete vygenerovat nové klíče pro uzel." } }, "adopt": { @@ -1806,9 +1962,33 @@ "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Podsíť", "subnetDescription": "Podsíť pro konfiguraci sítě této organizace.", - "authPage": "Auth stránka", - "authPageDescription": "Konfigurace autentizační stránky organizace", + "customDomain": "Vlastní doména", + "authPage": "Autentizační stránky", + "authPageDescription": "Nastavte vlastní doménu pro autentizační stránky organizace", "authPageDomain": "Doména ověření stránky", + "authPageBranding": "Vlastní branding", + "authPageBrandingDescription": "Nastavte branding, který se objeví na autentizačních stránkách této organizace", + "authPageBrandingUpdated": "Branding na autentizační stránce úspěšně aktualizován", + "authPageBrandingRemoved": "Branding na autentizační stránce úspěšně odstraněn", + "authPageBrandingRemoveTitle": "Odstranit branding autentizační stránky", + "authPageBrandingQuestionRemove": "Jste si jisti, že chcete odstranit branding autentizačních stránek?", + "authPageBrandingDeleteConfirm": "Potvrzení odstranění brandingu", + "brandingLogoURL": "URL loga", + "brandingLogoURLOrPath": "URL nebo cesta k logu", + "brandingLogoPathDescription": "Zadejte URL nebo místní cestu.", + "brandingLogoURLDescription": "Zadejte veřejně přístupnou adresu URL vašeho loga.", + "brandingPrimaryColor": "Primární barva", + "brandingLogoWidth": "Šířka (px)", + "brandingLogoHeight": "Výška (px)", + "brandingOrgTitle": "Název pro autentizační stránku organizace", + "brandingOrgDescription": "{orgName} bude nahrazeno názvem organizace", + "brandingOrgSubtitle": "Podtitul pro autentizační stránku organizace", + "brandingResourceTitle": "Název pro autentizační stránku prostředku", + "brandingResourceSubtitle": "Podtitul pro autentizační stránku prostředku", + "brandingResourceDescription": "{resourceName} bude nahrazeno názvem organizace", + "saveAuthPageDomain": "Uložit doménu", + "saveAuthPageBranding": "Uložit branding", + "removeAuthPageBranding": "Odstranit branding", "noDomainSet": "Není nastavena žádná doména", "changeDomain": "Změnit doménu", "selectDomain": "Vybrat doménu", @@ -1817,7 +1997,7 @@ "setAuthPageDomain": "Nastavit doménu autentické stránky", "failedToFetchCertificate": "Nepodařilo se načíst certifikát", "failedToRestartCertificate": "Restartování certifikátu se nezdařilo", - "addDomainToEnableCustomAuthPages": "Přidat doménu pro povolení vlastních ověřovacích stránek organizace", + "addDomainToEnableCustomAuthPages": "Uživatelé budou schopni přistupovat k přihlašovací stránce organizace a dokončit autentifikaci prostředků použitím této domény.", "selectDomainForOrgAuthPage": "Vyberte doménu pro ověřovací stránku organizace", "domainPickerProvidedDomain": "Poskytnutá doména", "domainPickerFreeProvidedDomain": "Zdarma poskytnutá doména", @@ -1832,11 +2012,27 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" nemohl být platný pro {domain}.", "domainPickerSubdomainSanitized": "Upravená subdoména", "domainPickerSubdomainCorrected": "\"{sub}\" bylo opraveno na \"{sanitized}\"", - "orgAuthSignInTitle": "Přihlásit se k organizaci", + "orgAuthSignInTitle": "Přihlášení do organizace", "orgAuthChooseIdpDescription": "Chcete-li pokračovat, vyberte svého poskytovatele identity", "orgAuthNoIdpConfigured": "Tato organizace nemá nakonfigurovány žádné poskytovatele identity. Místo toho se můžete přihlásit s vaší Pangolinovou identitou.", "orgAuthSignInWithPangolin": "Přihlásit se pomocí Pangolinu", + "orgAuthSignInToOrg": "Přihlásit se do organizace", + "orgAuthSelectOrgTitle": "Přihlášení do organizace", + "orgAuthSelectOrgDescription": "Zadejte ID vaší organizace pro pokračování", + "orgAuthOrgIdPlaceholder": "vaše-organizace", + "orgAuthOrgIdHelp": "Zadejte jedinečný identifikátor vaší organizace", + "orgAuthSelectOrgHelp": "Po zadání ID vaší organizace budete přesměrováni na přihlašovací stránku vaší organizace, kde můžete použít SSO nebo přihlašovací údaje vaší organizace.", + "orgAuthRememberOrgId": "Zapamatujte si toto ID organizace", + "orgAuthBackToSignIn": "Zpět ke standardnímu přihlášení", + "orgAuthNoAccount": "Nemáte účet?", "subscriptionRequiredToUse": "Pro použití této funkce je vyžadováno předplatné.", + "mustUpgradeToUse": "Pro použití této funkce musíte aktualizovat své předplatné.", + "subscriptionRequiredTierToUse": "Tato funkce vyžaduje {tier} nebo vyšší.", + "upgradeToTierToUse": "Pro použití této funkce upgradujte na {tier} nebo vyšší.", + "subscriptionTierTier1": "Domů", + "subscriptionTierTier2": "Tým", + "subscriptionTierTier3": "Podniky", + "subscriptionTierEnterprise": "Podniky", "idpDisabled": "Poskytovatelé identit jsou zakázáni.", "orgAuthPageDisabled": "Ověřovací stránka organizace je zakázána.", "domainRestartedDescription": "Ověření domény bylo úspěšně restartováno", @@ -1850,6 +2046,8 @@ "enableTwoFactorAuthentication": "Povolit dvoufaktorové ověření", "completeSecuritySteps": "Dokončit bezpečnostní kroky", "securitySettings": "Nastavení zabezpečení", + "dangerSection": "Nebezpečná zóna", + "dangerSectionDescription": "Trvale smazat všechna data spojená s touto organizací", "securitySettingsDescription": "Konfigurace bezpečnostních pravidel organizace", "requireTwoFactorForAllUsers": "Vyžadovat dvoufaktorové ověření pro všechny uživatele", "requireTwoFactorDescription": "Pokud je povoleno, všichni interní uživatelé v této organizaci musí mít dvoufaktorové ověření povoleno pro přístup k organizaci.", @@ -1887,7 +2085,7 @@ "securityPolicyChangeWarningText": "Toto ovlivní všechny uživatele v organizaci", "authPageErrorUpdateMessage": "Při aktualizaci nastavení autentizační stránky došlo k chybě", "authPageErrorUpdate": "Nelze aktualizovat ověřovací stránku", - "authPageUpdated": "Autentizační stránka byla úspěšně aktualizována", + "authPageDomainUpdated": "Doména na autentizační stránce úspěšně aktualizována", "healthCheckNotAvailable": "Místní", "rewritePath": "Přepsat cestu", "rewritePathDescription": "Volitelně přepište cestu před odesláním na cíl.", @@ -1915,8 +2113,15 @@ "beta": "Beta", "manageUserDevices": "Uživatelská zařízení", "manageUserDevicesDescription": "Zobrazit a spravovat zařízení, která používají uživatelé k soukromím připojení k zdrojům", + "downloadClientBannerTitle": "Stáhnout klienta Pangolinu", + "downloadClientBannerDescription": "Stáhnout klienta Pangolinu do vašeho systému pro připojení k síti Pangolin a zajištění soukromého přístupu k prostředkům.", "manageMachineClients": "Správa automatických klientů", "manageMachineClientsDescription": "Vytvořte a spravujte klienty, které servery a systémy používají k soukromím připojování k zdrojům", + "machineClientsBannerTitle": "Servery & Automatizované systémy", + "machineClientsBannerDescription": "Klientské stroje jsou určeny pro servery a automatizované systémy, které nejsou přiřazeny k žádnému specifickému uživateli. Autentizují se pomocí ID a tajemství, a mohou běžet s Pangolin CLI, Olm CLI nebo Olm jako kontejner.", + "machineClientsBannerPangolinCLI": "Pangolin CLI", + "machineClientsBannerOlmCLI": "Olm CLI", + "machineClientsBannerOlmContainer": "Olm kontejner", "clientsTableUserClients": "Uživatel", "clientsTableMachineClients": "Stroj", "licenseTableValidUntil": "Platná do", @@ -2015,6 +2220,32 @@ } } }, + "newPricingLicenseForm": { + "title": "Získat licenci", + "description": "Vyberte si plán a řekněte nám, jak plánujete používat Pangolin.", + "chooseTier": "Vyberte si svůj plán", + "viewPricingLink": "Zobrazit ceny, funkce a limity", + "tiers": { + "starter": { + "title": "Počáteční", + "description": "Firemní funkce, 25 uživatelů, 25 stránek a komunitní podpory." + }, + "scale": { + "title": "Měřítko", + "description": "Podnikové funkce, 50 uživatelů, 50 míst a prioritní podpory." + } + }, + "personalUseOnly": "Pouze osobní použití (bezplatná licence – bez platby)", + "buttons": { + "continueToCheckout": "Pokračovat do pokladny" + }, + "toasts": { + "checkoutError": { + "title": "Chyba při objednávce", + "description": "Nelze spustit objednávku. Zkuste to prosím znovu." + } + } + }, "priority": "Priorita", "priorityDescription": "Vyšší priorita je vyhodnocena jako první. Priorita = 100 znamená automatické řazení (rozhodnutí systému). Pro vynucení manuální priority použijte jiné číslo.", "instanceName": "Název instance", @@ -2060,13 +2291,15 @@ "request": "Žádost", "requests": "Požadavky", "logs": "Logy", - "logsSettingsDescription": "Monitorovat logy shromážděné z této orginizace", + "logsSettingsDescription": "Sledujte protokoly sbírané z této organizace", "searchLogs": "Hledat logy...", "action": "Akce", "actor": "Aktér", "timestamp": "Časové razítko", "accessLogs": "Protokoly přístupu", "exportCsv": "Exportovat CSV", + "exportError": "Neznámá chyba při exportu CSV", + "exportCsvTooltip": "V zadaném časovém rozmezí", "actorId": "ID aktéra", "allowedByRule": "Povoleno pomocí pravidla", "allowedNoAuth": "Povoleno bez ověření", @@ -2111,7 +2344,8 @@ "logRetentionEndOfFollowingYear": "Konec následujícího roku", "actionLogsDescription": "Zobrazit historii akcí provedených v této organizaci", "accessLogsDescription": "Zobrazit žádosti o ověření přístupu pro zdroje v této organizaci", - "licenseRequiredToUse": "Pro použití této funkce je vyžadována licence pro podnikání.", + "licenseRequiredToUse": "Pro použití této funkce je vyžadována licence Enterprise Edition nebo Pangolin Cloud . Zarezervujte si demo nebo POC zkušební verzi.", + "ossEnterpriseEditionRequired": "Enterprise Edition je vyžadována pro použití této funkce. Tato funkce je také k dispozici v Pangolin Cloud. Rezervujte si demo nebo POC zkušební verzi.", "certResolver": "Oddělovač certifikátů", "certResolverDescription": "Vyberte řešitele certifikátů pro tento dokument.", "selectCertResolver": "Vyberte řešič certifikátů", @@ -2120,7 +2354,7 @@ "unverified": "Neověřeno", "domainSetting": "Nastavení domény", "domainSettingDescription": "Konfigurace nastavení pro doménu", - "preferWildcardCertDescription": "Pokus o vygenerování zástupného certifikátu (vyžaduje správně nakonfigurovaný certifikát).", + "preferWildcardCertDescription": "Pokuste se vygenerovat certifikát se zástupným znakem (vyžaduje správně nakonfigurovaný resolver certifikátu).", "recordName": "Název záznamu", "auto": "Automaticky", "TTL": "TTL", @@ -2167,11 +2401,13 @@ "terms": "Výrazy", "privacy": "Soukromí", "security": "Zabezpečení", - "docs": "Dokumenty", + "docs": "Dokumentace", "deviceActivation": "Aktivace zařízení", "deviceCodeInvalidFormat": "Kód musí být 9 znaků (např. A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Neplatný nebo prošlý kód", "deviceCodeVerifyFailed": "Ověření kódu zařízení se nezdařilo", + "deviceCodeValidating": "Ověřování kódu zařízení...", + "deviceCodeVerifying": "Ověřování autorizace zařízení...", "signedInAs": "Přihlášen jako", "deviceCodeEnterPrompt": "Zadejte kód zobrazený na zařízení", "continue": "Pokračovat", @@ -2184,7 +2420,7 @@ "deviceOrganizationsAccess": "Přístup ke všem organizacím má přístup k vašemu účtu", "deviceAuthorize": "Autorizovat {applicationName}", "deviceConnected": "Zařízení připojeno!", - "deviceAuthorizedMessage": "Zařízení má oprávnění k přístupu k vašemu účtu.", + "deviceAuthorizedMessage": "Zařízení má oprávnění k přístupu k vašemu účtu. Vraťte se prosím do klientské aplikace.", "pangolinCloud": "Pangolin Cloud", "viewDevices": "Zobrazit zařízení", "viewDevicesDescription": "Spravovat připojená zařízení", @@ -2246,6 +2482,7 @@ "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Nejste vy? Použijte jiný účet.", "deviceLoginDeviceRequestingAccessToAccount": "Zařízení žádá o přístup k tomuto účtu.", + "loginSelectAuthenticationMethod": "Chcete-li pokračovat, vyberte metodu ověřování.", "noData": "Žádná data", "machineClients": "Strojoví klienti", "install": "Instalovat", @@ -2255,6 +2492,8 @@ "setupFailedToFetchSubnet": "Nepodařilo se načíst výchozí podsíť", "setupSubnetAdvanced": "Podsíť (předplacená)", "setupSubnetDescription": "Podsíť pro vnitřní síť této organizace.", + "setupUtilitySubnet": "Nástrojový podsíť (Pokročilé)", + "setupUtilitySubnetDescription": "Podsíť pro alias adresy a DNS server této organizace.", "siteRegenerateAndDisconnect": "Obnovit a odpojit", "siteRegenerateAndDisconnectConfirmation": "Opravdu chcete obnovit přihlašovací údaje a odpojit tuto stránku?", "siteRegenerateAndDisconnectWarning": "Toto obnoví přihlašovací údaje a okamžitě odpojí stránku. Stránka bude muset být restartována s novými přihlašovacími údaji.", @@ -2270,5 +2509,179 @@ "remoteExitNodeRegenerateAndDisconnectWarning": "Toto obnoví přihlašovací údaje a okamžitě odpojí vzdálený výstupní uzel. Vzdálený výstupní uzel bude muset být restartován s novými přihlašovacími údaji.", "remoteExitNodeRegenerateCredentialsConfirmation": "Jste si jisti, že chcete obnovit přihlašovací údaje pro tento vzdálený výstupní uzel?", "remoteExitNodeRegenerateCredentialsWarning": "Toto obnoví přihlašovací údaje. Vzdálený výstupní uzel zůstane připojen, dokud jej ručně nerestartujete a nepoužijete nové přihlašovací údaje.", - "agent": "Agent" + "agent": "Agent", + "personalUseOnly": "Pouze pro osobní použití", + "loginPageLicenseWatermark": "Tato instance je licencována pouze pro osobní použití.", + "instanceIsUnlicensed": "Tato instance není licencována.", + "portRestrictions": "Omezení portů", + "allPorts": "Vše", + "custom": "Vlastní", + "allPortsAllowed": "Všechny porty povoleny", + "allPortsBlocked": "Všechny porty blokovány", + "tcpPortsDescription": "Určete, které TCP porty jsou pro tento prostředek povoleny. Použijte „*“ pro všechny porty, nechte prázdné pro zablokování všech, nebo zadejte seznam portů a rozsahů oddělených čárkou (např. 80,443,8000-9000).", + "udpPortsDescription": "Určete, které UDP porty jsou pro tento prostředek povoleny. Použijte „*“ pro všechny porty, nechte prázdné pro zablokování všech, nebo zadejte seznam portů a rozsahů oddělených čárkou (např. 53,123,500-600).", + "organizationLoginPageTitle": "Přihlašovací stránka organizace", + "organizationLoginPageDescription": "Přizpůsobte přihlašovací stránku této organizace", + "resourceLoginPageTitle": "Přihlašovací stránka prostředku", + "resourceLoginPageDescription": "Přizpůsobte přihlašovací stránku jednotlivých prostředků", + "enterConfirmation": "Zadejte potvrzení", + "blueprintViewDetails": "Detaily", + "defaultIdentityProvider": "Výchozí poskytovatel identity", + "defaultIdentityProviderDescription": "Pokud je vybrán výchozí poskytovatel identity, uživatel bude automaticky přesměrován na poskytovatele pro ověření.", + "editInternalResourceDialogNetworkSettings": "Nastavení sítě", + "editInternalResourceDialogAccessPolicy": "Přístupová politika", + "editInternalResourceDialogAddRoles": "Přidat role", + "editInternalResourceDialogAddUsers": "Přidat uživatele", + "editInternalResourceDialogAddClients": "Přidat klienty", + "editInternalResourceDialogDestinationLabel": "Cíl", + "editInternalResourceDialogDestinationDescription": "Určete cílovou adresu pro interní prostředek. Může se jednat o hostname, IP adresu, nebo rozsah CIDR v závislosti na vybraném režimu. Volitelně nastavte interní DNS alias pro snazší identifikaci.", + "editInternalResourceDialogPortRestrictionsDescription": "Omezte přístup na specifické TCP/UDP porty nebo povolte/blokujte všechny porty.", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "Řízení přístupu", + "editInternalResourceDialogAccessControlDescription": "Kontrolujte, které role, uživatelé a klienti mohou přistupovat k tomuto prostředku, když jsou připojeni. Admini mají vždy přístup.", + "editInternalResourceDialogPortRangeValidationError": "Rozsah portů musí být \"*\" pro všechny porty, nebo seznam portů a rozsahů oddělených čárkou (např. \"80,443,8000-9000\"). Porty musí být mezi 1 a 65535.", + "internalResourceAuthDaemonStrategy": "SSH Auth Démon umístění", + "internalResourceAuthDaemonStrategyDescription": "Zvolte, kde běží SSH autentizační démon: na stránce (Newt) nebo na vzdáleném serveru.", + "internalResourceAuthDaemonDescription": "SSH autentizační daemon zpracovává podpis SSH klíče a PAM autentizaci tohoto zdroje. Vyberte si, zda běží na webu (Newt) nebo na samostatném vzdáleném serveru. Více informací najdete v dokumentaci.", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "Vybrat strategii", + "internalResourceAuthDaemonStrategyLabel": "Poloha", + "internalResourceAuthDaemonSite": "Na stránce", + "internalResourceAuthDaemonSiteDescription": "Auth daemon běží na webu (Newt).", + "internalResourceAuthDaemonRemote": "Vzdálený server", + "internalResourceAuthDaemonRemoteDescription": "Auth daemon běží na hostitele, který není web.", + "internalResourceAuthDaemonPort": "Daemon port (volitelné)", + "orgAuthWhatsThis": "Kde najdu ID mé organizace?", + "learnMore": "Zjistit více", + "backToHome": "Zpět na domovskou stránku", + "needToSignInToOrg": "Potřebujete použít identitního poskytovatele vaší organizace?", + "maintenanceMode": "Režim údržby", + "maintenanceModeDescription": "Zobrazit stránku údržby návštěvníkům", + "maintenanceModeType": "Typ režimu údržby", + "showMaintenancePage": "Zobrazit stránku údržby návštěvníkům", + "enableMaintenanceMode": "Povolit režim údržby", + "automatic": "Automatické", + "automaticModeDescription": "Zobrazte stránku údržby pouze, když jsou všechny cílové servery uživatele nebo prostředku nefunkční nebo nezdravé. Vaše prostředky budou nadále fungovat normálně, pokud je alespoň jeden cíl v pořádku.", + "forced": "Nucené", + "forcedModeDescription": "Vždy zobrazujte stránku údržby bez ohledu na stav backendu. Použijte to pro plánovanou údržbu, když chcete zabránit všem přístupům.", + "warning:": "Varování:", + "forcedeModeWarning": "Veškerý provoz bude směrován na stránku údržby. Vaše prostředky backendu neobdrží žádné žádosti.", + "pageTitle": "Název stránky", + "pageTitleDescription": "Hlavní titulek zobrazovaný na stránce údržby", + "maintenancePageMessage": "Zpráva údržby", + "maintenancePageMessagePlaceholder": "Vrátíme se brzy! Naše stránka právě prochází plánovanou údrbou.", + "maintenancePageMessageDescription": "Podrobná zpráva vysvětlující údržbu", + "maintenancePageTimeTitle": "Odhadovaný čas dokončení (volitelný)", + "maintenanceTime": "např. 2 hodiny, 1. listopadu v 17:00", + "maintenanceEstimatedTimeDescription": "Kdy očekáváte, že údržba bude dokončena", + "editDomain": "Upravit doménu", + "editDomainDescription": "Vyberte doménu pro váš prostředek", + "maintenanceModeDisabledTooltip": "Tato funkce vyžaduje platnou licenci, aby ji bylo možné povolit.", + "maintenanceScreenTitle": "Služba dočasně nedostupná", + "maintenanceScreenMessage": "Momentálně máme technické potíže. Zkontrolujte později.", + "maintenanceScreenEstimatedCompletion": "Odhadované dokončení:", + "createInternalResourceDialogDestinationRequired": "Cíl je povinný", + "available": "Dostupné", + "archived": "Archivováno", + "noArchivedDevices": "Nebyla nalezena žádná archivovaná zařízení", + "deviceArchived": "Zařízení archivováno", + "deviceArchivedDescription": "Zařízení bylo úspěšně archivováno.", + "errorArchivingDevice": "Chyba při archivaci zařízení", + "failedToArchiveDevice": "Archivace zařízení se nezdařila", + "deviceQuestionArchive": "Opravdu chcete archivovat toto zařízení?", + "deviceMessageArchive": "Zařízení bude archivováno a odebráno ze seznamu aktivních zařízení.", + "deviceArchiveConfirm": "Archivovat zařízení", + "archiveDevice": "Archivovat zařízení", + "archive": "Archiv", + "deviceUnarchived": "Zařízení bylo odarchivováno", + "deviceUnarchivedDescription": "Zařízení bylo úspěšně odarchivováno.", + "errorUnarchivingDevice": "Chyba při odarchivování zařízení", + "failedToUnarchiveDevice": "Nepodařilo se odarchivovat zařízení", + "unarchive": "Zrušit archiv", + "archiveClient": "Archivovat klienta", + "archiveClientQuestion": "Jste si jisti, že chcete archivovat tohoto klienta?", + "archiveClientMessage": "Klient bude archivován a odstraněn z vašeho aktivního seznamu klientů.", + "archiveClientConfirm": "Archivovat klienta", + "blockClient": "Blokovat klienta", + "blockClientQuestion": "Jste si jisti, že chcete zablokovat tohoto klienta?", + "blockClientMessage": "Zařízení bude nuceno odpojit, pokud je připojeno. Zařízení můžete později odblokovat.", + "blockClientConfirm": "Blokovat klienta", + "active": "Aktivní", + "usernameOrEmail": "Uživatelské jméno nebo e-mail", + "selectYourOrganization": "Vyberte vaši organizaci", + "signInTo": "Přihlásit se do", + "signInWithPassword": "Pokračovat s heslem", + "noAuthMethodsAvailable": "Pro tuto organizaci nejsou k dispozici žádné metody ověřování.", + "enterPassword": "Zadejte své heslo", + "enterMfaCode": "Zadejte kód z vaší ověřovací aplikace", + "securityKeyRequired": "Pro přihlášení použijte svůj bezpečnostní klíč.", + "needToUseAnotherAccount": "Potřebujete použít jiný účet?", + "loginLegalDisclaimer": "Kliknutím na tlačítka níže potvrzujete, že jste si přečetli, chápali, a souhlasím s obchodními podmínkami a Zásadami ochrany osobních údajů.", + "termsOfService": "Podmínky služby", + "privacyPolicy": "Ochrana osobních údajů", + "userNotFoundWithUsername": "Nebyl nalezen žádný uživatel s tímto uživatelským jménem.", + "verify": "Ověřit", + "signIn": "Přihlásit se", + "forgotPassword": "Zapomněli jste heslo?", + "orgSignInTip": "Pokud jste se přihlásili dříve, můžete místo toho zadat své uživatelské jméno nebo e-mail výše pro ověření u poskytovatele identity vaší organizace. Je to jednodušší!", + "continueAnyway": "Přesto pokračovat", + "dontShowAgain": "Znovu nezobrazovat", + "orgSignInNotice": "Věděli jste, že?", + "signupOrgNotice": "Chcete se přihlásit?", + "signupOrgTip": "Snažíte se přihlásit prostřednictvím poskytovatele identity vaší organizace?", + "signupOrgLink": "Namísto toho se přihlaste nebo se zaregistrujte pomocí své organizace", + "verifyEmailLogInWithDifferentAccount": "Použít jiný účet", + "logIn": "Přihlásit se", + "deviceInformation": "Informace o zařízení", + "deviceInformationDescription": "Informace o zařízení a agentovi", + "deviceSecurity": "Zabezpečení zařízení", + "deviceSecurityDescription": "Informace o bezpečnostní pozici zařízení", + "platform": "Platforma", + "macosVersion": "macOS verze", + "windowsVersion": "Verze Windows", + "iosVersion": "Verze iOS", + "androidVersion": "Verze Androidu", + "osVersion": "Verze OS", + "kernelVersion": "Verze jádra", + "deviceModel": "Model zařízení", + "serialNumber": "Pořadové číslo", + "hostname": "Hostname", + "firstSeen": "První vidění", + "lastSeen": "Naposledy viděno", + "biometricsEnabled": "Biometrie povolena", + "diskEncrypted": "Šifrovaný disk", + "firewallEnabled": "Firewall povolen", + "autoUpdatesEnabled": "Automatické aktualizace povoleny", + "tpmAvailable": "TPM k dispozici", + "windowsAntivirusEnabled": "Antivirus povolen", + "macosSipEnabled": "Ochrana systémové integrity (SIP)", + "macosGatekeeperEnabled": "Gatekeeper", + "macosFirewallStealthMode": "Režim neviditelnosti firewallu", + "linuxAppArmorEnabled": "Pancíř aplikace", + "linuxSELinuxEnabled": "SELinux", + "deviceSettingsDescription": "Zobrazit informace o zařízení a nastavení", + "devicePendingApprovalDescription": "Toto zařízení čeká na schválení", + "deviceBlockedDescription": "Toto zařízení je momentálně blokováno. Nebude se moci připojit k žádným zdrojům, dokud nebude odblokováno.", + "unblockClient": "Odblokovat klienta", + "unblockClientDescription": "Zařízení bylo odblokováno", + "unarchiveClient": "Zrušit archiv klienta", + "unarchiveClientDescription": "Zařízení bylo odarchivováno", + "block": "Blokovat", + "unblock": "Odblokovat", + "deviceActions": "Akce zařízení", + "deviceActionsDescription": "Spravovat stav zařízení a přístup", + "devicePendingApprovalBannerDescription": "Toto zařízení čeká na schválení. Nebude se moci připojit ke zdrojům, dokud nebude schváleno.", + "connected": "Připojeno", + "disconnected": "Odpojeno", + "approvalsEmptyStateTitle": "Schvalování zařízení není povoleno", + "approvalsEmptyStateDescription": "Povolte oprávnění oprávnění pro role správce před připojením nových zařízení.", + "approvalsEmptyStateStep1Title": "Přejít na role", + "approvalsEmptyStateStep1Description": "Přejděte do nastavení rolí vaší organizace pro konfiguraci schválení zařízení.", + "approvalsEmptyStateStep2Title": "Povolit schválení zařízení", + "approvalsEmptyStateStep2Description": "Upravte roli a povolte možnost 'Vyžadovat schválení zařízení'. Uživatelé s touto rolí budou potřebovat schválení pro nová zařízení správce.", + "approvalsEmptyStatePreviewDescription": "Náhled: Pokud je povoleno, čekající na zařízení se zde zobrazí žádosti o recenzi", + "approvalsEmptyStateButtonText": "Spravovat role", + "domainErrorTitle": "Máme problém s ověřením tvé domény" } diff --git a/messages/de-DE.json b/messages/de-DE.json index ea3647cf7..bfced13d0 100644 --- a/messages/de-DE.json +++ b/messages/de-DE.json @@ -1,8 +1,10 @@ { - "setupCreate": "Organisation, Seite und Ressourcen erstellen", + "setupCreate": "Organisation, Standort und Ressourcen erstellen", + "headerAuthCompatibilityInfo": "Aktivieren Sie dies, um eine 401 Nicht autorisierte Antwort zu erzwingen, wenn ein Authentifizierungs-Token fehlt. Dies ist erforderlich für Browser oder bestimmte HTTP-Bibliotheken, die keine Anmeldedaten ohne Server-Challenge senden.", + "headerAuthCompatibility": "Erweiterte Kompatibilität", "setupNewOrg": "Neue Organisation", "setupCreateOrg": "Organisation erstellen", - "setupCreateResources": "Ressource erstellen", + "setupCreateResources": "Ressourcen erstellen", "setupOrgName": "Name der Organisation", "orgDisplayName": "Dies ist der Anzeigename der Organisation.", "orgId": "Organisations-ID", @@ -16,6 +18,8 @@ "componentsMember": "Du bist Mitglied von {count, plural, =0 {keiner Organisation} one {einer Organisation} other {# Organisationen}}.", "componentsInvalidKey": "Ungültige oder abgelaufene Lizenzschlüssel erkannt. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.", "dismiss": "Verwerfen", + "subscriptionViolationMessage": "Sie überschreiten Ihre Grenzen für Ihr aktuelles Paket. Korrigieren Sie das Problem, indem Sie Webseiten, Benutzer oder andere Ressourcen entfernen, um in Ihrem Paket zu bleiben.", + "subscriptionViolationViewBilling": "Rechnung anzeigen", "componentsLicenseViolation": "Lizenzverstoß: Dieser Server benutzt {usedSites} Standorte, was das Lizenzlimit von {maxSites} Standorten überschreitet. Beachte die Lizenzbedingungen, um alle Funktionen weiterhin zu nutzen.", "componentsSupporterMessage": "Vielen Dank für die Unterstützung von Pangolin als {tier}!", "inviteErrorNotValid": "Es tut uns leid, aber es sieht so aus, als wäre die Einladung, auf die du zugreifen möchtest, entweder nicht angenommen worden oder nicht mehr gültig.", @@ -45,19 +49,25 @@ "tunnelType": "Tunneltyp", "local": "Lokal", "edit": "Bearbeiten", - "siteConfirmDelete": "Standort löschen bestätigen", + "siteConfirmDelete": "Löschen des Standorts bestätigen", "siteDelete": "Standort löschen", - "siteMessageRemove": "Sobald die Site entfernt ist, wird sie nicht mehr zugänglich sein. Alle mit der Site verbundenen Ziele werden ebenfalls entfernt.", - "siteQuestionRemove": "Sind Sie sicher, dass Sie die Site aus der Organisation entfernen möchten?", + "siteMessageRemove": "Sobald der Standort entfernt ist, wird sie nicht mehr zugänglich sein. Alle mit dem Standort verbundenen Ziele werden ebenfalls entfernt.", + "siteQuestionRemove": "Sind Sie sicher, dass Sie den Standort aus der Organisation entfernen möchten?", "siteManageSites": "Standorte verwalten", - "siteDescription": "Erstelle und verwalte Sites, um die Verbindung zu privaten Netzwerken zu ermöglichen", + "siteDescription": "Erstellen und Verwalten von Standorten, um die Verbindung zu privaten Netzwerken zu ermöglichen", + "sitesBannerTitle": "Verbinde ein beliebiges Netzwerk", + "sitesBannerDescription": "Ein Standort ist eine Verbindung zu einem Remote-Netzwerk, die es Pangolin ermöglicht, Zugriff auf öffentliche oder private Ressourcen für Benutzer überall zu gewähren. Installieren Sie den Site Netzwerk Connector (Newt) wo auch immer Sie eine Binärdatei oder einen Container starten können, um die Verbindung herzustellen.", + "sitesBannerButtonText": "Standort installieren", + "approvalsBannerTitle": "Gerätezugriff genehmigen oder verweigern", + "approvalsBannerDescription": "Überprüfen und genehmigen oder verweigern Gerätezugriffsanfragen von Benutzern. Wenn Gerätegenehmigungen erforderlich sind, müssen Benutzer eine Administratorgenehmigung erhalten, bevor ihre Geräte sich mit den Ressourcen Ihrer Organisation verbinden können.", + "approvalsBannerButtonText": "Mehr erfahren", "siteCreate": "Standort erstellen", "siteCreateDescription2": "Folge den nachfolgenden Schritten, um einen neuen Standort zu erstellen und zu verbinden", - "siteCreateDescription": "Erstellen Sie eine neue Seite, um Ressourcen zu verbinden", + "siteCreateDescription": "Erstellen Sie einen neuen Standort, um Ressourcen zu verbinden", "close": "Schließen", "siteErrorCreate": "Fehler beim Erstellen des Standortes", "siteErrorCreateKeyPair": "Schlüsselpaar oder Standardwerte nicht gefunden", - "siteErrorCreateDefaults": "Standardwerte der Site nicht gefunden", + "siteErrorCreateDefaults": "Standardwerte des Standortes nicht gefunden", "method": "Methode", "siteMethodDescription": "So werden Verbindungen freigegeben.", "siteLearnNewt": "Wie du Newt auf deinem System installieren kannst", @@ -67,7 +77,7 @@ "toggle": "Umschalten", "dockerCompose": "Docker Compose", "dockerRun": "Docker Run", - "siteLearnLocal": "Mehr Infos zu lokalen Sites", + "siteLearnLocal": "Mehr Infos zum lokalen Standort", "siteConfirmCopy": "Ich habe die Konfiguration kopiert", "searchSitesProgress": "Standorte durchsuchen...", "siteAdd": "Standort hinzufügen", @@ -78,7 +88,7 @@ "operatingSystem": "Betriebssystem", "commands": "Befehle", "recommended": "Empfohlen", - "siteNewtDescription": "Nutze Newt für die beste Benutzererfahrung. Newt verwendet WireGuard as Basis und erlaubt Ihnen, Ihre privaten Ressourcen über ihre LAN-Adresse in Ihrem privaten Netzwerk aus dem Pangolin-Dashboard heraus zu adressieren.", + "siteNewtDescription": "Nutze Newt für die beste Benutzererfahrung. Newt verwendet WireGuard als Basis und erlaubt Ihnen, Ihre privaten Ressourcen über ihre LAN-Adresse in Ihrem privaten Netzwerk aus dem Pangolin-Dashboard heraus zu adressieren.", "siteRunsInDocker": "Läuft in Docker", "siteRunsInShell": "Läuft in der Konsole auf macOS, Linux und Windows", "siteErrorDelete": "Fehler beim Löschen des Standortes", @@ -87,9 +97,9 @@ "siteUpdated": "Standort aktualisiert", "siteUpdatedDescription": "Der Standort wurde aktualisiert.", "siteGeneralDescription": "Allgemeine Einstellungen für diesen Standort konfigurieren", - "siteSettingDescription": "Einstellungen auf der Seite konfigurieren", + "siteSettingDescription": "Standorteinstellungen konfigurieren", "siteSetting": "{siteName} Einstellungen", - "siteNewtTunnel": "Neue Seite (empfohlen)", + "siteNewtTunnel": "Newt Standort (empfohlen)", "siteNewtTunnelDescription": "Einfachster Weg, einen Einstiegspunkt in jedes Netzwerk zu erstellen. Keine zusätzliche Einrichtung.", "siteWg": "Einfacher WireGuard Tunnel", "siteWgDescription": "Verwende jeden WireGuard-Client, um einen Tunnel einzurichten. Manuelles NAT-Setup erforderlich.", @@ -97,12 +107,13 @@ "siteLocalDescription": "Nur lokale Ressourcen. Kein Tunneling.", "siteLocalDescriptionSaas": "Nur lokale Ressourcen. Kein Tunneling. Nur für entfernte Knoten verfügbar.", "siteSeeAll": "Alle Standorte anzeigen", - "siteTunnelDescription": "Legen Sie fest, wie Sie sich mit der Site verbinden möchten", + "siteTunnelDescription": "Legen Sie fest, wie Sie sich mit dem Standort verbinden möchten", "siteNewtCredentials": "Zugangsdaten", - "siteNewtCredentialsDescription": "So wird sich die Seite mit dem Server authentifizieren", + "siteNewtCredentialsDescription": "So wird sich der Standort mit dem Server authentifizieren", + "remoteNodeCredentialsDescription": "So wird sich der entfernte Node mit dem Server authentifizieren", "siteCredentialsSave": "Anmeldedaten speichern", "siteCredentialsSaveDescription": "Du kannst das nur einmal sehen. Stelle sicher, dass du es an einen sicheren Ort kopierst.", - "siteInfo": "Standort-Informationen", + "siteInfo": "Standortinformationen", "status": "Status", "shareTitle": "Links zum Teilen verwalten", "shareDescription": "Erstelle teilbare Links, um temporären oder permanenten Zugriff auf Proxy-Ressourcen zu gewähren", @@ -146,8 +157,12 @@ "shareErrorSelectResource": "Bitte wählen Sie eine Ressource", "proxyResourceTitle": "Öffentliche Ressourcen verwalten", "proxyResourceDescription": "Erstelle und verwalte Ressourcen, die über einen Webbrowser öffentlich zugänglich sind", + "proxyResourcesBannerTitle": "Web-basierter öffentlicher Zugang", + "proxyResourcesBannerDescription": "Öffentliche Ressourcen sind HTTPS oder TCP/UDP-Proxys, die über einen Webbrowser für jeden zugänglich sind. Im Gegensatz zu privaten Ressourcen benötigen sie keine Client-seitige Software und können Identitäts- und kontextbezogene Zugriffsrichtlinien beinhalten.", "clientResourceTitle": "Private Ressourcen verwalten", "clientResourceDescription": "Erstelle und verwalte Ressourcen, die nur über einen verbundenen Client zugänglich sind", + "privateResourcesBannerTitle": "Zero-Trust Privater Zugang", + "privateResourcesBannerDescription": "Private Ressourcen nutzen Zero-Trust und stellen sicher, dass Benutzer und Maschinen nur auf Ressourcen zugreifen können, die Sie explizit gewähren. Verbinden Sie Benutzergeräte oder Maschinen-Clients, um auf diese Ressourcen über ein sicheres virtuelles privates Netzwerk zuzugreifen.", "resourcesSearch": "Suche Ressourcen...", "resourceAdd": "Ressource hinzufügen", "resourceErrorDelte": "Fehler beim Löschen der Ressource", @@ -157,9 +172,10 @@ "resourceMessageRemove": "Einmal entfernt, wird die Ressource nicht mehr zugänglich sein. Alle mit der Ressource verbundenen Ziele werden ebenfalls entfernt.", "resourceQuestionRemove": "Sind Sie sicher, dass Sie die Ressource aus der Organisation entfernen möchten?", "resourceHTTP": "HTTPS-Ressource", - "resourceHTTPDescription": "Proxy-Anfragen an die App über HTTPS unter Verwendung einer Subdomain oder einer Basis-Domain.", + "resourceHTTPDescription": "Proxy-Anfragen über HTTPS mit einem voll qualifizierten Domain-Namen.", "resourceRaw": "Direkte TCP/UDP Ressource (raw)", - "resourceRawDescription": "Proxy-Anfragen an die App über TCP/UDP mit einer Portnummer. Dies funktioniert nur, wenn Sites mit Knoten verbunden sind.", + "resourceRawDescription": "Proxy-Anfragen über rohes TCP/UDP mit einer Portnummer.", + "resourceRawDescriptionCloud": "Proxy-Anfragen über rohe TCP/UDP mit Portnummer. Benötigt Sites, um sich mit einem entfernten Knoten zu verbinden.", "resourceCreate": "Ressource erstellen", "resourceCreateDescription": "Folgen Sie den Schritten unten, um eine neue Ressource zu erstellen", "resourceSeeAll": "Alle Ressourcen anzeigen", @@ -186,6 +202,7 @@ "protocolSelect": "Wählen Sie ein Protokoll", "resourcePortNumber": "Portnummer", "resourcePortNumberDescription": "Die externe Portnummer für Proxy-Anfragen.", + "back": "Zurück", "cancel": "Abbrechen", "resourceConfig": "Konfiguration Snippets", "resourceConfigDescription": "Kopieren und fügen Sie diese Konfigurations-Snippets ein, um die TCP/UDP Ressource einzurichten", @@ -231,6 +248,17 @@ "orgErrorDeleteMessage": "Beim Löschen der Organisation ist ein Fehler aufgetreten.", "orgDeleted": "Organisation gelöscht", "orgDeletedMessage": "Die Organisation und ihre Daten wurden gelöscht.", + "deleteAccount": "Konto löschen", + "deleteAccountDescription": "Lösche dein Konto, alle Organisationen, die du besitzt, und alle Daten innerhalb dieser Organisationen. Dies kann nicht rückgängig gemacht werden.", + "deleteAccountButton": "Konto löschen", + "deleteAccountConfirmTitle": "Konto löschen", + "deleteAccountConfirmMessage": "Dies wird Ihr Konto dauerhaft löschen, alle Organisationen, die Sie besitzen, und alle Daten innerhalb dieser Organisationen. Dies kann nicht rückgängig gemacht werden.", + "deleteAccountConfirmString": "Konto löschen", + "deleteAccountSuccess": "Konto gelöscht", + "deleteAccountSuccessMessage": "Ihr Konto wurde gelöscht.", + "deleteAccountError": "Konto konnte nicht gelöscht werden", + "deleteAccountPreviewAccount": "Ihr Konto", + "deleteAccountPreviewOrgs": "Organisationen, die Sie besitzen (und ihre Daten)", "orgMissing": "Organisations-ID fehlt", "orgMissingMessage": "Einladung kann ohne Organisations-ID nicht neu generiert werden.", "accessUsersManage": "Benutzer verwalten", @@ -247,6 +275,8 @@ "accessRolesSearch": "Rollen suchen...", "accessRolesAdd": "Rolle hinzufügen", "accessRoleDelete": "Rolle löschen", + "accessApprovalsManage": "Genehmigungen verwalten", + "accessApprovalsDescription": "Zeige und verwalte ausstehende Genehmigungen für den Zugriff auf diese Organisation", "description": "Beschreibung", "inviteTitle": "Einladungen öffnen", "inviteDescription": "Einladungen für andere Benutzer verwalten, der Organisation beizutreten", @@ -440,6 +470,20 @@ "selectDuration": "Dauer auswählen", "selectResource": "Ressource auswählen", "filterByResource": "Nach Ressource filtern", + "selectApprovalState": "Genehmigungsstatus auswählen", + "filterByApprovalState": "Filtern nach Genehmigungsstatus", + "approvalListEmpty": "Keine Genehmigungen", + "approvalState": "Genehmigungsstatus", + "approvalLoadMore": "Mehr laden", + "loadingApprovals": "Genehmigungen werden geladen", + "approve": "Bestätigen", + "approved": "Genehmigt", + "denied": "Verweigert", + "deniedApproval": "Genehmigung verweigert", + "all": "Alle", + "deny": "Leugnen", + "viewDetails": "Details anzeigen", + "requestingNewDeviceApproval": "hat ein neues Gerät angefordert", "resetFilters": "Filter zurücksetzen", "totalBlocked": "Anfragen blockiert von Pangolin", "totalRequests": "Gesamte Anfragen", @@ -477,7 +521,7 @@ "proxyErrorTls": "Ungültiger TLS-Servername. Verwenden Sie das Domain-Namensformat oder speichern Sie leer, um den TLS-Servernamen zu entfernen.", "proxyEnableSSL": "SSL aktivieren", "proxyEnableSSLDescription": "Aktiviere SSL/TLS-Verschlüsselung für sichere HTTPS-Verbindungen zu den Zielen.", - "target": "Target", + "target": "Ziel", "configureTarget": "Ziele konfigurieren", "targetErrorFetch": "Fehler beim Abrufen der Ziele", "targetErrorFetchDescription": "Beim Abrufen der Ziele ist ein Fehler aufgetreten", @@ -501,7 +545,7 @@ "proxyErrorUpdateDescription": "Beim Aktualisieren der Proxy-Einstellungen ist ein Fehler aufgetreten", "targetAddr": "Host", "targetPort": "Port", - "targetProtocol": "Protokoll", + "targetProtocol": "Protokoll des Ziels", "targetTlsSettings": "Sicherheitskonfiguration", "targetTlsSettingsDescription": "SSL/TLS Einstellungen für die Ressource konfigurieren", "targetTlsSettingsAdvanced": "Erweiterte TLS-Einstellungen", @@ -510,8 +554,8 @@ "targetTlsSubmit": "Einstellungen speichern", "targets": "Ziel-Konfiguration", "targetsDescription": "Ziele zur Routenplanung für Backend-Dienste festlegen", - "targetStickySessions": "Sticky Sessions aktivieren", - "targetStickySessionsDescription": "Verbindungen für die gesamte Sitzung auf demselben Backend-Ziel halten.", + "targetStickySessions": "Sitzungspersistenz aktivieren", + "targetStickySessionsDescription": "Verbindungen während der gesamten Sitzung auf das gleiche Backend-Ziel leiten", "methodSelect": "Methode auswählen", "targetSubmit": "Ziel hinzufügen", "targetNoOne": "Diese Ressource hat keine Ziele. Fügen Sie ein Ziel hinzu, um zu konfigurieren, wo Anfragen an das Backend gesendet werden sollen.", @@ -522,8 +566,8 @@ "targetErrorInvalidIpDescription": "Bitte geben Sie eine gültige IP-Adresse oder einen Hostnamen ein", "targetErrorInvalidPort": "Ungültiger Port", "targetErrorInvalidPortDescription": "Bitte geben Sie eine gültige Portnummer ein", - "targetErrorNoSite": "Keine Site ausgewählt", - "targetErrorNoSiteDescription": "Bitte wähle eine Seite für das Ziel aus", + "targetErrorNoSite": "Kein Standort ausgewählt", + "targetErrorNoSiteDescription": "Bitte wähle einen Standort für das Ziel aus", "targetCreated": "Ziel erstellt", "targetCreatedDescription": "Ziel wurde erfolgreich erstellt", "targetErrorCreate": "Fehler beim Erstellen des Ziels", @@ -600,13 +644,14 @@ "none": "Keine", "unknown": "Unbekannt", "resources": "Ressourcen", - "resourcesDescription": "Ressourcen sind Proxies zu Anwendungen, die im privaten Netzwerk ausgeführt werden. Erstellen Sie eine Ressource für jeden HTTP/HTTPS oder rohen TCP/UDP-Dienst in Ihrem privaten Netzwerk. Jede Ressource muss mit einer Website verbunden sein, um eine private, sichere Verbindung durch einen verschlüsselten WireGuard-Tunnel zu aktivieren.", + "resourcesDescription": "Ressourcen sind Proxies zu Anwendungen, die im privaten Netzwerk ausgeführt werden. Erstellen Sie eine Ressource für jeden HTTP/HTTPS oder rohen TCP/UDP-Dienst in Ihrem privaten Netzwerk. Jede Ressource muss mit einem Standort verbunden sein, um eine private, sichere Verbindung durch einen verschlüsselten WireGuard-Tunnel zu aktivieren.", "resourcesWireGuardConnect": "Sichere Verbindung mit WireGuard-Verschlüsselung", "resourcesMultipleAuthenticationMethods": "Mehrere Authentifizierungsmethoden konfigurieren", "resourcesUsersRolesAccess": "Benutzer- und rollenbasierte Zugriffskontrolle", "resourcesErrorUpdate": "Fehler beim Umschalten der Ressource", "resourcesErrorUpdateDescription": "Beim Aktualisieren der Ressource ist ein Fehler aufgetreten", "access": "Zugriff", + "accessControl": "Zugriffskontrolle", "shareLink": "{resource} Freigabe-Link", "resourceSelect": "Ressource auswählen", "shareLinks": "Freigabe-Links", @@ -687,7 +732,7 @@ "resourceRoleDescription": "Administratoren haben immer Zugriff auf diese Ressource.", "resourceUsersRoles": "Zugriffskontrolle", "resourceUsersRolesDescription": "Konfigurieren Sie, welche Benutzer und Rollen diese Ressource besuchen können", - "resourceUsersRolesSubmit": "Benutzer & Rollen speichern", + "resourceUsersRolesSubmit": "Zugriffskontrollen speichern", "resourceWhitelistSave": "Erfolgreich gespeichert", "resourceWhitelistSaveDescription": "Whitelist-Einstellungen wurden gespeichert", "ssoUse": "Plattform SSO verwenden", @@ -719,22 +764,35 @@ "countries": "Länder", "accessRoleCreate": "Rolle erstellen", "accessRoleCreateDescription": "Erstellen Sie eine neue Rolle, um Benutzer zu gruppieren und ihre Berechtigungen zu verwalten.", + "accessRoleEdit": "Rolle bearbeiten", + "accessRoleEditDescription": "Rolleninformationen bearbeiten.", "accessRoleCreateSubmit": "Rolle erstellen", "accessRoleCreated": "Rolle erstellt", "accessRoleCreatedDescription": "Die Rolle wurde erfolgreich erstellt.", "accessRoleErrorCreate": "Fehler beim Erstellen der Rolle", "accessRoleErrorCreateDescription": "Beim Erstellen der Rolle ist ein Fehler aufgetreten.", + "accessRoleUpdateSubmit": "Rolle aktualisieren", + "accessRoleUpdated": "Rolle aktualisiert", + "accessRoleUpdatedDescription": "Die Rolle wurde erfolgreich aktualisiert.", + "accessApprovalUpdated": "Genehmigung bearbeitet", + "accessApprovalApprovedDescription": "Entscheidung für Genehmigungsanfrage setzen.", + "accessApprovalDeniedDescription": "Entscheidung für Genehmigungsanfrage ablehnen.", + "accessRoleErrorUpdate": "Fehler beim Aktualisieren der Rolle", + "accessRoleErrorUpdateDescription": "Beim Aktualisieren der Rolle ist ein Fehler aufgetreten.", + "accessApprovalErrorUpdate": "Genehmigung konnte nicht verarbeitet werden", + "accessApprovalErrorUpdateDescription": "Bei der Bearbeitung der Genehmigung ist ein Fehler aufgetreten.", "accessRoleErrorNewRequired": "Neue Rolle ist erforderlich", "accessRoleErrorRemove": "Fehler beim Entfernen der Rolle", "accessRoleErrorRemoveDescription": "Beim Entfernen der Rolle ist ein Fehler aufgetreten.", "accessRoleName": "Rollenname", - "accessRoleQuestionRemove": "Sie sind dabei, die Rolle {name} zu löschen. Diese Aktion kann nicht rückgängig gemacht werden.", + "accessRoleQuestionRemove": "Du bist dabei die Rolle `{name}` zu löschen. Du kannst diese Aktion nicht rückgängig machen.", "accessRoleRemove": "Rolle entfernen", "accessRoleRemoveDescription": "Eine Rolle aus der Organisation entfernen", "accessRoleRemoveSubmit": "Rolle entfernen", "accessRoleRemoved": "Rolle entfernt", "accessRoleRemovedDescription": "Die Rolle wurde erfolgreich entfernt.", "accessRoleRequiredRemove": "Bevor Sie diese Rolle löschen, wählen Sie bitte eine neue Rolle aus, zu der die bestehenden Mitglieder übertragen werden sollen.", + "network": "Netzwerk", "manage": "Verwalten", "sitesNotFound": "Keine Standorte gefunden.", "pangolinServerAdmin": "Server-Admin - Pangolin", @@ -750,6 +808,9 @@ "sitestCountIncrease": "Anzahl der Standorte erhöhen", "idpManage": "Identitätsanbieter verwalten", "idpManageDescription": "Identitätsanbieter im System anzeigen und verwalten", + "idpGlobalModeBanner": "Identitätsanbieter (IdPs) pro Organisation sind auf diesem Server deaktiviert. Es verwendet globale IdPs (geteilt über alle Organisationen). Verwalten Sie globale IdPs im Admin-Panel. Um IdPs pro Organisation zu aktivieren, bearbeiten Sie die Server-Konfiguration und setzen Sie den IdP-Modus auf org. Siehe Dokumentation. Wenn Sie weiterhin globale IdPs verwenden und diese in den Organisationseinstellungen verschwinden lassen wollen, setzen Sie den Modus explizit auf global in der Konfiguration.", + "idpGlobalModeBannerUpgradeRequired": "Identitätsanbieter (IdPs) pro Organisation sind auf diesem Server deaktiviert. Es verwendet globale IdPs (geteilt in allen Organisationen). Globale IdPs im Admin-Panelverwalten. Um Identitätsanbieter pro Organisation nutzen zu können, müssen Sie zur Enterprise Edition upgraden.", + "idpGlobalModeBannerLicenseRequired": "Identitätsanbieter (IdPs) pro Organisation sind auf diesem Server deaktiviert. Es verwendet globale IdPs (geteilt in allen Organisationen). Globale IdPs im Admin-Panelverwalten. Um Identitätsanbieter pro Organisation zu verwenden, ist eine Enterprise-Lizenz erforderlich.", "idpDeletedDescription": "Identitätsanbieter erfolgreich gelöscht", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Sind Sie sicher, dass Sie den Identitätsanbieter dauerhaft löschen möchten?", @@ -840,6 +901,7 @@ "orgPolicyConfig": "Zugriff für eine Organisation konfigurieren", "idpUpdatedDescription": "Identitätsanbieter erfolgreich aktualisiert", "redirectUrl": "Weiterleitungs-URL", + "orgIdpRedirectUrls": "Umleitungs-URLs", "redirectUrlAbout": "Über die Weiterleitungs-URL", "redirectUrlAboutDescription": "Dies ist die URL, zu der Benutzer nach der Authentifizierung umgeleitet werden. Sie müssen diese URL in den Einstellungen des Identity Providers konfigurieren.", "pangolinAuth": "Authentifizierung - Pangolin", @@ -943,13 +1005,13 @@ "passwordExpiryDescription": "Diese Organisation erfordert, dass Sie Ihr Passwort alle {maxDays} Tage ändern.", "changePasswordNow": "Passwort jetzt ändern", "pincodeAuth": "Authentifizierungscode", - "pincodeSubmit2": "Code absenden", + "pincodeSubmit2": "Code einreichen", "passwordResetSubmit": "Zurücksetzung anfordern", - "passwordResetAlreadyHaveCode": "Passwort zurücksetzen Code eingeben", + "passwordResetAlreadyHaveCode": "Code eingeben", "passwordResetSmtpRequired": "Bitte kontaktieren Sie Ihren Administrator", "passwordResetSmtpRequiredDescription": "Zum Zurücksetzen Ihres Passworts ist ein Passwort erforderlich. Bitte wenden Sie sich an Ihren Administrator.", "passwordBack": "Zurück zum Passwort", - "loginBack": "Zurück zur Anmeldung", + "loginBack": "Zurück zur Haupt-Login-Seite", "signup": "Registrieren", "loginStart": "Melden Sie sich an, um zu beginnen", "idpOidcTokenValidating": "OIDC-Token wird validiert", @@ -972,12 +1034,12 @@ "pangolinSetup": "Einrichtung - Pangolin", "orgNameRequired": "Organisationsname ist erforderlich", "orgIdRequired": "Organisations-ID ist erforderlich", + "orgIdMaxLength": "Organisations-ID darf höchstens 32 Zeichen lang sein", "orgErrorCreate": "Beim Erstellen der Organisation ist ein Fehler aufgetreten", "pageNotFound": "Seite nicht gefunden", "pageNotFoundDescription": "Hoppla! Die gesuchte Seite existiert nicht.", "overview": "Übersicht", "home": "Startseite", - "accessControl": "Zugriffskontrolle", "settings": "Einstellungen", "usersAll": "Alle Benutzer", "license": "Lizenz", @@ -1004,7 +1066,7 @@ "supportKeyDescription": "Kaufen Sie einen Unterstützer-Schlüssel, um uns bei der Weiterentwicklung von Pangolin für die Community zu helfen. Ihr Beitrag ermöglicht es uns, mehr Zeit in die Wartung und neue Funktionen für alle zu investieren. Wir werden dies nie für Paywalls nutzen. Dies ist unabhängig von der Commercial Edition.", "supportKeyPet": "Sie können auch Ihr eigenes Pangolin-Haustier adoptieren und kennenlernen!", "supportKeyPurchase": "Zahlungen werden über GitHub abgewickelt. Danach können Sie Ihren Schlüssel auf", - "supportKeyPurchaseLink": "unserer Website", + "supportKeyPurchaseLink": "Unserer Website", "supportKeyPurchase2": "abrufen und hier einlösen.", "supportKeyLearnMore": "Mehr erfahren.", "supportKeyOptions": "Bitte wählen Sie die Option, die am besten zu Ihnen passt.", @@ -1035,15 +1097,24 @@ "updateOrgUser": "Org Benutzer aktualisieren", "createOrgUser": "Org Benutzer erstellen", "actionUpdateOrg": "Organisation aktualisieren", + "actionRemoveInvitation": "Einladung entfernen", "actionUpdateUser": "Benutzer aktualisieren", "actionGetUser": "Benutzer abrufen", "actionGetOrgUser": "Organisationsbenutzer abrufen", "actionListOrgDomains": "Organisationsdomains auflisten", + "actionGetDomain": "Domain abrufen", + "actionCreateOrgDomain": "Domain erstellen", + "actionUpdateOrgDomain": "Domain aktualisieren", + "actionDeleteOrgDomain": "Domain löschen", + "actionGetDNSRecords": "DNS-Einträge abrufen", + "actionRestartOrgDomain": "Domain neu starten", "actionCreateSite": "Standort erstellen", "actionDeleteSite": "Standort löschen", "actionGetSite": "Standort abrufen", "actionListSites": "Standorte auflisten", "actionApplyBlueprint": "Blueprint anwenden", + "actionListBlueprints": "Blaupausen anzeigen", + "actionGetBlueprint": "Erhalte Blaupause", "setupToken": "Setup-Token", "setupTokenDescription": "Geben Sie das Setup-Token von der Serverkonsole ein.", "setupTokenRequired": "Setup-Token ist erforderlich", @@ -1077,6 +1148,7 @@ "actionRemoveUser": "Benutzer entfernen", "actionListUsers": "Benutzer auflisten", "actionAddUserRole": "Benutzerrolle hinzufügen", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Zugriffstoken generieren", "actionDeleteAccessToken": "Zugriffstoken löschen", "actionListAccessTokens": "Zugriffstoken auflisten", @@ -1104,30 +1176,35 @@ "actionUpdateIdpOrg": "IDP-Organisation aktualisieren", "actionCreateClient": "Client erstellen", "actionDeleteClient": "Client löschen", + "actionArchiveClient": "Client archivieren", + "actionUnarchiveClient": "Client dearchivieren", + "actionBlockClient": "Client sperren", + "actionUnblockClient": "Client entsperren", "actionUpdateClient": "Client aktualisieren", "actionListClients": "Clients auflisten", "actionGetClient": "Clients abrufen", - "actionCreateSiteResource": "Site-Ressource erstellen", - "actionDeleteSiteResource": "Site-Ressource löschen", - "actionGetSiteResource": "Site-Ressource abrufen", - "actionListSiteResources": "Site-Ressourcen auflisten", - "actionUpdateSiteResource": "Site-Ressource aktualisieren", + "actionCreateSiteResource": "Standort Ressource erstellen", + "actionDeleteSiteResource": "Standort Ressource löschen", + "actionGetSiteResource": "Standort Ressource abrufen", + "actionListSiteResources": "Standort Ressource auflisten", + "actionUpdateSiteResource": "Standort Ressource aktualisieren", "actionListInvitations": "Einladungen auflisten", "actionExportLogs": "Logs exportieren", "actionViewLogs": "Logs anzeigen", "noneSelected": "Keine ausgewählt", "orgNotFound2": "Keine Organisationen gefunden.", - "searchProgress": "Suche...", + "searchPlaceholder": "Suche...", + "emptySearchOptions": "Keine Optionen gefunden", "create": "Erstellen", "orgs": "Organisationen", - "loginError": "Beim Anmelden ist ein Fehler aufgetreten", - "loginRequiredForDevice": "Anmeldung ist erforderlich, um Ihr Gerät zu authentifizieren.", + "loginError": "Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es erneut.", + "loginRequiredForDevice": "Anmeldung ist für Ihr Gerät erforderlich.", "passwordForgot": "Passwort vergessen?", "otpAuth": "Zwei-Faktor-Authentifizierung", "otpAuthDescription": "Geben Sie den Code aus Ihrer Authenticator-App oder einen Ihrer einmaligen Backup-Codes ein.", "otpAuthSubmit": "Code absenden", "idpContinue": "Oder weiter mit", - "otpAuthBack": "Zurück zur Anmeldung", + "otpAuthBack": "Zurück zum Passwort", "navbar": "Navigationsmenü", "navbarDescription": "Hauptnavigationsmenü für die Anwendung", "navbarDocsLink": "Dokumentation", @@ -1175,11 +1252,13 @@ "sidebarOverview": "Übersicht", "sidebarHome": "Zuhause", "sidebarSites": "Standorte", + "sidebarApprovals": "Genehmigungsanfragen", "sidebarResources": "Ressourcen", "sidebarProxyResources": "Öffentlich", "sidebarClientResources": "Privat", "sidebarAccessControl": "Zugriffskontrolle", "sidebarLogsAndAnalytics": "Protokolle & Analysen", + "sidebarTeam": "Team", "sidebarUsers": "Benutzer", "sidebarAdmin": "Admin", "sidebarInvitations": "Einladungen", @@ -1191,15 +1270,17 @@ "sidebarIdentityProviders": "Identitätsanbieter", "sidebarLicense": "Lizenz", "sidebarClients": "Clients", - "sidebarUserDevices": "Benutzer", + "sidebarUserDevices": "Benutzer-Geräte", "sidebarMachineClients": "Maschinen", "sidebarDomains": "Domänen", - "sidebarGeneral": "Allgemein", + "sidebarGeneral": "Verwalten", "sidebarLogAndAnalytics": "Log & Analytik", - "sidebarBluePrints": "Baupläne", + "sidebarBluePrints": "Blaupausen", "sidebarOrganization": "Organisation", + "sidebarManagement": "Management", + "sidebarBillingAndLicenses": "Abrechnung & Lizenzen", "sidebarLogsAnalytics": "Analytik", - "blueprints": "Baupläne", + "blueprints": "Blaupausen", "blueprintsDescription": "Deklarative Konfigurationen anwenden und vorherige Abläufe anzeigen", "blueprintAdd": "Blueprint hinzufügen", "blueprintGoBack": "Alle Blueprints ansehen", @@ -1219,7 +1300,6 @@ "parsedContents": "Analysierte Inhalte (Nur lesen)", "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}", "selectContainerDescription": "Wählen Sie einen Container, der als Hostname für dieses Ziel verwendet werden soll. Klicken Sie auf einen Port, um einen Port zu verwenden.", @@ -1263,6 +1343,7 @@ "setupErrorCreateAdmin": "Beim Erstellen des Server-Admin-Kontos ist ein Fehler aufgetreten.", "certificateStatus": "Zertifikatsstatus", "loading": "Laden", + "loadingAnalytics": "Analytik wird geladen", "restart": "Neustart", "domains": "Domänen", "domainsDescription": "Erstellen und verwalten der in der Organisation verfügbaren Domänen", @@ -1290,6 +1371,7 @@ "refreshError": "Datenaktualisierung fehlgeschlagen", "verified": "Verifiziert", "pending": "Ausstehend", + "pendingApproval": "Ausstehende Genehmigung", "sidebarBilling": "Abrechnung", "billing": "Abrechnung", "orgBillingDescription": "Zahlungsinformationen und Abonnements verwalten", @@ -1308,8 +1390,11 @@ "accountSetupSuccess": "Kontoeinrichtung abgeschlossen! Willkommen bei Pangolin!", "documentation": "Dokumentation", "saveAllSettings": "Alle Einstellungen speichern", + "saveResourceTargets": "Ziele speichern", + "saveResourceHttp": "Proxy-Einstellungen speichern", + "saveProxyProtocol": "Proxy-Protokolleinstellungen speichern", "settingsUpdated": "Einstellungen aktualisiert", - "settingsUpdatedDescription": "Alle Einstellungen wurden erfolgreich aktualisiert", + "settingsUpdatedDescription": "Einstellungen erfolgreich aktualisiert", "settingsErrorUpdate": "Einstellungen konnten nicht aktualisiert werden", "settingsErrorUpdateDescription": "Beim Aktualisieren der Einstellungen ist ein Fehler aufgetreten", "sidebarCollapse": "Zusammenklappen", @@ -1342,6 +1427,7 @@ "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Mehr anzeigen", "regionSelectorTitle": "Region auswählen", + "domainPickerRemoteExitNodeWarning": "Angegebene Domains werden nicht unterstützt, wenn sich Websites mit externen Exit-Knoten verbinden. Damit Ressourcen auf entfernten Knoten verfügbar sind, verwenden Sie stattdessen eine eigene Domain.", "regionSelectorInfo": "Das Auswählen einer Region hilft uns, eine bessere Leistung für Ihren Standort bereitzustellen. Sie müssen sich nicht in derselben Region wie Ihr Server befinden.", "regionSelectorPlaceholder": "Wähle eine Region", "regionSelectorComingSoon": "Kommt bald", @@ -1351,10 +1437,11 @@ "billingUsageLimitsOverview": "Übersicht über Nutzungsgrenzen", "billingMonitorUsage": "Überwachen Sie Ihren Verbrauch im Vergleich zu konfigurierten Grenzwerten. Wenn Sie eine Erhöhung der Limits benötigen, kontaktieren Sie uns bitte support@pangolin.net.", "billingDataUsage": "Datenverbrauch", - "billingOnlineTime": "Online-Zeit der Seite", - "billingUsers": "Aktive Benutzer", - "billingDomains": "Aktive Domains", - "billingRemoteExitNodes": "Aktive selbstgehostete Nodes", + "billingSites": "Seiten", + "billingUsers": "Benutzergeräte", + "billingDomains": "Domänen", + "billingOrganizations": "Orden", + "billingRemoteExitNodes": "Entfernte Knoten", "billingNoLimitConfigured": "Kein Limit konfiguriert", "billingEstimatedPeriod": "Geschätzter Abrechnungszeitraum", "billingIncludedUsage": "Inklusive Nutzung", @@ -1379,15 +1466,24 @@ "billingFailedToGetPortalUrl": "Fehler beim Abrufen der Portal-URL", "billingPortalError": "Portalfehler", "billingDataUsageInfo": "Wenn Sie mit der Cloud verbunden sind, werden alle Daten über Ihre sicheren Tunnel belastet. Dies schließt eingehenden und ausgehenden Datenverkehr über alle Ihre Websites ein. Wenn Sie Ihr Limit erreichen, werden Ihre Seiten die Verbindung trennen, bis Sie Ihr Paket upgraden oder die Nutzung verringern. Daten werden nicht belastet, wenn Sie Knoten verwenden.", - "billingOnlineTimeInfo": "Sie werden belastet, abhängig davon, wie lange Ihre Seiten mit der Cloud verbunden bleiben. Zum Beispiel 44.640 Minuten entspricht einer Site, die 24 Stunden am Tag des Monats läuft. Wenn Sie Ihr Limit erreichen, werden Ihre Seiten die Verbindung trennen, bis Sie Ihr Paket upgraden oder die Nutzung verringern. Die Zeit wird nicht belastet, wenn Sie Knoten verwenden.", - "billingUsersInfo": "Sie werden für jeden Benutzer in der Organisation berechnet. Die Abrechnung wird täglich anhand der Anzahl der aktiven Benutzerkonten in Ihrer Org berechnet.", - "billingDomainInfo": "Sie werden für jede Domain in der Organisation berechnet. Die Abrechnung wird täglich anhand der Anzahl der aktiven Domain-Konten in Ihrer Org berechnet.", - "billingRemoteExitNodesInfo": "Sie werden für jeden verwalteten Knoten in der Organisation berechnet. Die Abrechnung wird täglich anhand der Anzahl der aktiven verwalteten Knoten in Ihrer Org berechnet.", + "billingSInfo": "Anzahl der Sites die Sie verwenden können", + "billingUsersInfo": "Wie viele Benutzer Sie verwenden können", + "billingDomainInfo": "Wie viele Domains Sie verwenden können", + "billingRemoteExitNodesInfo": "Wie viele entfernte Knoten Sie verwenden können", + "billingLicenseKeys": "Lizenzschlüssel", + "billingLicenseKeysDescription": "Verwalten Sie Ihre Lizenzschlüssel Abonnements", + "billingLicenseSubscription": "Lizenzabonnement", + "billingInactive": "Inaktiv", + "billingLicenseItem": "Lizenz-Element", + "billingQuantity": "Menge", + "billingTotal": "gesamt", + "billingModifyLicenses": "Lizenzabonnement ändern", "domainNotFound": "Domain nicht gefunden", "domainNotFoundDescription": "Diese Ressource ist deaktiviert, weil die Domain nicht mehr in unserem System existiert. Bitte setzen Sie eine neue Domain für diese Ressource.", "failed": "Fehlgeschlagen", "createNewOrgDescription": "Eine neue Organisation erstellen", "organization": "Organisation", + "primary": "Primär", "port": "Port", "securityKeyManage": "Sicherheitsschlüssel verwalten", "securityKeyDescription": "Sicherheitsschlüssel für passwortlose Authentifizierung hinzufügen oder entfernen", @@ -1403,7 +1499,7 @@ "securityKeyRemoveSuccess": "Sicherheitsschlüssel erfolgreich entfernt", "securityKeyRemoveError": "Fehler beim Entfernen des Sicherheitsschlüssels", "securityKeyLoadError": "Fehler beim Laden der Sicherheitsschlüssel", - "securityKeyLogin": "Mit dem Sicherheitsschlüssel fortfahren", + "securityKeyLogin": "Sicherheitsschlüssel verwenden", "securityKeyAuthError": "Fehler bei der Authentifizierung mit Sicherheitsschlüssel", "securityKeyRecommendation": "Erwägen Sie die Registrierung eines weiteren Sicherheitsschlüssels auf einem anderen Gerät, um sicherzustellen, dass Sie sich nicht aus Ihrem Konto aussperren.", "registering": "Registrierung...", @@ -1459,11 +1555,47 @@ "resourcePortRequired": "Portnummer ist für nicht-HTTP-Ressourcen erforderlich", "resourcePortNotAllowed": "Portnummer sollte für HTTP-Ressourcen nicht gesetzt werden", "billingPricingCalculatorLink": "Preisrechner", + "billingYourPlan": "Ihr Plan", + "billingViewOrModifyPlan": "Zeige oder ändere dein aktuelles Paket", + "billingViewPlanDetails": "Plan Details anzeigen", + "billingUsageAndLimits": "Nutzung und Einschränkungen", + "billingViewUsageAndLimits": "Schau dir die Grenzen und die aktuelle Nutzung deines Plans an", + "billingCurrentUsage": "Aktuelle Nutzung", + "billingMaximumLimits": "Maximale Grenzen", + "billingRemoteNodes": "Entfernte Knoten", + "billingUnlimited": "Unbegrenzt", + "billingPaidLicenseKeys": "Bezahlte Lizenzschlüssel", + "billingManageLicenseSubscription": "Verwalten Sie Ihr Abonnement für kostenpflichtige selbstgehostete Lizenzschlüssel", + "billingCurrentKeys": "Aktuelle Tasten", + "billingModifyCurrentPlan": "Aktuelles Paket ändern", + "billingConfirmUpgrade": "Upgrade bestätigen", + "billingConfirmDowngrade": "Downgrade bestätigen", + "billingConfirmUpgradeDescription": "Sie sind dabei, Ihr Paket zu aktualisieren. Schauen Sie sich die neuen Limits und Preise unten an.", + "billingConfirmDowngradeDescription": "Sie sind dabei, Ihren Plan herunterzustufen. Überprüfen Sie die neuen Limits und Preise unten.", + "billingPlanIncludes": "Plan beinhaltet", + "billingProcessing": "Verarbeitung...", + "billingConfirmUpgradeButton": "Upgrade bestätigen", + "billingConfirmDowngradeButton": "Downgrade bestätigen", + "billingLimitViolationWarning": "Nutzung überschreitet neue Plan-Grenzen", + "billingLimitViolationDescription": "Ihre aktuelle Nutzung überschreitet die Grenzen dieses Plans. Nach dem Downgrade werden alle Aktionen deaktiviert, bis Sie die Nutzung innerhalb der neuen Grenzen reduzieren. Bitte überprüfen Sie die Funktionen unten, die derzeit über den Grenzen liegen. Grenzwerte verletzen:", + "billingFeatureLossWarning": "Verfügbarkeitshinweis", + "billingFeatureLossDescription": "Durch Herabstufung werden Funktionen, die im neuen Paket nicht verfügbar sind, automatisch deaktiviert. Einige Einstellungen und Konfigurationen können verloren gehen. Bitte überprüfen Sie die Preismatrix um zu verstehen, welche Funktionen nicht mehr verfügbar sein werden.", + "billingUsageExceedsLimit": "Aktuelle Nutzung ({current}) überschreitet das Limit ({limit})", + "billingPastDueTitle": "Zahlung vergangene Fälligkeit", + "billingPastDueDescription": "Ihre Zahlung ist abgelaufen. Bitte aktualisieren Sie Ihre Zahlungsmethode, um die aktuellen Funktionen Ihres Pakets weiter zu nutzen. Wenn nicht geklärt, wird Ihr Abonnement abgebrochen und Sie werden auf die kostenlose Stufe zurückgekehrt.", + "billingUnpaidTitle": "Unbezahltes Abonnement", + "billingUnpaidDescription": "Dein Abonnement ist unbezahlt und du wurdest auf die kostenlose Stufe zurückgekehrt. Bitte aktualisiere deine Zahlungsmethode, um dein Abonnement wiederherzustellen.", + "billingIncompleteTitle": "Zahlung unvollständig", + "billingIncompleteDescription": "Ihre Zahlung ist unvollständig. Bitte schließen Sie den Zahlungsvorgang ab, um Ihr Abonnement zu aktivieren.", + "billingIncompleteExpiredTitle": "Zahlung abgelaufen", + "billingIncompleteExpiredDescription": "Deine Zahlung wurde nie abgeschlossen und ist abgelaufen. Du wurdest zur kostenlosen Stufe zurückgekehrt. Bitte melde dich erneut an, um den Zugriff auf kostenpflichtige Funktionen wiederherzustellen.", + "billingManageSubscription": "Verwalten Sie Ihr Abonnement", + "billingResolvePaymentIssue": "Bitte beheben Sie Ihr Zahlungsproblem vor dem Upgrade oder Herabstufen", "signUpTerms": { "IAgreeToThe": "Ich stimme den", "termsOfService": "Nutzungsbedingungen zu", "and": "und", - "privacyPolicy": "Datenschutzrichtlinie" + "privacyPolicy": "datenschutzrichtlinie." }, "signUpMarketing": { "keepMeInTheLoop": "Halten Sie mich auf dem Laufenden mit Neuigkeiten, Updates und neuen Funktionen per E-Mail." @@ -1508,6 +1640,7 @@ "addNewTarget": "Neues Ziel hinzufügen", "targetsList": "Ziel-Liste", "advancedMode": "Erweiterter Modus", + "advancedSettings": "Erweiterte Einstellungen", "targetErrorDuplicateTargetFound": "Doppeltes Ziel gefunden", "healthCheckHealthy": "Gesund", "healthCheckUnhealthy": "Ungesund", @@ -1529,6 +1662,26 @@ "IntervalSeconds": "Gesunder Intervall", "timeoutSeconds": "Timeout (Sek.)", "timeIsInSeconds": "Zeit ist in Sekunden", + "requireDeviceApproval": "Gerätegenehmigungen erforderlich", + "requireDeviceApprovalDescription": "Benutzer mit dieser Rolle benötigen neue Geräte, die von einem Administrator genehmigt wurden, bevor sie sich verbinden und auf Ressourcen zugreifen können.", + "sshAccess": "SSH-Zugriff", + "roleAllowSsh": "SSH erlauben", + "roleAllowSshAllow": "Erlauben", + "roleAllowSshDisallow": "Nicht zulassen", + "roleAllowSshDescription": "Benutzern mit dieser Rolle erlauben, sich über SSH mit Ressourcen zu verbinden. Wenn deaktiviert, kann die Rolle keinen SSH-Zugriff verwenden.", + "sshSudoMode": "Sudo-Zugriff", + "sshSudoModeNone": "Keine", + "sshSudoModeNoneDescription": "Benutzer kann keine Befehle mit sudo ausführen.", + "sshSudoModeFull": "Volles Sudo", + "sshSudoModeFullDescription": "Benutzer kann jeden Befehl mit sudo ausführen.", + "sshSudoModeCommands": "Befehle", + "sshSudoModeCommandsDescription": "Benutzer kann nur die angegebenen Befehle mit sudo ausführen.", + "sshSudo": "sudo erlauben", + "sshSudoCommands": "Sudo-Befehle", + "sshSudoCommandsDescription": "Kommagetrennte Liste von Befehlen, die der Benutzer mit sudo ausführen darf.", + "sshCreateHomeDir": "Home-Verzeichnis erstellen", + "sshUnixGroups": "Unix-Gruppen", + "sshUnixGroupsDescription": "Durch Komma getrennte Unix-Gruppen, um den Benutzer auf dem Zielhost hinzuzufügen.", "retryAttempts": "Wiederholungsversuche", "expectedResponseCodes": "Erwartete Antwortcodes", "expectedResponseCodesDescription": "HTTP-Statuscode, der einen gesunden Zustand anzeigt. Wenn leer gelassen, wird 200-300 als gesund angesehen.", @@ -1561,7 +1714,7 @@ "domainPickerNotWorkSelfHosted": "Hinweis: Kostenlose bereitgestellte Domains sind derzeit nicht für selbstgehostete Instanzen verfügbar.", "resourceDomain": "Domäne", "resourceEditDomain": "Domain bearbeiten", - "siteName": "Site-Name", + "siteName": "Standortname", "proxyPort": "Port", "resourcesTableProxyResources": "Öffentlich", "resourcesTableClientResources": "Privat", @@ -1569,6 +1722,8 @@ "resourcesTableNoInternalResourcesFound": "Keine internen Ressourcen gefunden.", "resourcesTableDestination": "Ziel", "resourcesTableAlias": "Alias", + "resourcesTableAliasAddress": "Alias-Adresse", + "resourcesTableAliasAddressInfo": "Diese Adresse ist Teil des Utility-Subnetzes der Organisation. Sie wird verwendet, um Alias-Einträge mit interner DNS-Auflösung aufzulösen.", "resourcesTableClients": "Clients", "resourcesTableAndOnlyAccessibleInternally": "und sind nur intern zugänglich, wenn mit einem Client verbunden.", "resourcesTableNoTargets": "Keine Ziele", @@ -1582,7 +1737,7 @@ "editInternalResourceDialogResourceProperties": "Ressourceneigenschaften", "editInternalResourceDialogName": "Name", "editInternalResourceDialogProtocol": "Protokoll", - "editInternalResourceDialogSitePort": "Site-Port", + "editInternalResourceDialogSitePort": "Standort Port", "editInternalResourceDialogTargetConfiguration": "Zielkonfiguration", "editInternalResourceDialogCancel": "Abbrechen", "editInternalResourceDialogSaveResource": "Ressource speichern", @@ -1608,21 +1763,20 @@ "editInternalResourceDialogDestinationCidrDescription": "Der CIDR-Bereich der Ressource im Netzwerk der Website.", "editInternalResourceDialogAlias": "Alias", "editInternalResourceDialogAliasDescription": "Ein optionaler interner DNS-Alias für diese Ressource.", - "createInternalResourceDialogNoSitesAvailable": "Keine Sites verfügbar", - "createInternalResourceDialogNoSitesAvailableDescription": "Sie müssen mindestens eine Newt-Site mit einem konfigurierten Subnetz haben, um interne Ressourcen zu erstellen.", + "createInternalResourceDialogNoSitesAvailable": "Kein Standort verfügbar", + "createInternalResourceDialogNoSitesAvailableDescription": "Sie müssen mindestens ein Newt-Standort mit einem konfigurierten Subnetz haben, um interne Ressourcen zu erstellen.", "createInternalResourceDialogClose": "Schließen", "createInternalResourceDialogCreateClientResource": "Private Ressource erstellen", "createInternalResourceDialogCreateClientResourceDescription": "Erstelle eine neue Ressource, die nur für Clients zugänglich ist, die mit der Organisation verbunden sind", "createInternalResourceDialogResourceProperties": "Ressourceneigenschaften", "createInternalResourceDialogName": "Name", "createInternalResourceDialogSite": "Standort", - "createInternalResourceDialogSelectSite": "Standort auswählen...", - "createInternalResourceDialogSearchSites": "Sites durchsuchen...", - "createInternalResourceDialogNoSitesFound": "Keine Standorte gefunden.", + "selectSite": "Standort auswählen...", + "noSitesFound": "Keine Standorte gefunden.", "createInternalResourceDialogProtocol": "Protokoll", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", - "createInternalResourceDialogSitePort": "Site-Port", + "createInternalResourceDialogSitePort": "Standort Port", "createInternalResourceDialogSitePortDescription": "Verwenden Sie diesen Port, um bei Verbindung mit einem Client auf die Ressource an der Site zuzugreifen.", "createInternalResourceDialogTargetConfiguration": "Zielkonfiguration", "createInternalResourceDialogDestinationIPDescription": "Die IP-Adresse oder Hostname Adresse der Ressource im Netzwerk der Website.", @@ -1635,7 +1789,7 @@ "createInternalResourceDialogFailedToCreateInternalResource": "Interne Ressource konnte nicht erstellt werden", "createInternalResourceDialogNameRequired": "Name ist erforderlich", "createInternalResourceDialogNameMaxLength": "Der Name darf nicht länger als 255 Zeichen sein", - "createInternalResourceDialogPleaseSelectSite": "Bitte wählen Sie eine Site aus", + "createInternalResourceDialogPleaseSelectSite": "Bitte wählen Sie einen Standort aus", "createInternalResourceDialogProxyPortMin": "Proxy-Port muss mindestens 1 sein", "createInternalResourceDialogProxyPortMax": "Proxy-Port muss kleiner als 65536 sein", "createInternalResourceDialogInvalidIPAddressFormat": "Ungültiges IP-Adressformat", @@ -1653,12 +1807,12 @@ "createInternalResourceDialogAliasDescription": "Ein optionaler interner DNS-Alias für diese Ressource.", "siteConfiguration": "Konfiguration", "siteAcceptClientConnections": "Clientverbindungen akzeptieren", - "siteAcceptClientConnectionsDescription": "Erlaube Benutzer-Geräten und Clients Zugriff auf Ressourcen auf dieser Website. Dies kann später geändert werden.", - "siteAddress": "Site-Adresse (Erweitert)", - "siteAddressDescription": "Die interne Adresse der Website. Muss in das Subnetz der Organisation fallen.", - "siteNameDescription": "Der Anzeigename der Site, der später geändert werden kann.", + "siteAcceptClientConnectionsDescription": "Erlaube Benutzer-Geräten und Clients Zugriff auf Ressourcen auf diesem Standort. Dies kann später geändert werden.", + "siteAddress": "Standort-Adresse (Erweitert)", + "siteAddressDescription": "Die interne Adresse des Standorts. Sie muss im Subnetz der Organisation liegen.", + "siteNameDescription": "Der Anzeigename des Standorts, kann später geändert werden", "autoLoginExternalIdp": "Automatische Anmeldung mit externem IDP", - "autoLoginExternalIdpDescription": "Leiten Sie den Benutzer sofort zur Authentifizierung an den externen IDP weiter.", + "autoLoginExternalIdpDescription": "Den Nutzer zur Authentifizierung sofort an den externen Identifikationsanbieter weiterleiten.", "selectIdp": "IDP auswählen", "selectIdpPlaceholder": "Wählen Sie einen IDP...", "selectIdpRequired": "Bitte wählen Sie einen IDP aus, wenn automatische Anmeldung aktiviert ist.", @@ -1670,7 +1824,7 @@ "autoLoginErrorNoRedirectUrl": "Keine Weiterleitungs-URL vom Identitätsanbieter erhalten.", "autoLoginErrorGeneratingUrl": "Fehler beim Generieren der Authentifizierungs-URL.", "remoteExitNodeManageRemoteExitNodes": "Entfernte Knoten", - "remoteExitNodeDescription": "Self-Hoster einen oder mehrere entfernte Knoten, um die Netzwerkverbindung zu erweitern und die Abhängigkeit von der Cloud zu verringern", + "remoteExitNodeDescription": "Hosten Sie selbst Ihr eigenes Relay und ihre Server Nodes", "remoteExitNodes": "Knoten", "searchRemoteExitNodes": "Knoten suchen...", "remoteExitNodeAdd": "Knoten hinzufügen", @@ -1680,20 +1834,22 @@ "remoteExitNodeConfirmDelete": "Löschknoten bestätigen", "remoteExitNodeDelete": "Knoten löschen", "sidebarRemoteExitNodes": "Entfernte Knoten", + "remoteExitNodeId": "ID", + "remoteExitNodeSecretKey": "Geheimnis", "remoteExitNodeCreate": { - "title": "Knoten erstellen", - "description": "Erstelle einen neuen Knoten, um die Netzwerkverbindung zu erweitern", + "title": "Erstelle Remote Node", + "description": "Erstelle einen neues selbst gehostetes Relay und ihre Proxyserver Nodes", "viewAllButton": "Alle Knoten anzeigen", "strategy": { "title": "Erstellungsstrategie", - "description": "Wählen Sie diese Option, um den Knoten manuell zu konfigurieren oder neue Zugangsdaten zu generieren.", + "description": "Wählen Sie, wie Sie den entfernten Node erstellen möchten", "adopt": { "title": "Node übernehmen", "description": "Wählen Sie dies, wenn Sie bereits die Anmeldedaten für den Knoten haben." }, "generate": { "title": "Schlüssel generieren", - "description": "Wählen Sie dies, wenn Sie neue Schlüssel für den Knoten generieren möchten" + "description": "Wählen Sie dies, wenn Sie neue Schlüssel für den Node generieren möchten." } }, "adopt": { @@ -1730,7 +1886,7 @@ "remoteExitNodeSelectionDescription": "Wählen Sie einen Knoten aus, durch den Traffic für diese lokale Seite geleitet werden soll", "remoteExitNodeRequired": "Ein Knoten muss für lokale Seiten ausgewählt sein", "noRemoteExitNodesAvailable": "Keine Knoten verfügbar", - "noRemoteExitNodesAvailableDescription": "Für diese Organisation sind keine Knoten verfügbar. Erstellen Sie zuerst einen Knoten, um lokale Sites zu verwenden.", + "noRemoteExitNodesAvailableDescription": "Für diese Organisation sind keine Knoten verfügbar. Erstellen Sie zuerst einen Knoten, um lokale Standorte zu verwenden.", "exitNode": "Exit-Node", "country": "Land", "rulesMatchCountry": "Derzeit basierend auf der Quell-IP", @@ -1840,9 +1996,33 @@ "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subnetz", "subnetDescription": "Das Subnetz für die Netzwerkkonfiguration dieser Organisation.", - "authPage": "Auth Seite", - "authPageDescription": "Konfigurieren Sie die Authentifizierungsseite für die Organisation", + "customDomain": "Eigene Domain", + "authPage": "Authentifizierungs-Seiten", + "authPageDescription": "Legen Sie eine eigene Domain für die Authentifizierungsseiten der Organisation fest", "authPageDomain": "Domain der Auth Seite", + "authPageBranding": "Eigenes Branding", + "authPageBrandingDescription": "Konfigurieren Sie das Branding, das auf Authentifizierungsseiten für diese Organisation erscheint", + "authPageBrandingUpdated": "Branding der Authentifizierungsseiten erfolgreich aktualisiert", + "authPageBrandingRemoved": "Branding der Authentifizierungsseiten erfolgreich entfernt", + "authPageBrandingRemoveTitle": "Authentifizierungsseiten Branding entfernen", + "authPageBrandingQuestionRemove": "Sind Sie sicher, dass Sie das Branding für Authentifizierungsseiten entfernen möchten?", + "authPageBrandingDeleteConfirm": "Branding löschen bestätigen", + "brandingLogoURL": "Logo URL", + "brandingLogoURLOrPath": "Logo-URL oder Pfad", + "brandingLogoPathDescription": "Geben Sie eine URL oder einen lokalen Pfad ein.", + "brandingLogoURLDescription": "Geben Sie eine öffentlich zugängliche URL zu Ihrem Logobild ein.", + "brandingPrimaryColor": "Primär-Farbe", + "brandingLogoWidth": "Breite (px)", + "brandingLogoHeight": "Höhe (px)", + "brandingOrgTitle": "Titel für die Authentifizierungsseite der Organisation", + "brandingOrgDescription": "{orgName} wird durch den Namen der Organisation ersetzt", + "brandingOrgSubtitle": "Untertitel für die Authentifizierungsseite der Organisation", + "brandingResourceTitle": "Titel für die Ressourcen-Authentifizierungsseite", + "brandingResourceSubtitle": "Untertitel für Ressourcen-Authentifizierungsseite", + "brandingResourceDescription": "{resourceName} wird durch den Namen der Organisation ersetzt", + "saveAuthPageDomain": "Domain speichern", + "saveAuthPageBranding": "Branding speichern", + "removeAuthPageBranding": "Branding entfernen", "noDomainSet": "Keine Domain gesetzt", "changeDomain": "Domain ändern", "selectDomain": "Domain auswählen", @@ -1851,7 +2031,7 @@ "setAuthPageDomain": "Domain der Auth Seite festlegen", "failedToFetchCertificate": "Zertifikat konnte nicht abgerufen werden", "failedToRestartCertificate": "Zertifikat konnte nicht neu gestartet werden", - "addDomainToEnableCustomAuthPages": "Füge eine Domain hinzu, um benutzerdefinierte Authentifizierungsseiten für die Organisation zu aktivieren", + "addDomainToEnableCustomAuthPages": "Benutzer können über diese Domain auf die Login-Seite der Organisation zugreifen und die Ressourcen-Authentifizierung durchführen.", "selectDomainForOrgAuthPage": "Wählen Sie eine Domain für die Authentifizierungsseite der Organisation", "domainPickerProvidedDomain": "Angegebene Domain", "domainPickerFreeProvidedDomain": "Kostenlose Domain", @@ -1866,11 +2046,27 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" konnte nicht für {domain} gültig gemacht werden.", "domainPickerSubdomainSanitized": "Subdomain bereinigt", "domainPickerSubdomainCorrected": "\"{sub}\" wurde korrigiert zu \"{sanitized}\"", - "orgAuthSignInTitle": "In der Organisation anmelden", + "orgAuthSignInTitle": "Organisations-Anmeldung", "orgAuthChooseIdpDescription": "Wähle deinen Identitätsanbieter um fortzufahren", "orgAuthNoIdpConfigured": "Diese Organisation hat keine Identitätsanbieter konfiguriert. Sie können sich stattdessen mit Ihrer Pangolin-Identität anmelden.", "orgAuthSignInWithPangolin": "Mit Pangolin anmelden", + "orgAuthSignInToOrg": "Bei einer Organisation anmelden", + "orgAuthSelectOrgTitle": "Organisations-Anmeldung", + "orgAuthSelectOrgDescription": "Geben Sie Ihre Organisations-ID ein, um fortzufahren", + "orgAuthOrgIdPlaceholder": "Ihre Organisation", + "orgAuthOrgIdHelp": "Geben Sie die eindeutige Kennung Ihrer Organisation ein", + "orgAuthSelectOrgHelp": "Nachdem Sie Ihre Organisations-ID eingegeben haben, werden Sie auf die Anmeldeseite Ihrer Organisation gebracht, auf der Sie SSO oder die Zugangsdaten Ihrer Organisation verwenden können.", + "orgAuthRememberOrgId": "Diese Organisations-ID merken", + "orgAuthBackToSignIn": "Zurück zum Standard Login", + "orgAuthNoAccount": "Sie haben noch kein Konto?", "subscriptionRequiredToUse": "Um diese Funktion nutzen zu können, ist ein Abonnement erforderlich.", + "mustUpgradeToUse": "Sie müssen Ihr Abonnement aktualisieren, um diese Funktion nutzen zu können.", + "subscriptionRequiredTierToUse": "Diese Funktion erfordert {tier} oder höher.", + "upgradeToTierToUse": "Upgrade auf {tier} oder höher, um diese Funktion zu nutzen.", + "subscriptionTierTier1": "Zuhause", + "subscriptionTierTier2": "Team", + "subscriptionTierTier3": "Geschäftlich", + "subscriptionTierEnterprise": "Firma", "idpDisabled": "Identitätsanbieter sind deaktiviert.", "orgAuthPageDisabled": "Organisations-Authentifizierungsseite ist deaktiviert.", "domainRestartedDescription": "Domain-Verifizierung erfolgreich neu gestartet", @@ -1884,6 +2080,8 @@ "enableTwoFactorAuthentication": "Zwei-Faktor-Authentifizierung aktivieren", "completeSecuritySteps": "Schließe Sicherheitsschritte ab", "securitySettings": "Sicherheitseinstellungen", + "dangerSection": "Gefahrenzone", + "dangerSectionDescription": "Alle mit dieser Organisation verbundenen Daten dauerhaft löschen", "securitySettingsDescription": "Sicherheitsrichtlinien für die Organisation konfigurieren", "requireTwoFactorForAllUsers": "Zwei-Faktor-Authentifizierung für alle Benutzer erforderlich", "requireTwoFactorDescription": "Wenn aktiviert, müssen alle internen Benutzer in dieser Organisation die Zwei-Faktor-Authentifizierung aktiviert haben, um auf die Organisation zuzugreifen.", @@ -1921,9 +2119,9 @@ "securityPolicyChangeWarningText": "Dies betrifft alle Benutzer in der Organisation", "authPageErrorUpdateMessage": "Beim Aktualisieren der Auth-Seiten-Einstellungen ist ein Fehler aufgetreten", "authPageErrorUpdate": "Auth Seite kann nicht aktualisiert werden", - "authPageUpdated": "Auth-Seite erfolgreich aktualisiert", + "authPageDomainUpdated": "Domain der Authentifizierungsseite erfolgreich aktualisiert", "healthCheckNotAvailable": "Lokal", - "rewritePath": "Pfad neu schreiben", + "rewritePath": "Pfad umschreiben", "rewritePathDescription": "Optional den Pfad umschreiben, bevor er an das Ziel weitergeleitet wird.", "continueToApplication": "Weiter zur Anwendung", "checkingInvite": "Einladung wird überprüft", @@ -1949,8 +2147,15 @@ "beta": "Beta", "manageUserDevices": "Benutzer-Geräte", "manageUserDevicesDescription": "Geräte anschauen und verwalten, die Benutzer für private Verbindungen zu Ressourcen verwenden", + "downloadClientBannerTitle": "Pangolin Client herunterladen", + "downloadClientBannerDescription": "Laden Sie den Pangolin Client für Ihr System herunter, um sich mit dem Pangolin Netzwerk zu verbinden und privat auf Ressourcen zuzugreifen.", "manageMachineClients": "Maschinen-Clients verwalten", "manageMachineClientsDescription": "Erstelle und verwalte Clients, die Server und Systeme nutzen, um privat mit Ressourcen zu verbinden", + "machineClientsBannerTitle": "Server & Automatisierte Systeme", + "machineClientsBannerDescription": "Maschinelle Clients sind für Server und automatisierte Systeme, die nicht einem bestimmten Benutzer zugeordnet sind. Sie authentifizieren sich mit einer ID und einem Geheimnis und können mit Pangolin CLI, Olm CLI oder Olm als Container laufen.", + "machineClientsBannerPangolinCLI": "Pangolin CLI", + "machineClientsBannerOlmCLI": "Olm CLI", + "machineClientsBannerOlmContainer": "Olm Container", "clientsTableUserClients": "Benutzer", "clientsTableMachineClients": "Maschine", "licenseTableValidUntil": "Gültig bis", @@ -2021,7 +2226,7 @@ "primaryUseQuestion": "Wofür planen Sie in erster Linie Pangolin zu benutzen?", "industryQuestion": "Was ist Ihre Branche?", "prospectiveUsersQuestion": "Wie viele Interessenten erwarten Sie?", - "prospectiveSitesQuestion": "Wie viele potentielle Standorte (Tunnel) erwarten Sie?", + "prospectiveSitesQuestion": "Wie viele potenzielle Standorte (Tunnel) erwarten Sie?", "companyName": "Firmenname", "countryOfResidence": "Land des Wohnsitzes", "stateProvinceRegion": "Bundesland / Provinz / Region", @@ -2049,6 +2254,32 @@ } } }, + "newPricingLicenseForm": { + "title": "Lizenz erhalten", + "description": "Wählen Sie einen Plan und teilen Sie uns mit, wie Sie Pangolin verwenden möchten.", + "chooseTier": "Wählen Sie Ihren Plan", + "viewPricingLink": "Siehe Preise, Funktionen und Limits", + "tiers": { + "starter": { + "title": "Starter", + "description": "Enterprise Features, 25 Benutzer, 25 Sites und Community-Unterstützung." + }, + "scale": { + "title": "Maßstab", + "description": "Enterprise Features, 50 Benutzer, 50 Sites und Prioritätsunterstützung." + } + }, + "personalUseOnly": "Nur persönliche Nutzung (kostenlose Lizenz — keine Kasse)", + "buttons": { + "continueToCheckout": "Weiter zur Kasse" + }, + "toasts": { + "checkoutError": { + "title": "Checkout-Fehler", + "description": "Kasse konnte nicht gestartet werden. Bitte versuchen Sie es erneut." + } + } + }, "priority": "Priorität", "priorityDescription": "Die Routen mit höherer Priorität werden zuerst ausgewertet. Priorität = 100 bedeutet automatische Bestellung (Systementscheidung). Verwenden Sie eine andere Nummer, um manuelle Priorität zu erzwingen.", "instanceName": "Instanzname", @@ -2069,10 +2300,10 @@ "pathRewriteModalTitle": "Pfad Rewriting konfigurieren", "pathRewriteModalDescription": "Transformieren Sie den übereinstimmenden Pfad bevor Sie zum Ziel weiterleiten.", "pathRewriteType": "Rewrite Typ", - "pathRewritePrefixOption": "Präfix - Präfix ersetzen", + "pathRewritePrefixOption": "Präfix ersetzen", "pathRewriteExactOption": "Exakt - Gesamten Pfad ersetzen", "pathRewriteRegexOption": "Regex - Musterersetzung", - "pathRewriteStripPrefixOption": "Präfix entfernen - Präfix entfernen", + "pathRewriteStripPrefixOption": "Präfix entfernen", "pathRewriteValue": "Wert umschreiben", "pathRewriteRegexPlaceholder": "/neu/$1", "pathRewriteDefaultPlaceholder": "/new-path", @@ -2088,37 +2319,39 @@ "sidebarEnableEnterpriseLicense": "Enterprise-Lizenz aktivieren", "cannotbeUndone": "Dies kann nicht rückgängig gemacht werden.", "toConfirm": "bestätigen.", - "deleteClientQuestion": "Sind Sie sicher, dass Sie den Client von der Website und der Organisation entfernen möchten?", - "clientMessageRemove": "Nach dem Entfernen kann sich der Client nicht mehr mit der Website verbinden.", + "deleteClientQuestion": "Sind Sie sicher, dass Sie den Client von dem Standort und der Organisation entfernen möchten?", + "clientMessageRemove": "Nach dem Entfernen kann sich der Client nicht mehr mit dem Standort verbinden.", "sidebarLogs": "Logs", "request": "Anfrage", "requests": "Anfragen", "logs": "Logs", - "logsSettingsDescription": "Aus dieser Orginisierung gesammelte Logs überwachen", + "logsSettingsDescription": "Protokolle aus dieser Organisation überwachen", "searchLogs": "Logs suchen...", "action": "Aktion", "actor": "Akteur", "timestamp": "Zeitstempel", "accessLogs": "Zugriffsprotokolle", "exportCsv": "CSV exportieren", + "exportError": "Unbekannter Fehler beim Exportieren von CSV", + "exportCsvTooltip": "Innerhalb des Zeitraums", "actorId": "Akteur-ID", "allowedByRule": "Erlaubt durch Regel", "allowedNoAuth": "Keine Auth erlaubt", "validAccessToken": "Gültiges Zugriffstoken", - "validHeaderAuth": "Valid header auth", - "validPincode": "Valid Pincode", + "validHeaderAuth": "Gültige Header-Authentifizierung", + "validPincode": "Gültiger PIN-Code", "validPassword": "Gültiges Passwort", - "validEmail": "Valid email", - "validSSO": "Valid SSO", + "validEmail": "Gültige E-Mail-Adresse", + "validSSO": "Gültige SSO-Anmeldung", "resourceBlocked": "Ressource blockiert", "droppedByRule": "Abgelegt durch Regel", "noSessions": "Keine Sitzungen", "temporaryRequestToken": "Temporäres Anfrage-Token", - "noMoreAuthMethods": "No Valid Auth", + "noMoreAuthMethods": "Keine gültige Authentifizierungsmethode verfügbar", "ip": "IP", "reason": "Grund", "requestLogs": "Logs anfordern", - "requestAnalytics": "Analytik anfordern", + "requestAnalytics": "Anfrage-Analyse anzeigen", "host": "Host", "location": "Standort", "actionLogs": "Aktionsprotokolle", @@ -2128,7 +2361,7 @@ "logRetention": "Log-Speicherung", "logRetentionDescription": "Verwalten, wie lange verschiedene Logs für diese Organisation gespeichert werden oder deaktivieren", "requestLogsDescription": "Detaillierte Request-Logs für Ressourcen in dieser Organisation anzeigen", - "requestAnalyticsDescription": "Detaillierte Anfrageanalytik für Ressourcen in dieser Organisation anzeigen", + "requestAnalyticsDescription": "Detaillierte Anfrage-Analyse für Ressourcen in dieser Organisation anzeigen", "logRetentionRequestLabel": "Log-Speicherung anfordern", "logRetentionRequestDescription": "Wie lange sollen Request-Logs gespeichert werden", "logRetentionAccessLabel": "Zugriffsprotokoll-Speicherung", @@ -2145,7 +2378,8 @@ "logRetentionEndOfFollowingYear": "Ende des folgenden Jahres", "actionLogsDescription": "Verlauf der in dieser Organisation durchgeführten Aktionen anzeigen", "accessLogsDescription": "Zugriffsauth-Anfragen für Ressourcen in dieser Organisation anzeigen", - "licenseRequiredToUse": "Um diese Funktion nutzen zu können, ist eine Enterprise-Lizenz erforderlich.", + "licenseRequiredToUse": "Eine Enterprise Edition Lizenz oder Pangolin Cloud wird benötigt, um diese Funktion nutzen zu können. Buchen Sie eine Demo oder POC Testversion.", + "ossEnterpriseEditionRequired": "Die Enterprise Edition wird benötigt, um diese Funktion nutzen zu können. Diese Funktion ist auch in Pangolin Cloudverfügbar. Buchen Sie eine Demo oder POC Testversion.", "certResolver": "Zertifikatsauflöser", "certResolverDescription": "Wählen Sie den Zertifikatslöser aus, der für diese Ressource verwendet werden soll.", "selectCertResolver": "Zertifikatsauflöser auswählen", @@ -2154,7 +2388,7 @@ "unverified": "Nicht verifiziert", "domainSetting": "Domänen-Einstellungen", "domainSettingDescription": "Einstellungen für die Domain konfigurieren", - "preferWildcardCertDescription": "Versuch ein Platzhalterzertifikat zu generieren (erfordert einen richtig konfigurierten Zertifikatslöser).", + "preferWildcardCertDescription": "Versuch, ein Wildcard Zertifikat zu generieren (erfordert einen richtig konfigurierten Zertifikats-Resolver).", "recordName": "Name des Datensatzes", "auto": "Auto", "TTL": "TTL", @@ -2206,6 +2440,8 @@ "deviceCodeInvalidFormat": "Code muss 9 Zeichen lang sein (z.B. A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Ungültiger oder abgelaufener Code", "deviceCodeVerifyFailed": "Fehler beim Überprüfen des Gerätecodes", + "deviceCodeValidating": "Überprüfe Gerätecode...", + "deviceCodeVerifying": "Geräteautorisierung wird überprüft...", "signedInAs": "Angemeldet als", "deviceCodeEnterPrompt": "Geben Sie den auf dem Gerät angezeigten Code ein", "continue": "Weiter", @@ -2218,7 +2454,7 @@ "deviceOrganizationsAccess": "Zugriff auf alle Organisationen, auf die Ihr Konto Zugriff hat", "deviceAuthorize": "{applicationName} autorisieren", "deviceConnected": "Gerät verbunden!", - "deviceAuthorizedMessage": "Gerät ist berechtigt, auf Ihr Konto zuzugreifen.", + "deviceAuthorizedMessage": "Gerät ist berechtigt, auf Ihr Konto zuzugreifen. Bitte kehren Sie zur Client-Anwendung zurück.", "pangolinCloud": "Pangolin Cloud", "viewDevices": "Geräte anzeigen", "viewDevicesDescription": "Verwalten Sie Ihre verbundenen Geräte", @@ -2280,6 +2516,7 @@ "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Nicht du? Verwenden Sie ein anderes Konto.", "deviceLoginDeviceRequestingAccessToAccount": "Ein Gerät fordert Zugriff auf dieses Konto an.", + "loginSelectAuthenticationMethod": "Wählen Sie eine Authentifizierungsmethode aus, um fortzufahren.", "noData": "Keine Daten", "machineClients": "Maschinen-Clients", "install": "Installieren", @@ -2289,9 +2526,11 @@ "setupFailedToFetchSubnet": "Fehler beim Abrufen des Standard-Subnetzes", "setupSubnetAdvanced": "Subnetz (Fortgeschritten)", "setupSubnetDescription": "Das Subnetz für das interne Netzwerk dieser Organisation.", + "setupUtilitySubnet": "Utility Subnetz (Erweitert)", + "setupUtilitySubnetDescription": "Das Subnetz für die Alias-Adressen und den DNS-Server dieser Organisation.", "siteRegenerateAndDisconnect": "Regenerieren und trennen", - "siteRegenerateAndDisconnectConfirmation": "Sind Sie sicher, dass Sie die Anmeldedaten neu generieren und diese Website trennen möchten?", - "siteRegenerateAndDisconnectWarning": "Dies wird die Anmeldeinformationen neu generieren und die Website sofort trennen. Die Seite muss mit den neuen Anmeldeinformationen neu gestartet werden.", + "siteRegenerateAndDisconnectConfirmation": "Sind Sie sicher, dass Sie die Anmeldedaten neu generieren und diesen Standort trennen möchten?", + "siteRegenerateAndDisconnectWarning": "Dies wird die Anmeldeinformationen neu generieren und den Standort sofort trennen. Der Standort muss mit den neuen Anmeldeinformationen neu gestartet werden.", "siteRegenerateCredentialsConfirmation": "Sind Sie sicher, dass Sie die Zugangsdaten für diese Seite neu generieren möchten?", "siteRegenerateCredentialsWarning": "Dies wird die Anmeldeinformationen neu generieren. Die Seite bleibt verbunden, bis Sie sie manuell neu starten und die neuen Anmeldeinformationen verwenden.", "clientRegenerateAndDisconnect": "Regenerieren und trennen", @@ -2304,5 +2543,179 @@ "remoteExitNodeRegenerateAndDisconnectWarning": "Dies wird die Anmeldeinformationen neu generieren und den Remote-Exit-Knoten sofort trennen. Der Remote-Exit-Knoten muss mit den neuen Anmeldeinformationen neu gestartet werden.", "remoteExitNodeRegenerateCredentialsConfirmation": "Sind Sie sicher, dass Sie die Zugangsdaten für diesen Remote-Exit-Knoten neu generieren möchten?", "remoteExitNodeRegenerateCredentialsWarning": "Dies wird die Anmeldeinformationen neu generieren. Der Remote-Exit-Knoten bleibt verbunden, bis Sie ihn manuell neu starten und die neuen Anmeldeinformationen verwenden.", - "agent": "Agent" + "agent": "Agent", + "personalUseOnly": "Nur für persönliche Nutzung", + "loginPageLicenseWatermark": "Diese Instanz ist nur für den persönlichen Gebrauch lizenziert.", + "instanceIsUnlicensed": "Diese Instanz ist nicht lizenziert.", + "portRestrictions": "Port Einschränkungen", + "allPorts": "Alle", + "custom": "Benutzerdefiniert", + "allPortsAllowed": "Alle Ports erlaubt", + "allPortsBlocked": "Alle Ports blockiert", + "tcpPortsDescription": "Legen Sie fest, welche TCP-Ports für diese Ressource erlaubt sind. Benutzen Sie '*' für alle Ports, lassen Sie leer um alle zu blockieren, oder geben Sie eine kommaseparierte Liste von Ports und Bereichen ein (z.B. 80,443,8000-9000).", + "udpPortsDescription": "Geben Sie an, welche UDP-Ports für diese Ressource erlaubt sind. Benutzen Sie '*' für alle Ports, lassen Sie leer um alle zu blockieren, oder geben Sie eine kommaseparierte Liste von Ports und Bereichen (z.B. 53,123,500-600) ein.", + "organizationLoginPageTitle": "Organisations-Anmeldeseite", + "organizationLoginPageDescription": "Die Anmeldeseite für diese Organisation anpassen", + "resourceLoginPageTitle": "Ressourcen-Anmeldeseite", + "resourceLoginPageDescription": "Anpassen der Anmeldeseite für einzelne Ressourcen", + "enterConfirmation": "Bestätigung eingeben", + "blueprintViewDetails": "Details", + "defaultIdentityProvider": "Standard Identitätsanbieter", + "defaultIdentityProviderDescription": "Wenn ein Standard-Identity Provider ausgewählt ist, wird der Benutzer zur Authentifizierung automatisch an den Anbieter weitergeleitet.", + "editInternalResourceDialogNetworkSettings": "Netzwerkeinstellungen", + "editInternalResourceDialogAccessPolicy": "Zugriffsrichtlinie", + "editInternalResourceDialogAddRoles": "Rollen hinzufügen", + "editInternalResourceDialogAddUsers": "Nutzer hinzufügen", + "editInternalResourceDialogAddClients": "Clients hinzufügen", + "editInternalResourceDialogDestinationLabel": "Ziel", + "editInternalResourceDialogDestinationDescription": "Geben Sie die Zieladresse für die interne Ressource an. Dies kann ein Hostname, eine IP-Adresse oder ein CIDR-Bereich sein, abhängig vom gewählten Modus. Legen Sie optional einen internen DNS-Alias für eine vereinfachte Identifizierung fest.", + "editInternalResourceDialogPortRestrictionsDescription": "Den Zugriff auf bestimmte TCP/UDP-Ports beschränken oder alle Ports erlauben/blockieren.", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "Zugriffskontrolle", + "editInternalResourceDialogAccessControlDescription": "Kontrollieren Sie, welche Rollen, Benutzer und Maschinen-Clients Zugriff auf diese Ressource haben, wenn sie verbunden sind. Admins haben immer Zugriff.", + "editInternalResourceDialogPortRangeValidationError": "Der Port-Bereich muss \"*\" für alle Ports sein, oder eine kommaseparierte Liste von Ports und Bereichen (z.B. \"80,443.8000-9000\"). Ports müssen zwischen 1 und 65535 liegen.", + "internalResourceAuthDaemonStrategy": "SSH Auth-Daemon Standort", + "internalResourceAuthDaemonStrategyDescription": "Wählen Sie aus, wo der SSH-Authentifizierungs-Daemon läuft: auf der Site (Newt) oder auf einem entfernten Host.", + "internalResourceAuthDaemonDescription": "Der SSH-Authentifizierungs-Daemon verarbeitet SSH-Schlüsselsignaturen und PAM-Authentifizierung für diese Ressource. Wählen Sie, ob sie auf der Website (Newt) oder auf einem separaten entfernten Host ausgeführt wird. Siehe die Dokumentation für mehr.", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "Strategie auswählen", + "internalResourceAuthDaemonStrategyLabel": "Standort", + "internalResourceAuthDaemonSite": "Vor Ort", + "internalResourceAuthDaemonSiteDescription": "Der Auth Daemon läuft auf der Seite (Newt).", + "internalResourceAuthDaemonRemote": "Entfernter Host", + "internalResourceAuthDaemonRemoteDescription": "Der Auth Daemon läuft auf einem Host, der nicht die Site ist.", + "internalResourceAuthDaemonPort": "Daemon-Port (optional)", + "orgAuthWhatsThis": "Wo finde ich meine Organisations-ID?", + "learnMore": "Mehr erfahren", + "backToHome": "Zurück zur Startseite", + "needToSignInToOrg": "Benötigen Sie den Identitätsanbieter Ihres Unternehmens?", + "maintenanceMode": "Wartungsmodus", + "maintenanceModeDescription": "Eine Wartungsseite für Besucher anzeigen", + "maintenanceModeType": "Art des Wartungsmodus", + "showMaintenancePage": "Eine Wartungsseite für Besucher anzeigen", + "enableMaintenanceMode": "Wartungsmodus aktivieren", + "automatic": "Automatisch", + "automaticModeDescription": " Wartungsseite nur anzeigen, wenn alle Backend-Ziele deaktiviert oder ungesund sind. Deine Ressource funktioniert normal, solange mindestens ein Ziel gesund ist.", + "forced": "Erzwungen", + "forcedModeDescription": "Immer die Wartungsseite anzeigen, unabhängig von der Gesundheit des Backends. Verwenden Sie diese für geplante Wartung, wenn Sie alle Zugriffe verhindern möchten.", + "warning:": "Warnung:", + "forcedeModeWarning": "Der gesamte Datenverkehr wird zur Wartungsseite weitergeleitet. Ihre Backend-Ressourcen werden keine Anfragen erhalten.", + "pageTitle": "Seitentitel", + "pageTitleDescription": "Die Hauptüberschrift auf der Wartungsseite", + "maintenancePageMessage": "Wartungsmeldung", + "maintenancePageMessagePlaceholder": "Wir sind bald wieder da! Unsere Seite wird derzeit planmäßig gewartet.", + "maintenancePageMessageDescription": "Detaillierte Meldung zur Erklärung der Wartung", + "maintenancePageTimeTitle": "Geschätzte Abschlusszeit (Optional)", + "maintenanceTime": "z.B.: 2 Stunden, Nov 1 um 17:00 Uhr", + "maintenanceEstimatedTimeDescription": "Wann Sie den Abschluss der Wartung erwarten", + "editDomain": "Domain bearbeiten", + "editDomainDescription": "Wählen Sie eine Domain für Ihre Ressource", + "maintenanceModeDisabledTooltip": "Diese Funktion erfordert eine gültige Lizenz, um sie zu aktivieren.", + "maintenanceScreenTitle": "Dienst vorübergehend nicht verfügbar", + "maintenanceScreenMessage": "Wir haben derzeit technische Schwierigkeiten. Bitte schauen Sie bald noch einmal vorbei.", + "maintenanceScreenEstimatedCompletion": "Geschätzter Abschluss:", + "createInternalResourceDialogDestinationRequired": "Ziel ist erforderlich", + "available": "Verfügbar", + "archived": "Archiviert", + "noArchivedDevices": "Keine archivierten Geräte gefunden", + "deviceArchived": "Gerät archiviert", + "deviceArchivedDescription": "Das Gerät wurde erfolgreich archiviert.", + "errorArchivingDevice": "Fehler beim Archivieren des Geräts", + "failedToArchiveDevice": "Archivierung des Geräts fehlgeschlagen", + "deviceQuestionArchive": "Sind Sie sicher, dass Sie dieses Gerät archivieren möchten?", + "deviceMessageArchive": "Das Gerät wird archiviert und aus Ihrer Liste der aktiven Geräte entfernt.", + "deviceArchiveConfirm": "Gerät archivieren", + "archiveDevice": "Gerät archivieren", + "archive": "Archiv", + "deviceUnarchived": "Gerät nicht archiviert", + "deviceUnarchivedDescription": "Das Gerät wurde erfolgreich deinstalliert.", + "errorUnarchivingDevice": "Fehler beim Entarchivieren des Geräts", + "failedToUnarchiveDevice": "Fehler beim Entfernen des Geräts", + "unarchive": "Archivieren", + "archiveClient": "Client archivieren", + "archiveClientQuestion": "Sind Sie sicher, dass Sie diesen Client archivieren möchten?", + "archiveClientMessage": "Der Client wird archiviert und aus der Liste Ihrer aktiven Clients entfernt.", + "archiveClientConfirm": "Client archivieren", + "blockClient": "Client sperren", + "blockClientQuestion": "Sind Sie sicher, dass Sie diesen Client blockieren möchten?", + "blockClientMessage": "Das Gerät wird gezwungen, die Verbindung zu trennen, wenn es gerade verbunden ist. Sie können das Gerät später entsperren.", + "blockClientConfirm": "Client sperren", + "active": "Aktiv", + "usernameOrEmail": "Benutzername oder E-Mail", + "selectYourOrganization": "Wählen Sie Ihre Organisation", + "signInTo": "Einloggen in", + "signInWithPassword": "Mit Passwort fortfahren", + "noAuthMethodsAvailable": "Keine Authentifizierungsmethoden für diese Organisation verfügbar.", + "enterPassword": "Geben Sie Ihr Passwort ein", + "enterMfaCode": "Geben Sie den Code aus Ihrer Authentifizierungs-App ein", + "securityKeyRequired": "Bitte verwenden Sie Ihren Sicherheitsschlüssel zum Anmelden.", + "needToUseAnotherAccount": "Benötigen Sie ein anderes Konto?", + "loginLegalDisclaimer": "Indem Sie auf die Buttons unten klicken, bestätigen Sie, dass Sie gelesen haben, verstehen, und stimmen den Nutzungsbedingungen und Datenschutzrichtlinien zu.", + "termsOfService": "Nutzungsbedingungen", + "privacyPolicy": "Datenschutzerklärung", + "userNotFoundWithUsername": "Kein Benutzer mit diesem Benutzernamen gefunden.", + "verify": "Überprüfen", + "signIn": "Anmelden", + "forgotPassword": "Passwort vergessen?", + "orgSignInTip": "Wenn Sie sich vorher angemeldet haben, können Sie Ihren Benutzernamen oder Ihre E-Mail-Adresse eingeben, um sich stattdessen beim Identifikationsprovider Ihrer Organisation zu authentifizieren. Es ist einfacher!", + "continueAnyway": "Trotzdem fortfahren", + "dontShowAgain": "Nicht mehr anzeigen", + "orgSignInNotice": "Wussten Sie schon?", + "signupOrgNotice": "Versucht sich anzumelden?", + "signupOrgTip": "Versuchen Sie, sich über den Identitätsanbieter Ihrer Organisation anzumelden?", + "signupOrgLink": "Melden Sie sich an oder melden Sie sich stattdessen bei Ihrer Organisation an", + "verifyEmailLogInWithDifferentAccount": "Anderes Konto verwenden", + "logIn": "Anmelden", + "deviceInformation": "Geräteinformationen", + "deviceInformationDescription": "Informationen über das Gerät und den Agent", + "deviceSecurity": "Gerätesicherheit", + "deviceSecurityDescription": "Informationen zur Gerätesicherheit", + "platform": "Plattform", + "macosVersion": "macOS-Version", + "windowsVersion": "Windows-Version", + "iosVersion": "iOS-Version", + "androidVersion": "Android-Version", + "osVersion": "OS-Version", + "kernelVersion": "Kernel-Version", + "deviceModel": "Gerätemodell", + "serialNumber": "Seriennummer", + "hostname": "Hostname", + "firstSeen": "Zuerst gesehen", + "lastSeen": "Zuletzt gesehen", + "biometricsEnabled": "Biometrie aktiviert", + "diskEncrypted": "Festplatte verschlüsselt", + "firewallEnabled": "Firewall aktiviert", + "autoUpdatesEnabled": "Automatische Updates aktiviert", + "tpmAvailable": "TPM verfügbar", + "windowsAntivirusEnabled": "Antivirus aktiviert", + "macosSipEnabled": "Schutz der Systemintegrität (SIP)", + "macosGatekeeperEnabled": "Gatekeeper", + "macosFirewallStealthMode": "Firewall Stealth-Modus", + "linuxAppArmorEnabled": "AppRüstung", + "linuxSELinuxEnabled": "SELinux", + "deviceSettingsDescription": "Geräteinformationen und -einstellungen anzeigen", + "devicePendingApprovalDescription": "Dieses Gerät wartet auf Freigabe", + "deviceBlockedDescription": "Dieses Gerät ist derzeit gesperrt. Es kann keine Verbindung zu anderen Ressourcen herstellen, es sei denn, es entsperrt.", + "unblockClient": "Client entsperren", + "unblockClientDescription": "Das Gerät wurde entsperrt", + "unarchiveClient": "Client dearchivieren", + "unarchiveClientDescription": "Das Gerät wurde nicht archiviert", + "block": "Blockieren", + "unblock": "Entsperren", + "deviceActions": "Geräte-Aktionen", + "deviceActionsDescription": "Gerätestatus und Zugriff verwalten", + "devicePendingApprovalBannerDescription": "Dieses Gerät wartet auf Genehmigung. Es kann sich erst mit Ressourcen verbinden.", + "connected": "Verbunden", + "disconnected": "Verbindung getrennt", + "approvalsEmptyStateTitle": "Gerätezulassungen nicht aktiviert", + "approvalsEmptyStateDescription": "Aktiviere Gerätegenehmigungen für Rollen, um Administratorgenehmigungen zu benötigen, bevor Benutzer neue Geräte verbinden können.", + "approvalsEmptyStateStep1Title": "Gehe zu Rollen", + "approvalsEmptyStateStep1Description": "Navigieren Sie zu den Rolleneinstellungen Ihrer Organisation, um die Gerätefreigaben zu konfigurieren.", + "approvalsEmptyStateStep2Title": "Gerätegenehmigungen aktivieren", + "approvalsEmptyStateStep2Description": "Bearbeite eine Rolle und aktiviere die Option 'Gerätegenehmigung erforderlich'. Benutzer mit dieser Rolle benötigen Administrator-Genehmigung für neue Geräte.", + "approvalsEmptyStatePreviewDescription": "Vorschau: Wenn aktiviert, werden ausstehende Geräteanfragen hier zur Überprüfung angezeigt", + "approvalsEmptyStateButtonText": "Rollen verwalten", + "domainErrorTitle": "Wir haben Probleme mit der Überprüfung deiner Domain" } diff --git a/messages/en-US.json b/messages/en-US.json index 55cf2d6bd..5ccc7c230 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1,5 +1,7 @@ { "setupCreate": "Create the organization, site, and resources", + "headerAuthCompatibilityInfo": "Enable this to force a 401 Unauthorized response when an authentication token is missing. This is required for browsers or specific HTTP libraries that do not send credentials without a server challenge.", + "headerAuthCompatibility": "Extended compatibility", "setupNewOrg": "New Organization", "setupCreateOrg": "Create Organization", "setupCreateResources": "Create Resources", @@ -16,6 +18,8 @@ "componentsMember": "You're a member of {count, plural, =0 {no organization} one {one organization} other {# organizations}}.", "componentsInvalidKey": "Invalid or expired license keys detected. Follow license terms to continue using all features.", "dismiss": "Dismiss", + "subscriptionViolationMessage": "You're beyond your limits for your current plan. Correct the problem by removing sites, users, or other resources to stay within your plan.", + "subscriptionViolationViewBilling": "View billing", "componentsLicenseViolation": "License Violation: This server is using {usedSites} sites which exceeds its licensed limit of {maxSites} sites. Follow license terms to continue using all features.", "componentsSupporterMessage": "Thank you for supporting Pangolin as a {tier}!", "inviteErrorNotValid": "We're sorry, but it looks like the invite you're trying to access has not been accepted or is no longer valid.", @@ -33,7 +37,7 @@ "password": "Password", "confirmPassword": "Confirm Password", "createAccount": "Create Account", - "viewSettings": "View settings", + "viewSettings": "View Settings", "delete": "Delete", "name": "Name", "online": "Online", @@ -51,6 +55,12 @@ "siteQuestionRemove": "Are you sure you want to remove the site from the organization?", "siteManageSites": "Manage Sites", "siteDescription": "Create and manage sites to enable connectivity to private networks", + "sitesBannerTitle": "Connect Any Network", + "sitesBannerDescription": "A site is a connection to a remote network that allows Pangolin to provide access to resources, whether public or private, to users anywhere. Install the site network connector (Newt) anywhere you can run a binary or container to establish the connection.", + "sitesBannerButtonText": "Install Site Connector", + "approvalsBannerTitle": "Approve or Deny Device Access", + "approvalsBannerDescription": "Review and approve or deny device access requests from users. When device approvals are required, users must get admin approval before their devices can connect to your organization's resources.", + "approvalsBannerButtonText": "Learn More", "siteCreate": "Create Site", "siteCreateDescription2": "Follow the steps below to create and connect a new site", "siteCreateDescription": "Create a new site to start connecting resources", @@ -71,8 +81,8 @@ "siteConfirmCopy": "I have copied the config", "searchSitesProgress": "Search sites...", "siteAdd": "Add Site", - "siteInstallNewt": "Install Newt", - "siteInstallNewtDescription": "Get Newt running on your system", + "siteInstallNewt": "Install Site", + "siteInstallNewtDescription": "Install the site connector for your system", "WgConfiguration": "WireGuard Configuration", "WgConfigurationDescription": "Use the following configuration to connect to the network", "operatingSystem": "Operating System", @@ -100,6 +110,7 @@ "siteTunnelDescription": "Determine how you want to connect to the site", "siteNewtCredentials": "Credentials", "siteNewtCredentialsDescription": "This is how the site will authenticate with the server", + "remoteNodeCredentialsDescription": "This is how the remote node will authenticate with the server", "siteCredentialsSave": "Save the Credentials", "siteCredentialsSaveDescription": "You will only be able to see this once. Make sure to copy it to a secure place.", "siteInfo": "Site Information", @@ -137,6 +148,11 @@ "createLink": "Create Link", "resourcesNotFound": "No resources found", "resourceSearch": "Search resources", + "machineSearch": "Search machines", + "machinesSearch": "Search machine clients...", + "machineNotFound": "No machines found", + "userDeviceSearch": "Search user devices", + "userDevicesSearch": "Search user devices...", "openMenu": "Open menu", "resource": "Resource", "title": "Title", @@ -146,8 +162,12 @@ "shareErrorSelectResource": "Please select a resource", "proxyResourceTitle": "Manage Public Resources", "proxyResourceDescription": "Create and manage resources that are publicly accessible through a web browser", + "proxyResourcesBannerTitle": "Web-based Public Access", + "proxyResourcesBannerDescription": "Public resources are HTTPS or TCP/UDP proxies accessible to anyone on the internet through a web browser. Unlike private resources, they do not require client-side software and can include identity and context-aware access policies.", "clientResourceTitle": "Manage Private Resources", "clientResourceDescription": "Create and manage resources that are only accessible through a connected client", + "privateResourcesBannerTitle": "Zero-Trust Private Access", + "privateResourcesBannerDescription": "Private resources use zero-trust security, ensuring users and machines can only access resources you explicitly grant. Connect user devices or machine clients to access these resources over a secure virtual private network.", "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", @@ -157,9 +177,10 @@ "resourceMessageRemove": "Once removed, the resource will no longer be accessible. All targets associated with the resource will also be removed.", "resourceQuestionRemove": "Are you sure you want to remove the resource from the organization?", "resourceHTTP": "HTTPS Resource", - "resourceHTTPDescription": "Proxy requests to the app over HTTPS using a subdomain or base domain.", + "resourceHTTPDescription": "Proxy requests over HTTPS using a fully qualified domain name.", "resourceRaw": "Raw TCP/UDP Resource", - "resourceRawDescription": "Proxy requests to the app over TCP/UDP using a port number. This only works when sites are connected to nodes.", + "resourceRawDescription": "Proxy requests over raw TCP/UDP using a port number.", + "resourceRawDescriptionCloud": "Proxy requests over raw TCP/UDP using a port number. Requires sites to connect to a remote node.", "resourceCreate": "Create Resource", "resourceCreateDescription": "Follow the steps below to create a new resource", "resourceSeeAll": "See All Resources", @@ -186,6 +207,7 @@ "protocolSelect": "Select a protocol", "resourcePortNumber": "Port Number", "resourcePortNumberDescription": "The external port number to proxy requests.", + "back": "Back", "cancel": "Cancel", "resourceConfig": "Configuration Snippets", "resourceConfigDescription": "Copy and paste these configuration snippets to set up the TCP/UDP resource", @@ -231,6 +253,17 @@ "orgErrorDeleteMessage": "An error occurred while deleting the organization.", "orgDeleted": "Organization deleted", "orgDeletedMessage": "The organization and its data has been deleted.", + "deleteAccount": "Delete Account", + "deleteAccountDescription": "Permanently delete your account, all organizations you own, and all data within those organizations. This cannot be undone.", + "deleteAccountButton": "Delete Account", + "deleteAccountConfirmTitle": "Delete Account", + "deleteAccountConfirmMessage": "This will permanently wipe your account, all organizations you own, and all data within those organizations. This cannot be undone.", + "deleteAccountConfirmString": "delete account", + "deleteAccountSuccess": "Account Deleted", + "deleteAccountSuccessMessage": "Your account has been deleted.", + "deleteAccountError": "Failed to delete account", + "deleteAccountPreviewAccount": "Your Account", + "deleteAccountPreviewOrgs": "Organizations you own (and all their data)", "orgMissing": "Organization ID Missing", "orgMissingMessage": "Unable to regenerate invitation without an organization ID.", "accessUsersManage": "Manage Users", @@ -247,6 +280,8 @@ "accessRolesSearch": "Search roles...", "accessRolesAdd": "Add Role", "accessRoleDelete": "Delete Role", + "accessApprovalsManage": "Manage Approvals", + "accessApprovalsDescription": "View and manage pending approvals for access to this organization", "description": "Description", "inviteTitle": "Open Invitations", "inviteDescription": "Manage invitations for other users to join the organization", @@ -293,6 +328,41 @@ "apiKeysDelete": "Delete API Key", "apiKeysManage": "Manage API Keys", "apiKeysDescription": "API keys are used to authenticate with the integration API", + "provisioningKeysTitle": "Provisioning Key", + "provisioningKeysManage": "Manage Provisioning Keys", + "provisioningKeysDescription": "Provisioning keys are used to authenticate automated site provisioning for your organization.", + "provisioningKeys": "Provisioning Keys", + "searchProvisioningKeys": "Search provisioning keys...", + "provisioningKeysAdd": "Generate Provisioning Key", + "provisioningKeysErrorDelete": "Error deleting provisioning key", + "provisioningKeysErrorDeleteMessage": "Error deleting provisioning key", + "provisioningKeysQuestionRemove": "Are you sure you want to remove this provisioning key from the organization?", + "provisioningKeysMessageRemove": "Once removed, the key can no longer be used for site provisioning.", + "provisioningKeysDeleteConfirm": "Confirm Delete Provisioning Key", + "provisioningKeysDelete": "Delete Provisioning key", + "provisioningKeysCreate": "Generate Provisioning Key", + "provisioningKeysCreateDescription": "Generate a new provisioning key for the organization", + "provisioningKeysSeeAll": "See all provisioning keys", + "provisioningKeysSave": "Save the provisioning key", + "provisioningKeysSaveDescription": "You will only be able to see this once. Copy it to a secure place.", + "provisioningKeysErrorCreate": "Error creating provisioning key", + "provisioningKeysList": "New provisioning key", + "provisioningKeysMaxBatchSize": "Max batch size", + "provisioningKeysUnlimitedBatchSize": "Unlimited batch size (no limit)", + "provisioningKeysMaxBatchUnlimited": "Unlimited", + "provisioningKeysMaxBatchSizeInvalid": "Enter a valid max batch size (1–1,000,000).", + "provisioningKeysValidUntil": "Valid until", + "provisioningKeysValidUntilHint": "Leave empty for no expiration.", + "provisioningKeysValidUntilInvalid": "Enter a valid date and time.", + "provisioningKeysNumUsed": "Times used", + "provisioningKeysLastUsed": "Last used", + "provisioningKeysNoExpiry": "No expiration", + "provisioningKeysNeverUsed": "Never", + "provisioningKeysEdit": "Edit Provisioning Key", + "provisioningKeysEditDescription": "Update the max batch size and expiration time for this key.", + "provisioningKeysUpdateError": "Error updating provisioning key", + "provisioningKeysUpdated": "Provisioning key updated", + "provisioningKeysUpdatedDescription": "Your changes have been saved.", "apiKeysSettings": "{apiKeyName} Settings", "userTitle": "Manage All Users", "userDescription": "View and manage all users in the system", @@ -419,7 +489,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", @@ -440,6 +510,20 @@ "selectDuration": "Select duration", "selectResource": "Select Resource", "filterByResource": "Filter By Resource", + "selectApprovalState": "Select Approval State", + "filterByApprovalState": "Filter By Approval State", + "approvalListEmpty": "No approvals", + "approvalState": "Approval State", + "approvalLoadMore": "Load more", + "loadingApprovals": "Loading Approvals", + "approve": "Approve", + "approved": "Approved", + "denied": "Denied", + "deniedApproval": "Denied Approval", + "all": "All", + "deny": "Deny", + "viewDetails": "View Details", + "requestingNewDeviceApproval": "requested a new device", "resetFilters": "Reset Filters", "totalBlocked": "Requests Blocked By Pangolin", "totalRequests": "Total Requests", @@ -465,9 +549,12 @@ "userSaved": "User saved", "userSavedDescription": "The user has been updated.", "autoProvisioned": "Auto Provisioned", + "autoProvisionSettings": "Auto Provision Settings", "autoProvisionedDescription": "Allow this user to be automatically managed by identity provider", "accessControlsDescription": "Manage what this user can access and do in the organization", "accessControlsSubmit": "Save Access Controls", + "singleRolePerUserPlanNotice": "Your plan only supports one role per user.", + "singleRolePerUserEditionNotice": "This edition only supports one role per user.", "roles": "Roles", "accessUsersRoles": "Manage Users & Roles", "accessUsersRolesDescription": "Invite users and add them to roles to manage access to the organization", @@ -607,6 +694,7 @@ "resourcesErrorUpdate": "Failed to toggle resource", "resourcesErrorUpdateDescription": "An error occurred while updating the resource", "access": "Access", + "accessControl": "Access Control", "shareLink": "{resource} Share Link", "resourceSelect": "Select resource", "shareLinks": "Share Links", @@ -687,7 +775,7 @@ "resourceRoleDescription": "Admins can always access this resource.", "resourceUsersRoles": "Access Controls", "resourceUsersRolesDescription": "Configure which users and roles can visit this resource", - "resourceUsersRolesSubmit": "Save Users & Roles", + "resourceUsersRolesSubmit": "Save Access Controls", "resourceWhitelistSave": "Saved successfully", "resourceWhitelistSaveDescription": "Whitelist settings have been saved", "ssoUse": "Use Platform SSO", @@ -719,22 +807,35 @@ "countries": "Countries", "accessRoleCreate": "Create Role", "accessRoleCreateDescription": "Create a new role to group users and manage their permissions.", + "accessRoleEdit": "Edit Role", + "accessRoleEditDescription": "Edit role information.", "accessRoleCreateSubmit": "Create Role", "accessRoleCreated": "Role created", "accessRoleCreatedDescription": "The role has been successfully created.", "accessRoleErrorCreate": "Failed to create role", "accessRoleErrorCreateDescription": "An error occurred while creating the role.", + "accessRoleUpdateSubmit": "Update Role", + "accessRoleUpdated": "Role updated", + "accessRoleUpdatedDescription": "The role has been successfully updated.", + "accessApprovalUpdated": "Approval processed", + "accessApprovalApprovedDescription": "Set Approval Request decision to approved.", + "accessApprovalDeniedDescription": "Set Approval Request decision to denied.", + "accessRoleErrorUpdate": "Failed to update role", + "accessRoleErrorUpdateDescription": "An error occurred while updating the role.", + "accessApprovalErrorUpdate": "Failed to process approval", + "accessApprovalErrorUpdateDescription": "An error occurred while processing the approval.", "accessRoleErrorNewRequired": "New role is required", "accessRoleErrorRemove": "Failed to remove role", "accessRoleErrorRemoveDescription": "An error occurred while removing the role.", "accessRoleName": "Role Name", - "accessRoleQuestionRemove": "You're about to delete the {name} role. You cannot undo this action.", + "accessRoleQuestionRemove": "You're about to delete the `{name}` role. You cannot undo this action.", "accessRoleRemove": "Remove Role", "accessRoleRemoveDescription": "Remove a role from the organization", "accessRoleRemoveSubmit": "Remove Role", "accessRoleRemoved": "Role removed", "accessRoleRemovedDescription": "The role has been successfully removed.", "accessRoleRequiredRemove": "Before deleting this role, please select a new role to transfer existing members to.", + "network": "Network", "manage": "Manage", "sitesNotFound": "No sites found.", "pangolinServerAdmin": "Server Admin - Pangolin", @@ -750,6 +851,9 @@ "sitestCountIncrease": "Increase site count", "idpManage": "Manage Identity Providers", "idpManageDescription": "View and manage identity providers in the system", + "idpGlobalModeBanner": "Identity providers (IdPs) per organization are disabled on this server. It is using global IdPs (shared across all organizations). Manage global IdPs in the admin panel. To enable IdPs per organization, edit the server config and set IdP mode to org. See the docs. If you want to continue using global IdPs and make this disappear from the organization settings, explicitly set the mode to global in the config.", + "idpGlobalModeBannerUpgradeRequired": "Identity providers (IdPs) per organization are disabled on this server. It is using global IdPs (shared across all organizations). Manage global IdPs in the admin panel. To use identity providers per organization, you must upgrade to the Enterprise edition.", + "idpGlobalModeBannerLicenseRequired": "Identity providers (IdPs) per organization are disabled on this server. It is using global IdPs (shared across all organizations). Manage global IdPs in the admin panel. To use identity providers per organization, an Enterprise license is required.", "idpDeletedDescription": "Identity provider deleted successfully", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Are you sure you want to permanently delete the identity provider?", @@ -826,7 +930,7 @@ "defaultMappingsRole": "Default Role Mapping", "defaultMappingsRoleDescription": "The result of this expression must return the role name as defined in the organization as a string.", "defaultMappingsOrg": "Default Organization Mapping", - "defaultMappingsOrgDescription": "This expression must return the org ID or true for the user to be allowed to access the organization.", + "defaultMappingsOrgDescription": "When set, this expression must return the organization ID or true for the user to access that organization. When unset, defining an organization policy for that org is enough: the user is allowed in as long as a valid role mapping can be resolved for them within the organization.", "defaultMappingsSubmit": "Save Default Mappings", "orgPoliciesEdit": "Edit Organization Policy", "org": "Organization", @@ -840,6 +944,7 @@ "orgPolicyConfig": "Configure access for an organization", "idpUpdatedDescription": "Identity provider updated successfully", "redirectUrl": "Redirect URL", + "orgIdpRedirectUrls": "Redirect URLs", "redirectUrlAbout": "About Redirect URL", "redirectUrlAboutDescription": "This is the URL to which users will be redirected after authentication. You need to configure this URL in the identity provider's settings.", "pangolinAuth": "Auth - Pangolin", @@ -863,7 +968,7 @@ "inviteAlready": "Looks like you've been invited!", "inviteAlreadyDescription": "To accept the invite, you must log in or create an account.", "signupQuestion": "Already have an account?", - "login": "Log in", + "login": "Log In", "resourceNotFound": "Resource Not Found", "resourceNotFoundDescription": "The resource you're trying to access does not exist.", "pincodeRequirementsLength": "PIN must be exactly 6 digits", @@ -943,13 +1048,13 @@ "passwordExpiryDescription": "This organization requires you to change your password every {maxDays} days.", "changePasswordNow": "Change Password Now", "pincodeAuth": "Authenticator Code", - "pincodeSubmit2": "Submit Code", + "pincodeSubmit2": "Submit code", "passwordResetSubmit": "Request Reset", - "passwordResetAlreadyHaveCode": "Enter Password Reset Code", + "passwordResetAlreadyHaveCode": "Enter Code", "passwordResetSmtpRequired": "Please contact your administrator", "passwordResetSmtpRequiredDescription": "A password reset code is required to reset your password. Please contact your administrator for assistance.", "passwordBack": "Back to Password", - "loginBack": "Go back to log in", + "loginBack": "Go back to main login page", "signup": "Sign up", "loginStart": "Log in to get started", "idpOidcTokenValidating": "Validating OIDC token", @@ -972,12 +1077,12 @@ "pangolinSetup": "Setup - Pangolin", "orgNameRequired": "Organization name is required", "orgIdRequired": "Organization ID is required", + "orgIdMaxLength": "Organization ID must be at most 32 characters", "orgErrorCreate": "An error occurred while creating org", "pageNotFound": "Page Not Found", "pageNotFoundDescription": "Oops! The page you're looking for doesn't exist.", "overview": "Overview", "home": "Home", - "accessControl": "Access Control", "settings": "Settings", "usersAll": "All Users", "license": "License", @@ -1035,19 +1140,29 @@ "updateOrgUser": "Update Org User", "createOrgUser": "Create Org User", "actionUpdateOrg": "Update Organization", + "actionRemoveInvitation": "Remove Invitation", "actionUpdateUser": "Update User", "actionGetUser": "Get User", "actionGetOrgUser": "Get Organization User", "actionListOrgDomains": "List Organization Domains", + "actionGetDomain": "Get Domain", + "actionCreateOrgDomain": "Create Domain", + "actionUpdateOrgDomain": "Update Domain", + "actionDeleteOrgDomain": "Delete Domain", + "actionGetDNSRecords": "Get DNS Records", + "actionRestartOrgDomain": "Restart Domain", "actionCreateSite": "Create Site", "actionDeleteSite": "Delete Site", "actionGetSite": "Get Site", "actionListSites": "List Sites", "actionApplyBlueprint": "Apply Blueprint", + "actionListBlueprints": "List Blueprints", + "actionGetBlueprint": "Get Blueprint", "setupToken": "Setup Token", "setupTokenDescription": "Enter the setup token from the server console.", "setupTokenRequired": "Setup token is required", "actionUpdateSite": "Update Site", + "actionResetSiteBandwidth": "Reset Organization Bandwidth", "actionListSiteRoles": "List Allowed Site Roles", "actionCreateResource": "Create Resource", "actionDeleteResource": "Delete Resource", @@ -1077,6 +1192,7 @@ "actionRemoveUser": "Remove User", "actionListUsers": "List Users", "actionAddUserRole": "Add User Role", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Generate Access Token", "actionDeleteAccessToken": "Delete Access Token", "actionListAccessTokens": "List Access Tokens", @@ -1104,6 +1220,10 @@ "actionUpdateIdpOrg": "Update IDP Org", "actionCreateClient": "Create Client", "actionDeleteClient": "Delete Client", + "actionArchiveClient": "Archive Client", + "actionUnarchiveClient": "Unarchive Client", + "actionBlockClient": "Block Client", + "actionUnblockClient": "Unblock Client", "actionUpdateClient": "Update Client", "actionListClients": "List Clients", "actionGetClient": "Get Client", @@ -1117,17 +1237,18 @@ "actionViewLogs": "View Logs", "noneSelected": "None selected", "orgNotFound2": "No organizations found.", - "searchProgress": "Search...", + "searchPlaceholder": "Search...", + "emptySearchOptions": "No options found", "create": "Create", "orgs": "Organizations", - "loginError": "An error occurred while logging in", - "loginRequiredForDevice": "Login is required to authenticate your device.", + "loginError": "An unexpected error occurred. Please try again.", + "loginRequiredForDevice": "Login is required for your device.", "passwordForgot": "Forgot your password?", "otpAuth": "Two-Factor Authentication", "otpAuthDescription": "Enter the code from your authenticator app or one of your single-use backup codes.", "otpAuthSubmit": "Submit Code", "idpContinue": "Or continue with", - "otpAuthBack": "Back to Log In", + "otpAuthBack": "Back to Password", "navbar": "Navigation Menu", "navbarDescription": "Main navigation menu for the application", "navbarDocsLink": "Documentation", @@ -1175,29 +1296,34 @@ "sidebarOverview": "Overview", "sidebarHome": "Home", "sidebarSites": "Sites", + "sidebarApprovals": "Approval Requests", "sidebarResources": "Resources", "sidebarProxyResources": "Public", "sidebarClientResources": "Private", "sidebarAccessControl": "Access Control", "sidebarLogsAndAnalytics": "Logs & Analytics", + "sidebarTeam": "Team", "sidebarUsers": "Users", "sidebarAdmin": "Admin", "sidebarInvitations": "Invitations", "sidebarRoles": "Roles", "sidebarShareableLinks": "Links", "sidebarApiKeys": "API Keys", + "sidebarProvisioning": "Provisioning", "sidebarSettings": "Settings", "sidebarAllUsers": "All Users", "sidebarIdentityProviders": "Identity Providers", "sidebarLicense": "License", "sidebarClients": "Clients", - "sidebarUserDevices": "Users", + "sidebarUserDevices": "User Devices", "sidebarMachineClients": "Machines", "sidebarDomains": "Domains", - "sidebarGeneral": "General", + "sidebarGeneral": "Manage", "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blueprints", "sidebarOrganization": "Organization", + "sidebarManagement": "Management", + "sidebarBillingAndLicenses": "Billing & Licenses", "sidebarLogsAnalytics": "Analytics", "blueprints": "Blueprints", "blueprintsDescription": "Apply declarative configurations and view previous runs", @@ -1218,8 +1344,7 @@ "contents": "Contents", "parsedContents": "Parsed Contents (Read Only)", "enableDockerSocket": "Enable Docker Blueprint", - "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt.", - "enableDockerSocketLink": "Learn More", + "enableDockerSocketDescription": "Enable Docker Socket label scraping for blueprint labels. Socket path must be provided to Newt. Read about how this works in the documentation.", "viewDockerContainers": "View Docker Containers", "containersIn": "Containers in {siteName}", "selectContainerDescription": "Select any container to use as a hostname for this target. Click a port to use a port.", @@ -1263,6 +1388,7 @@ "setupErrorCreateAdmin": "An error occurred while creating the server admin account.", "certificateStatus": "Certificate Status", "loading": "Loading", + "loadingAnalytics": "Loading Analytics", "restart": "Restart", "domains": "Domains", "domainsDescription": "Create and manage domains available in the organization", @@ -1290,6 +1416,7 @@ "refreshError": "Failed to refresh data", "verified": "Verified", "pending": "Pending", + "pendingApproval": "Pending Approval", "sidebarBilling": "Billing", "billing": "Billing", "orgBillingDescription": "Manage billing information and subscriptions", @@ -1308,8 +1435,11 @@ "accountSetupSuccess": "Account setup completed! Welcome to Pangolin!", "documentation": "Documentation", "saveAllSettings": "Save All Settings", + "saveResourceTargets": "Save Targets", + "saveResourceHttp": "Save Proxy Settings", + "saveProxyProtocol": "Save Proxy protocol settings", "settingsUpdated": "Settings updated", - "settingsUpdatedDescription": "All settings have been updated successfully", + "settingsUpdatedDescription": "Settings updated successfully", "settingsErrorUpdate": "Failed to update settings", "settingsErrorUpdateDescription": "An error occurred while updating settings", "sidebarCollapse": "Collapse", @@ -1342,6 +1472,7 @@ "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Show More", "regionSelectorTitle": "Select Region", + "domainPickerRemoteExitNodeWarning": "Provided domains are not supported when sites connect to remote exit nodes. For resources to be available on remote nodes, use a custom domain instead.", "regionSelectorInfo": "Selecting a region helps us provide better performance for your location. You do not have to be in the same region as your server.", "regionSelectorPlaceholder": "Choose a region", "regionSelectorComingSoon": "Coming Soon", @@ -1351,10 +1482,11 @@ "billingUsageLimitsOverview": "Usage Limits Overview", "billingMonitorUsage": "Monitor your usage against configured limits. If you need limits increased please contact us support@pangolin.net.", "billingDataUsage": "Data Usage", - "billingOnlineTime": "Site Online Time", - "billingUsers": "Active Users", - "billingDomains": "Active Domains", - "billingRemoteExitNodes": "Active Self-hosted Nodes", + "billingSites": "Sites", + "billingUsers": "Users", + "billingDomains": "Domains", + "billingOrganizations": "Orgs", + "billingRemoteExitNodes": "Remote Nodes", "billingNoLimitConfigured": "No limit configured", "billingEstimatedPeriod": "Estimated Billing Period", "billingIncludedUsage": "Included Usage", @@ -1379,15 +1511,24 @@ "billingFailedToGetPortalUrl": "Failed to get portal URL", "billingPortalError": "Portal Error", "billingDataUsageInfo": "You're charged for all data transferred through your secure tunnels when connected to the cloud. This includes both incoming and outgoing traffic across all your sites. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Data is not charged when using nodes.", - "billingOnlineTimeInfo": "You're charged based on how long your sites stay connected to the cloud. For example, 44,640 minutes equals one site running 24/7 for a full month. When you reach your limit, your sites will disconnect until you upgrade your plan or reduce usage. Time is not charged when using nodes.", - "billingUsersInfo": "You're charged for each user in the organization. Billing is calculated daily based on the number of active user accounts in your org.", - "billingDomainInfo": "You're charged for each domain in the organization. Billing is calculated daily based on the number of active domain accounts in your org.", - "billingRemoteExitNodesInfo": "You're charged for each managed Node in the organization. Billing is calculated daily based on the number of active managed Nodes in your org.", + "billingSInfo": "How many sites you can use", + "billingUsersInfo": "How many users you can use", + "billingDomainInfo": "How many domains you can use", + "billingRemoteExitNodesInfo": "How many remote nodes you can use", + "billingLicenseKeys": "License Keys", + "billingLicenseKeysDescription": "Manage your license key subscriptions", + "billingLicenseSubscription": "License Subscription", + "billingInactive": "Inactive", + "billingLicenseItem": "License Item", + "billingQuantity": "Quantity", + "billingTotal": "total", + "billingModifyLicenses": "Modify License Subscription", "domainNotFound": "Domain Not Found", "domainNotFoundDescription": "This resource is disabled because the domain no longer exists our system. Please set a new domain for this resource.", "failed": "Failed", "createNewOrgDescription": "Create a new organization", "organization": "Organization", + "primary": "Primary", "port": "Port", "securityKeyManage": "Manage Security Keys", "securityKeyDescription": "Add or remove security keys for passwordless authentication", @@ -1403,7 +1544,7 @@ "securityKeyRemoveSuccess": "Security key removed successfully", "securityKeyRemoveError": "Failed to remove security key", "securityKeyLoadError": "Failed to load security keys", - "securityKeyLogin": "Continue with security key", + "securityKeyLogin": "Use Security Key", "securityKeyAuthError": "Failed to authenticate with security key", "securityKeyRecommendation": "Register a backup security key on another device to ensure you always have access to your account.", "registering": "Registering...", @@ -1459,11 +1600,47 @@ "resourcePortRequired": "Port number is required for non-HTTP resources", "resourcePortNotAllowed": "Port number should not be set for HTTP resources", "billingPricingCalculatorLink": "Pricing Calculator", + "billingYourPlan": "Your Plan", + "billingViewOrModifyPlan": "View or modify your current plan", + "billingViewPlanDetails": "View Plan Details", + "billingUsageAndLimits": "Usage and Limits", + "billingViewUsageAndLimits": "View your plan's limits and current usage", + "billingCurrentUsage": "Current Usage", + "billingMaximumLimits": "Maximum Limits", + "billingRemoteNodes": "Remote Nodes", + "billingUnlimited": "Unlimited", + "billingPaidLicenseKeys": "Paid License Keys", + "billingManageLicenseSubscription": "Manage your subscription for paid self-hosted license keys", + "billingCurrentKeys": "Current Keys", + "billingModifyCurrentPlan": "Modify Current Plan", + "billingConfirmUpgrade": "Confirm Upgrade", + "billingConfirmDowngrade": "Confirm Downgrade", + "billingConfirmUpgradeDescription": "You are about to upgrade your plan. Review the new limits and pricing below.", + "billingConfirmDowngradeDescription": "You are about to downgrade your plan. Review the new limits and pricing below.", + "billingPlanIncludes": "Plan Includes", + "billingProcessing": "Processing...", + "billingConfirmUpgradeButton": "Confirm Upgrade", + "billingConfirmDowngradeButton": "Confirm Downgrade", + "billingLimitViolationWarning": "Usage Exceeds New Plan Limits", + "billingLimitViolationDescription": "Your current usage exceeds the limits of this plan. After downgrading, all actions will be disabled until you reduce usage within the new limits. Please review the features below that are currently over the limits. Limits in violation:", + "billingFeatureLossWarning": "Feature Availability Notice", + "billingFeatureLossDescription": "By downgrading, features not available in the new plan will be automatically disabled. Some settings and configurations may be lost. Please review the pricing matrix to understand which features will no longer be available.", + "billingUsageExceedsLimit": "Current usage ({current}) exceeds limit ({limit})", + "billingPastDueTitle": "Payment Past Due", + "billingPastDueDescription": "Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier.", + "billingUnpaidTitle": "Subscription Unpaid", + "billingUnpaidDescription": "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription.", + "billingIncompleteTitle": "Payment Incomplete", + "billingIncompleteDescription": "Your payment is incomplete. Please complete the payment process to activate your subscription.", + "billingIncompleteExpiredTitle": "Payment Expired", + "billingIncompleteExpiredDescription": "Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features.", + "billingManageSubscription": "Manage your subscription", + "billingResolvePaymentIssue": "Please resolve your payment issue before upgrading or downgrading", "signUpTerms": { "IAgreeToThe": "I agree to the", "termsOfService": "terms of service", "and": "and", - "privacyPolicy": "privacy policy" + "privacyPolicy": "privacy policy." }, "signUpMarketing": { "keepMeInTheLoop": "Keep me in the loop with news, updates, and new features by email." @@ -1483,8 +1660,8 @@ "addressDescription": "The internal address of the client. Must fall within the organization's subnet.", "selectSites": "Select sites", "sitesDescription": "The client will have connectivity to the selected sites", - "clientInstallOlm": "Install Olm", - "clientInstallOlmDescription": "Get Olm running on your system", + "clientInstallOlm": "Install Machine Client", + "clientInstallOlmDescription": "Install the machine client for your system", "clientOlmCredentials": "Credentials", "clientOlmCredentialsDescription": "This is how the client will authenticate with the server", "olmEndpoint": "Endpoint", @@ -1508,6 +1685,7 @@ "addNewTarget": "Add New Target", "targetsList": "Targets List", "advancedMode": "Advanced Mode", + "advancedSettings": "Advanced Settings", "targetErrorDuplicateTargetFound": "Duplicate target found", "healthCheckHealthy": "Healthy", "healthCheckUnhealthy": "Unhealthy", @@ -1529,6 +1707,26 @@ "IntervalSeconds": "Healthy Interval", "timeoutSeconds": "Timeout (sec)", "timeIsInSeconds": "Time is in seconds", + "requireDeviceApproval": "Require Device Approvals", + "requireDeviceApprovalDescription": "Users with this role need new devices approved by an admin before they can connect and access resources.", + "sshAccess": "SSH Access", + "roleAllowSsh": "Allow SSH", + "roleAllowSshAllow": "Allow", + "roleAllowSshDisallow": "Disallow", + "roleAllowSshDescription": "Allow users with this role to connect to resources via SSH. When disabled, the role cannot use SSH access.", + "sshSudoMode": "Sudo Access", + "sshSudoModeNone": "None", + "sshSudoModeNoneDescription": "User cannot run commands with sudo.", + "sshSudoModeFull": "Full Sudo", + "sshSudoModeFullDescription": "User can run any command with sudo.", + "sshSudoModeCommands": "Commands", + "sshSudoModeCommandsDescription": "User can run only the specified commands with sudo.", + "sshSudo": "Allow sudo", + "sshSudoCommands": "Sudo Commands", + "sshSudoCommandsDescription": "Comma separated list of commands the user is allowed to run with sudo.", + "sshCreateHomeDir": "Create Home Directory", + "sshUnixGroups": "Unix Groups", + "sshUnixGroupsDescription": "Comma separated Unix groups to add the user to on the target host.", "retryAttempts": "Retry Attempts", "expectedResponseCodes": "Expected Response Codes", "expectedResponseCodesDescription": "HTTP status code that indicates healthy status. If left blank, 200-300 is considered healthy.", @@ -1569,6 +1767,8 @@ "resourcesTableNoInternalResourcesFound": "No internal resources found.", "resourcesTableDestination": "Destination", "resourcesTableAlias": "Alias", + "resourcesTableAliasAddress": "Alias Address", + "resourcesTableAliasAddressInfo": "This address is part of the organization's utility subnet. It's used to resolve alias records using internal DNS resolution.", "resourcesTableClients": "Clients", "resourcesTableAndOnlyAccessibleInternally": "and are only accessible internally when connected with a client.", "resourcesTableNoTargets": "No targets", @@ -1616,9 +1816,8 @@ "createInternalResourceDialogResourceProperties": "Resource Properties", "createInternalResourceDialogName": "Name", "createInternalResourceDialogSite": "Site", - "createInternalResourceDialogSelectSite": "Select site...", - "createInternalResourceDialogSearchSites": "Search sites...", - "createInternalResourceDialogNoSitesFound": "No sites found.", + "selectSite": "Select site...", + "noSitesFound": "No sites found.", "createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", @@ -1658,7 +1857,7 @@ "siteAddressDescription": "The internal address of the site. Must fall within the organization's subnet.", "siteNameDescription": "The display name of the site that can be changed later.", "autoLoginExternalIdp": "Auto Login with External IDP", - "autoLoginExternalIdpDescription": "Immediately redirect the user to the external IDP for authentication.", + "autoLoginExternalIdpDescription": "Immediately redirect the user to the external identity provider for authentication.", "selectIdp": "Select IDP", "selectIdpPlaceholder": "Choose an IDP...", "selectIdpRequired": "Please select an IDP when auto login is enabled.", @@ -1670,7 +1869,7 @@ "autoLoginErrorNoRedirectUrl": "No redirect URL received from the identity provider.", "autoLoginErrorGeneratingUrl": "Failed to generate authentication URL.", "remoteExitNodeManageRemoteExitNodes": "Remote Nodes", - "remoteExitNodeDescription": "Self-host one or more remote nodes to extend network connectivity and reduce reliance on the cloud", + "remoteExitNodeDescription": "Self-host your own remote relay and proxy server nodes", "remoteExitNodes": "Nodes", "searchRemoteExitNodes": "Search nodes...", "remoteExitNodeAdd": "Add Node", @@ -1680,20 +1879,22 @@ "remoteExitNodeConfirmDelete": "Confirm Delete Node", "remoteExitNodeDelete": "Delete Node", "sidebarRemoteExitNodes": "Remote Nodes", + "remoteExitNodeId": "ID", + "remoteExitNodeSecretKey": "Secret", "remoteExitNodeCreate": { - "title": "Create Node", - "description": "Create a new node to extend network connectivity", + "title": "Create Remote Node", + "description": "Create a new self-hosted remote relay and proxy server node", "viewAllButton": "View All Nodes", "strategy": { "title": "Creation Strategy", - "description": "Choose this to manually configure the node or generate new credentials.", + "description": "Select how you want to create the remote node", "adopt": { "title": "Adopt Node", "description": "Choose this if you already have the credentials for the node." }, "generate": { "title": "Generate Keys", - "description": "Choose this if you want to generate new keys for the node" + "description": "Choose this if you want to generate new keys for the node." } }, "adopt": { @@ -1816,6 +2017,25 @@ "invalidValue": "Invalid value", "idpTypeLabel": "Identity Provider Type", "roleMappingExpressionPlaceholder": "e.g., contains(groups, 'admin') && 'Admin' || 'Member'", + "roleMappingModeFixedRoles": "Fixed Roles", + "roleMappingModeMappingBuilder": "Mapping Builder", + "roleMappingModeRawExpression": "Raw Expression", + "roleMappingFixedRolesPlaceholderSelect": "Select one or more roles", + "roleMappingFixedRolesPlaceholderFreeform": "Type role names (exact match per organization)", + "roleMappingFixedRolesDescriptionSameForAll": "Assign the same role set to every auto-provisioned user.", + "roleMappingFixedRolesDescriptionDefaultPolicy": "For default policies, type role names that exist in each organization where users are provisioned. Names must match exactly.", + "roleMappingClaimPath": "Claim Path", + "roleMappingClaimPathPlaceholder": "groups", + "roleMappingClaimPathDescription": "Path in the token payload that contains source values (for example, groups).", + "roleMappingMatchValue": "Match Value", + "roleMappingAssignRoles": "Assign Roles", + "roleMappingAddMappingRule": "Add Mapping Rule", + "roleMappingRawExpressionResultDescription": "Expression must evaluate to a string or string array.", + "roleMappingRawExpressionResultDescriptionSingleRole": "Expression must evaluate to a string (a single role name).", + "roleMappingMatchValuePlaceholder": "Match value (for example: admin)", + "roleMappingAssignRolesPlaceholderFreeform": "Type role names (exact per org)", + "roleMappingBuilderFreeformRowHint": "Role names must match a role in each target organization.", + "roleMappingRemoveRule": "Remove", "idpGoogleConfiguration": "Google Configuration", "idpGoogleConfigurationDescription": "Configure the Google OAuth2 credentials", "idpGoogleClientIdDescription": "Google OAuth2 Client ID", @@ -1840,9 +2060,33 @@ "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subnet", "subnetDescription": "The subnet for this organization's network configuration.", - "authPage": "Auth Page", - "authPageDescription": "Configure the auth page for the organization", + "customDomain": "Custom Domain", + "authPage": "Authentication Pages", + "authPageDescription": "Set a custom domain for the organization's authentication pages", "authPageDomain": "Auth Page Domain", + "authPageBranding": "Custom Branding", + "authPageBrandingDescription": "Configure the branding that appears on authentication pages for this organization", + "authPageBrandingUpdated": "Auth page Branding updated successfully", + "authPageBrandingRemoved": "Auth page Branding removed successfully", + "authPageBrandingRemoveTitle": "Remove Auth Page Branding", + "authPageBrandingQuestionRemove": "Are you sure you want to remove the branding for Auth Pages ?", + "authPageBrandingDeleteConfirm": "Confirm Delete Branding", + "brandingLogoURL": "Logo URL", + "brandingLogoURLOrPath": "Logo URL or Path", + "brandingLogoPathDescription": "Enter a URL or a local path.", + "brandingLogoURLDescription": "Enter a publicly accessible URL to your logo image.", + "brandingPrimaryColor": "Primary Color", + "brandingLogoWidth": "Width (px)", + "brandingLogoHeight": "Height (px)", + "brandingOrgTitle": "Title for Organization Auth Page", + "brandingOrgDescription": "{orgName} will be replaced with the organization's name", + "brandingOrgSubtitle": "Subtitle for Organization Auth Page", + "brandingResourceTitle": "Title for Resource Auth Page", + "brandingResourceSubtitle": "Subtitle for Resource Auth Page", + "brandingResourceDescription": "{resourceName} will be replaced with the organization's name", + "saveAuthPageDomain": "Save Domain", + "saveAuthPageBranding": "Save Branding", + "removeAuthPageBranding": "Remove Branding", "noDomainSet": "No domain set", "changeDomain": "Change Domain", "selectDomain": "Select Domain", @@ -1851,7 +2095,7 @@ "setAuthPageDomain": "Set Auth Page Domain", "failedToFetchCertificate": "Failed to fetch certificate", "failedToRestartCertificate": "Failed to restart certificate", - "addDomainToEnableCustomAuthPages": "Add a domain to enable custom authentication pages for the organization", + "addDomainToEnableCustomAuthPages": "Users will be able to access the organization's login page and complete resource authentication using this domain.", "selectDomainForOrgAuthPage": "Select a domain for the organization's authentication page", "domainPickerProvidedDomain": "Provided Domain", "domainPickerFreeProvidedDomain": "Free Provided Domain", @@ -1866,11 +2110,27 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" could not be made valid for {domain}.", "domainPickerSubdomainSanitized": "Subdomain sanitized", "domainPickerSubdomainCorrected": "\"{sub}\" was corrected to \"{sanitized}\"", - "orgAuthSignInTitle": "Sign in to the organization", + "orgAuthSignInTitle": "Organization Sign In", "orgAuthChooseIdpDescription": "Choose your identity provider to continue", "orgAuthNoIdpConfigured": "This organization doesn't have any identity providers configured. You can log in with your Pangolin identity instead.", "orgAuthSignInWithPangolin": "Sign in with Pangolin", + "orgAuthSignInToOrg": "Sign in to an organization", + "orgAuthSelectOrgTitle": "Organization Sign In", + "orgAuthSelectOrgDescription": "Enter your organization ID to continue", + "orgAuthOrgIdPlaceholder": "your-organization", + "orgAuthOrgIdHelp": "Enter your organization's unique identifier", + "orgAuthSelectOrgHelp": "After entering your organization ID, you'll be taken to your organization's sign-in page where you can use SSO or your organization credentials.", + "orgAuthRememberOrgId": "Remember this organization ID", + "orgAuthBackToSignIn": "Back to standard sign in", + "orgAuthNoAccount": "Don't have an account?", "subscriptionRequiredToUse": "A subscription is required to use this feature.", + "mustUpgradeToUse": "You must upgrade your subscription to use this feature.", + "subscriptionRequiredTierToUse": "This feature requires {tier}.", + "upgradeToTierToUse": "Upgrade to {tier} to use this feature.", + "subscriptionTierTier1": "Home", + "subscriptionTierTier2": "Team", + "subscriptionTierTier3": "Business", + "subscriptionTierEnterprise": "Enterprise", "idpDisabled": "Identity providers are disabled.", "orgAuthPageDisabled": "Organization auth page is disabled.", "domainRestartedDescription": "Domain verification restarted successfully", @@ -1884,6 +2144,8 @@ "enableTwoFactorAuthentication": "Enable two-factor authentication", "completeSecuritySteps": "Complete Security Steps", "securitySettings": "Security Settings", + "dangerSection": "Danger Zone", + "dangerSectionDescription": "Permanently delete all data associated with this organization", "securitySettingsDescription": "Configure security policies for the organization", "requireTwoFactorForAllUsers": "Require Two-Factor Authentication for All Users", "requireTwoFactorDescription": "When enabled, all internal users in this organization must have two-factor authentication enabled to access the organization.", @@ -1921,7 +2183,7 @@ "securityPolicyChangeWarningText": "This will affect all users in the organization", "authPageErrorUpdateMessage": "An error occurred while updating the auth page settings", "authPageErrorUpdate": "Unable to update auth page", - "authPageUpdated": "Auth page updated successfully", + "authPageDomainUpdated": "Auth page Domain updated successfully", "healthCheckNotAvailable": "Local", "rewritePath": "Rewrite Path", "rewritePathDescription": "Optionally rewrite the path before forwarding to the target.", @@ -1949,8 +2211,15 @@ "beta": "Beta", "manageUserDevices": "User Devices", "manageUserDevicesDescription": "View and manage devices that users use to privately connect to resources", + "downloadClientBannerTitle": "Download Pangolin Client", + "downloadClientBannerDescription": "Download the Pangolin client for your system to connect to the Pangolin network and access resources privately.", "manageMachineClients": "Manage Machine Clients", "manageMachineClientsDescription": "Create and manage clients that servers and systems use to privately connect to resources", + "machineClientsBannerTitle": "Servers & Automated Systems", + "machineClientsBannerDescription": "Machine clients are for servers and automated systems that are not associated with a specific user. They authenticate with an ID and secret, and can be deployed as a CLI or a container.", + "machineClientsBannerPangolinCLI": "Pangolin CLI", + "machineClientsBannerOlmCLI": "Olm CLI", + "machineClientsBannerOlmContainer": "Container", "clientsTableUserClients": "User", "clientsTableMachineClients": "Machine", "licenseTableValidUntil": "Valid Until", @@ -2049,6 +2318,32 @@ } } }, + "newPricingLicenseForm": { + "title": "Get a license", + "description": "Choose a plan and tell us how you plan to use Pangolin.", + "chooseTier": "Choose your plan", + "viewPricingLink": "See pricing, features, and limits", + "tiers": { + "starter": { + "title": "Starter", + "description": "Enterprise features, 25 users, 25 sites, and community support." + }, + "scale": { + "title": "Scale", + "description": "Enterprise features, 50 users, 50 sites, and priority support." + } + }, + "personalUseOnly": "Personal use only (free license — no checkout)", + "buttons": { + "continueToCheckout": "Continue to Checkout" + }, + "toasts": { + "checkoutError": { + "title": "Checkout error", + "description": "Could not start checkout. Please try again." + } + } + }, "priority": "Priority", "priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.", "instanceName": "Instance Name", @@ -2094,7 +2389,7 @@ "request": "Request", "requests": "Requests", "logs": "Logs", - "logsSettingsDescription": "Monitor logs collected from this orginization", + "logsSettingsDescription": "Monitor logs collected from this organization", "searchLogs": "Search logs...", "action": "Action", "actor": "Actor", @@ -2137,6 +2432,8 @@ "logRetentionAccessDescription": "How long to retain access logs", "logRetentionActionLabel": "Action Log Retention", "logRetentionActionDescription": "How long to retain action logs", + "logRetentionConnectionLabel": "Connection Log Retention", + "logRetentionConnectionDescription": "How long to retain connection logs", "logRetentionDisabled": "Disabled", "logRetention3Days": "3 days", "logRetention7Days": "7 days", @@ -2147,7 +2444,14 @@ "logRetentionEndOfFollowingYear": "End of following year", "actionLogsDescription": "View a history of actions performed in this organization", "accessLogsDescription": "View access auth requests for resources in this organization", - "licenseRequiredToUse": "An Enterprise license is required to use this feature.", + "connectionLogs": "Connection Logs", + "connectionLogsDescription": "View connection logs for tunnels in this organization", + "sidebarLogsConnection": "Connection Logs", + "sourceAddress": "Source Address", + "destinationAddress": "Destination Address", + "duration": "Duration", + "licenseRequiredToUse": "An Enterprise Edition license or Pangolin Cloud is required to use this feature. Book a demo or POC trial.", + "ossEnterpriseEditionRequired": "The Enterprise Edition is required to use this feature. This feature is also available in Pangolin Cloud. Book a demo or POC trial.", "certResolver": "Certificate Resolver", "certResolverDescription": "Select the certificate resolver to use for this resource.", "selectCertResolver": "Select Certificate Resolver", @@ -2156,7 +2460,7 @@ "unverified": "Unverified", "domainSetting": "Domain Settings", "domainSettingDescription": "Configure settings for the domain", - "preferWildcardCertDescription": "Attempt to generate a wildcard certificate (require a properly configured certificate resolver).", + "preferWildcardCertDescription": "Attempt to generate a wildcard certificate (requires a properly configured certificate resolver).", "recordName": "Record Name", "auto": "Auto", "TTL": "TTL", @@ -2208,6 +2512,8 @@ "deviceCodeInvalidFormat": "Code must be 9 characters (e.g., A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Invalid or expired code", "deviceCodeVerifyFailed": "Failed to verify device code", + "deviceCodeValidating": "Validating device code...", + "deviceCodeVerifying": "Verifying device authorization...", "signedInAs": "Signed in as", "deviceCodeEnterPrompt": "Enter the code displayed on the device", "continue": "Continue", @@ -2220,7 +2526,7 @@ "deviceOrganizationsAccess": "Access to all organizations your account has access to", "deviceAuthorize": "Authorize {applicationName}", "deviceConnected": "Device Connected!", - "deviceAuthorizedMessage": "Device is authorized to access your account.", + "deviceAuthorizedMessage": "Device is authorized to access your account. Please return to the client application.", "pangolinCloud": "Pangolin Cloud", "viewDevices": "View Devices", "viewDevicesDescription": "Manage your connected devices", @@ -2282,6 +2588,7 @@ "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Not you? Use a different account.", "deviceLoginDeviceRequestingAccessToAccount": "A device is requesting access to this account.", + "loginSelectAuthenticationMethod": "Select an authentication method to continue.", "noData": "No Data", "machineClients": "Machine Clients", "install": "Install", @@ -2291,6 +2598,8 @@ "setupFailedToFetchSubnet": "Failed to fetch default subnet", "setupSubnetAdvanced": "Subnet (Advanced)", "setupSubnetDescription": "The subnet for this organization's internal network.", + "setupUtilitySubnet": "Utility Subnet (Advanced)", + "setupUtilitySubnetDescription": "The subnet for this organization's alias addresses and DNS server.", "siteRegenerateAndDisconnect": "Regenerate and Disconnect", "siteRegenerateAndDisconnectConfirmation": "Are you sure you want to regenerate the credentials and disconnect this site?", "siteRegenerateAndDisconnectWarning": "This will regenerate the credentials and immediately disconnect the site. The site will need to be restarted with the new credentials.", @@ -2307,7 +2616,179 @@ "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", - "personalUseOnly": "Personal Use Only", - "loginPageLicenseWatermark": "This instance is licensed for personal use only.", - "instanceIsUnlicensed": "This instance is unlicensed." + "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).", + "organizationLoginPageTitle": "Organization Login Page", + "organizationLoginPageDescription": "Customize the login page for this organization", + "resourceLoginPageTitle": "Resource Login Page", + "resourceLoginPageDescription": "Customize the login page for individual resources", + "enterConfirmation": "Enter confirmation", + "blueprintViewDetails": "Details", + "defaultIdentityProvider": "Default Identity Provider", + "defaultIdentityProviderDescription": "When a default identity provider is selected, the user will be automatically redirected to the provider for authentication.", + "editInternalResourceDialogNetworkSettings": "Network Settings", + "editInternalResourceDialogAccessPolicy": "Access Policy", + "editInternalResourceDialogAddRoles": "Add Roles", + "editInternalResourceDialogAddUsers": "Add Users", + "editInternalResourceDialogAddClients": "Add Clients", + "editInternalResourceDialogDestinationLabel": "Destination", + "editInternalResourceDialogDestinationDescription": "Specify the destination address for the internal resource. This can be a hostname, IP address, or CIDR range depending on the selected mode. Optionally set an internal DNS alias for easier identification.", + "editInternalResourceDialogPortRestrictionsDescription": "Restrict access to specific TCP/UDP ports or allow/block all ports.", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "Access Control", + "editInternalResourceDialogAccessControlDescription": "Control which roles, users, and machine clients have access to this resource when connected. Admins always have access.", + "editInternalResourceDialogPortRangeValidationError": "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.", + "internalResourceAuthDaemonStrategy": "SSH Auth Daemon Location", + "internalResourceAuthDaemonStrategyDescription": "Choose where the SSH authentication daemon runs: on the site (Newt) or on a remote host.", + "internalResourceAuthDaemonDescription": "The SSH authentication daemon handles SSH key signing and PAM authentication for this resource. Choose whether it runs on the site (Newt) or on a separate remote host. See the documentation for more.", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "Select Strategy", + "internalResourceAuthDaemonStrategyLabel": "Location", + "internalResourceAuthDaemonSite": "On Site", + "internalResourceAuthDaemonSiteDescription": "Auth daemon runs on the site (Newt).", + "internalResourceAuthDaemonRemote": "Remote Host", + "internalResourceAuthDaemonRemoteDescription": "Auth daemon runs on this resource's destination - not the site.", + "internalResourceAuthDaemonPort": "Daemon Port (optional)", + "orgAuthWhatsThis": "Where can I find my organization ID?", + "learnMore": "Learn more", + "backToHome": "Go back to home", + "needToSignInToOrg": "Need to use your organization's identity provider?", + "maintenanceMode": "Maintenance Mode", + "maintenanceModeDescription": "Display a maintenance page to visitors", + "maintenanceModeType": "Maintenance Mode Type", + "showMaintenancePage": "Show a maintenance page to visitors", + "enableMaintenanceMode": "Enable Maintenance Mode", + "automatic": "Automatic", + "automaticModeDescription": " Show maintenance page only when all backend targets are down or unhealthy. Your resource continues working normally as long as at least one target is healthy.", + "forced": "Forced", + "forcedModeDescription": "Always show the maintenance page regardless of backend health. Use this for planned maintenance when you want to prevent all access.", + "warning:": "Warning:", + "forcedeModeWarning": "All traffic will be directed to the maintenance page. Your backend resources will not receive any requests.", + "pageTitle": "Page Title", + "pageTitleDescription": "The main heading displayed on the maintenance page", + "maintenancePageMessage": "Maintenance Message", + "maintenancePageMessagePlaceholder": "We'll be back soon! Our site is currently undergoing scheduled maintenance.", + "maintenancePageMessageDescription": "Detailed message explaining the maintenance", + "maintenancePageTimeTitle": "Estimated Completion Time (Optional)", + "maintenanceTime": "e.g., 2 hours, Nov 1 at 5:00 PM", + "maintenanceEstimatedTimeDescription": "When you expect maintenance to be completed", + "editDomain": "Edit Domain", + "editDomainDescription": "Select a domain for your resource", + "maintenanceModeDisabledTooltip": "This feature requires a valid license to enable.", + "maintenanceScreenTitle": "Service Temporarily Unavailable", + "maintenanceScreenMessage": "We are currently experiencing technical difficulties. Please check back soon.", + "maintenanceScreenEstimatedCompletion": "Estimated Completion:", + "createInternalResourceDialogDestinationRequired": "Destination is required", + "available": "Available", + "archived": "Archived", + "noArchivedDevices": "No archived devices found", + "deviceArchived": "Device archived", + "deviceArchivedDescription": "The device has been successfully archived.", + "errorArchivingDevice": "Error archiving device", + "failedToArchiveDevice": "Failed to archive device", + "deviceQuestionArchive": "Are you sure you want to archive this device?", + "deviceMessageArchive": "The device will be archived and removed from your active devices list.", + "deviceArchiveConfirm": "Archive Device", + "archiveDevice": "Archive Device", + "archive": "Archive", + "deviceUnarchived": "Device unarchived", + "deviceUnarchivedDescription": "The device has been successfully unarchived.", + "errorUnarchivingDevice": "Error unarchiving device", + "failedToUnarchiveDevice": "Failed to unarchive device", + "unarchive": "Unarchive", + "archiveClient": "Archive Client", + "archiveClientQuestion": "Are you sure you want to archive this client?", + "archiveClientMessage": "The client will be archived and removed from your active clients list.", + "archiveClientConfirm": "Archive Client", + "blockClient": "Block Client", + "blockClientQuestion": "Are you sure you want to block this client?", + "blockClientMessage": "The device will be forced to disconnect if currently connected. You can unblock the device later.", + "blockClientConfirm": "Block Client", + "active": "Active", + "usernameOrEmail": "Username or Email", + "selectYourOrganization": "Select your organization", + "signInTo": "Log in in to", + "signInWithPassword": "Continue with Password", + "noAuthMethodsAvailable": "No authentication methods available for this organization.", + "enterPassword": "Enter your password", + "enterMfaCode": "Enter the code from your authenticator app", + "securityKeyRequired": "Please use your security key to sign in.", + "needToUseAnotherAccount": "Need to use a different account?", + "loginLegalDisclaimer": "By clicking the buttons below, you acknowledge you have read, understand, and agree to the Terms of Service and Privacy Policy.", + "termsOfService": "Terms of Service", + "privacyPolicy": "Privacy Policy", + "userNotFoundWithUsername": "No user found with that username.", + "verify": "Verify", + "signIn": "Sign In", + "forgotPassword": "Forgot password?", + "orgSignInTip": "If you've logged in before, you can enter your username or email above to authenticate with your organization's identity provider instead. It's easier!", + "continueAnyway": "Continue anyway", + "dontShowAgain": "Don't show again", + "orgSignInNotice": "Did you know?", + "signupOrgNotice": "Trying to sign in?", + "signupOrgTip": "Are you trying to sign in through your organization's identity provider?", + "signupOrgLink": "Sign in or sign up with your organization instead", + "verifyEmailLogInWithDifferentAccount": "Use a Different Account", + "logIn": "Log In", + "deviceInformation": "Device Information", + "deviceInformationDescription": "Information about the device and agent", + "deviceSecurity": "Device Security", + "deviceSecurityDescription": "Device security posture information", + "platform": "Platform", + "macosVersion": "macOS Version", + "windowsVersion": "Windows Version", + "iosVersion": "iOS Version", + "androidVersion": "Android Version", + "osVersion": "OS Version", + "kernelVersion": "Kernel Version", + "deviceModel": "Device Model", + "serialNumber": "Serial Number", + "hostname": "Hostname", + "firstSeen": "First Seen", + "lastSeen": "Last Seen", + "biometricsEnabled": "Biometrics Enabled", + "diskEncrypted": "Disk Encrypted", + "firewallEnabled": "Firewall Enabled", + "autoUpdatesEnabled": "Auto Updates Enabled", + "tpmAvailable": "TPM Available", + "windowsAntivirusEnabled": "Antivirus Enabled", + "macosSipEnabled": "System Integrity Protection (SIP)", + "macosGatekeeperEnabled": "Gatekeeper", + "macosFirewallStealthMode": "Firewall Stealth Mode", + "linuxAppArmorEnabled": "AppArmor", + "linuxSELinuxEnabled": "SELinux", + "deviceSettingsDescription": "View device information and settings", + "devicePendingApprovalDescription": "This device is waiting for approval", + "deviceBlockedDescription": "This device is currently blocked. It won't be able to connect to any resources unless unblocked.", + "unblockClient": "Unblock Client", + "unblockClientDescription": "The device has been unblocked", + "unarchiveClient": "Unarchive Client", + "unarchiveClientDescription": "The device has been unarchived", + "block": "Block", + "unblock": "Unblock", + "deviceActions": "Device Actions", + "deviceActionsDescription": "Manage device status and access", + "devicePendingApprovalBannerDescription": "This device is pending approval. It won't be able to connect to resources until approved.", + "connected": "Connected", + "disconnected": "Disconnected", + "approvalsEmptyStateTitle": "Device Approvals Not Enabled", + "approvalsEmptyStateDescription": "Enable device approvals for roles to require admin approval before users can connect new devices.", + "approvalsEmptyStateStep1Title": "Go to Roles", + "approvalsEmptyStateStep1Description": "Navigate to your organization's roles settings to configure device approvals.", + "approvalsEmptyStateStep2Title": "Enable Device Approvals", + "approvalsEmptyStateStep2Description": "Edit a role and enable the 'Require Device Approvals' option. Users with this role will need admin approval for new devices.", + "approvalsEmptyStatePreviewDescription": "Preview: When enabled, pending device requests will appear here for review", + "approvalsEmptyStateButtonText": "Manage Roles", + "domainErrorTitle": "We are having trouble verifying your domain", + "idpAdminAutoProvisionPoliciesTabHint": "Configure role mapping and organization policies on the Auto Provision Settings tab." } diff --git a/messages/es-ES.json b/messages/es-ES.json index 1ff02bca4..2fc52b885 100644 --- a/messages/es-ES.json +++ b/messages/es-ES.json @@ -1,5 +1,7 @@ { "setupCreate": "Crear la organización, el sitio y los recursos", + "headerAuthCompatibilityInfo": "Habilite esto para forzar una respuesta 401 no autorizada cuando falte un token de autenticación. Esto es necesario para navegadores o bibliotecas HTTP específicas que no envían credenciales sin un desafío del servidor.", + "headerAuthCompatibility": "Compatibilidad extendida", "setupNewOrg": "Nueva organización", "setupCreateOrg": "Crear organización", "setupCreateResources": "Crear Recursos", @@ -16,6 +18,8 @@ "componentsMember": "Eres un miembro de {count, plural, =0 {ninguna organización} one {una organización} other {# organizaciones}}.", "componentsInvalidKey": "Se han detectado claves de licencia inválidas o caducadas. Siga los términos de licencia para seguir usando todas las características.", "dismiss": "Descartar", + "subscriptionViolationMessage": "Estás más allá de tus límites para tu plan actual. Corrija el problema eliminando sitios, usuarios u otros recursos para permanecer dentro de tu plan.", + "subscriptionViolationViewBilling": "Ver facturación", "componentsLicenseViolation": "Violación de la Licencia: Este servidor está usando sitios {usedSites} que exceden su límite de licencias de sitios {maxSites} . Siga los términos de licencia para seguir usando todas las características.", "componentsSupporterMessage": "¡Gracias por apoyar a Pangolin como {tier}!", "inviteErrorNotValid": "Lo sentimos, pero parece que la invitación a la que intentas acceder no ha sido aceptada o ya no es válida.", @@ -33,7 +37,7 @@ "password": "Contraseña", "confirmPassword": "Confirmar contraseña", "createAccount": "Crear cuenta", - "viewSettings": "Ver ajustes", + "viewSettings": "Ver configuraciones", "delete": "Eliminar", "name": "Nombre", "online": "En línea", @@ -51,6 +55,12 @@ "siteQuestionRemove": "¿Está seguro que desea eliminar el sitio de la organización?", "siteManageSites": "Administrar Sitios", "siteDescription": "Crear y administrar sitios para permitir la conectividad a redes privadas", + "sitesBannerTitle": "Conectar cualquier red", + "sitesBannerDescription": "Un sitio es una conexión a una red remota que permite a Pangolin proporcionar acceso a recursos, públicos o privados, a usuarios en cualquier lugar. Instale el conector de red del sitio (Newt) en cualquier lugar donde pueda ejecutar un binario o contenedor para establecer la conexión.", + "sitesBannerButtonText": "Instalar sitio", + "approvalsBannerTitle": "Aprobar o denegar el acceso al dispositivo", + "approvalsBannerDescription": "Revisar y aprobar o denegar las solicitudes de acceso al dispositivo de los usuarios. Cuando se requieren aprobaciones de dispositivos, los usuarios deben obtener la aprobación del administrador antes de que sus dispositivos puedan conectarse a los recursos de su organización.", + "approvalsBannerButtonText": "Saber más", "siteCreate": "Crear sitio", "siteCreateDescription2": "Siga los pasos siguientes para crear y conectar un nuevo sitio", "siteCreateDescription": "Crear un nuevo sitio para empezar a conectar recursos", @@ -100,6 +110,7 @@ "siteTunnelDescription": "Determina cómo quieres conectarte al sitio", "siteNewtCredentials": "Credenciales", "siteNewtCredentialsDescription": "Así es como el sitio se autentificará con el servidor", + "remoteNodeCredentialsDescription": "Así es como el nodo remoto se autentificará con el servidor", "siteCredentialsSave": "Guardar las credenciales", "siteCredentialsSaveDescription": "Sólo podrás verlo una vez. Asegúrate de copiarlo a un lugar seguro.", "siteInfo": "Información del sitio", @@ -146,8 +157,12 @@ "shareErrorSelectResource": "Por favor, seleccione un recurso", "proxyResourceTitle": "Administrar recursos públicos", "proxyResourceDescription": "Crear y administrar recursos que sean accesibles públicamente a través de un navegador web", + "proxyResourcesBannerTitle": "Acceso público basado en web", + "proxyResourcesBannerDescription": "Los recursos públicos son proxies HTTPS o TCP/UDP accesibles a cualquiera en Internet a través de un navegador web. A diferencia de los recursos privados, no requieren software del lado del cliente e incluye políticas de acceso basadas en identidad y contexto.", "clientResourceTitle": "Administrar recursos privados", "clientResourceDescription": "Crear y administrar recursos que sólo son accesibles a través de un cliente conectado", + "privateResourcesBannerTitle": "Acceso privado de confianza cero", + "privateResourcesBannerDescription": "Los recursos privados usan seguridad de confianza cero, asegurando que usuarios y máquinas solo puedan acceder a los recursos que usted conceda explícitamente. Conecte dispositivos de usuario o clientes de máquinas para acceder a estos recursos a través de una red privada virtual segura.", "resourcesSearch": "Buscar recursos...", "resourceAdd": "Añadir Recurso", "resourceErrorDelte": "Error al eliminar el recurso", @@ -157,9 +172,10 @@ "resourceMessageRemove": "Una vez eliminado, el recurso ya no será accesible. Todos los objetivos asociados con el recurso también serán eliminados.", "resourceQuestionRemove": "¿Está seguro que desea eliminar el recurso de la organización?", "resourceHTTP": "HTTPS Recurso", - "resourceHTTPDescription": "Solicitudes de proxy a la aplicación sobre HTTPS usando un subdominio o dominio base.", + "resourceHTTPDescription": "Proxy proporciona solicitudes sobre HTTPS usando un nombre de dominio completamente calificado.", "resourceRaw": "Recurso TCP/UDP sin procesar", - "resourceRawDescription": "Solicitudes de proxy a la aplicación a través de TCP/UDP usando un número de puerto. Esto solo funciona cuando los sitios están conectados a nodos.", + "resourceRawDescription": "Proxy proporciona solicitudes sobre TCP/UDP usando un número de puerto.", + "resourceRawDescriptionCloud": "Las peticiones de proxy sobre TCP/UDP crudas usando un número de puerto. Requiere que los sitios se conecten a un nodo remoto.", "resourceCreate": "Crear Recurso", "resourceCreateDescription": "Siga los siguientes pasos para crear un nuevo recurso", "resourceSeeAll": "Ver todos los recursos", @@ -186,6 +202,7 @@ "protocolSelect": "Seleccionar un protocolo", "resourcePortNumber": "Número de puerto", "resourcePortNumberDescription": "El número de puerto externo a las solicitudes de proxy.", + "back": "Atrás", "cancel": "Cancelar", "resourceConfig": "Fragmentos de configuración", "resourceConfigDescription": "Copia y pega estos fragmentos de configuración para configurar el recurso TCP/UDP", @@ -231,6 +248,17 @@ "orgErrorDeleteMessage": "Se ha producido un error al eliminar la organización.", "orgDeleted": "Organización eliminada", "orgDeletedMessage": "La organización y sus datos han sido eliminados.", + "deleteAccount": "Eliminar cuenta", + "deleteAccountDescription": "Elimina permanentemente tu cuenta, todas las organizaciones que posees y todos los datos dentro de esas organizaciones. Esto no se puede deshacer.", + "deleteAccountButton": "Eliminar cuenta", + "deleteAccountConfirmTitle": "Eliminar cuenta", + "deleteAccountConfirmMessage": "Esto borrará permanentemente tu cuenta, todas las organizaciones que posees y todos los datos dentro de esas organizaciones. Esto no se puede deshacer.", + "deleteAccountConfirmString": "eliminar cuenta", + "deleteAccountSuccess": "Cuenta eliminada", + "deleteAccountSuccessMessage": "Tu cuenta ha sido eliminada.", + "deleteAccountError": "Error al eliminar la cuenta", + "deleteAccountPreviewAccount": "Tu cuenta", + "deleteAccountPreviewOrgs": "Organizaciones que tienes (y todos sus datos)", "orgMissing": "Falta el ID de la organización", "orgMissingMessage": "No se puede regenerar la invitación sin el ID de la organización.", "accessUsersManage": "Administrar usuarios", @@ -247,6 +275,8 @@ "accessRolesSearch": "Buscar roles...", "accessRolesAdd": "Añadir rol", "accessRoleDelete": "Eliminar rol", + "accessApprovalsManage": "Administrar aprobaciones", + "accessApprovalsDescription": "Ver y administrar aprobaciones pendientes para el acceso a esta organización", "description": "Descripción", "inviteTitle": "Invitaciones abiertas", "inviteDescription": "Administrar invitaciones para que otros usuarios se unan a la organización", @@ -440,6 +470,20 @@ "selectDuration": "Seleccionar duración", "selectResource": "Seleccionar Recurso", "filterByResource": "Filtrar por Recurso", + "selectApprovalState": "Seleccionar Estado de Aprobación", + "filterByApprovalState": "Filtrar por estado de aprobación", + "approvalListEmpty": "No hay aprobaciones", + "approvalState": "Estado de aprobación", + "approvalLoadMore": "Cargar más", + "loadingApprovals": "Cargando aprobaciones", + "approve": "Aprobar", + "approved": "Aprobado", + "denied": "Denegado", + "deniedApproval": "Aprobación denegada", + "all": "Todo", + "deny": "Denegar", + "viewDetails": "Ver detalles", + "requestingNewDeviceApproval": "solicitó un nuevo dispositivo", "resetFilters": "Reiniciar filtros", "totalBlocked": "Solicitudes bloqueadas por Pangolin", "totalRequests": "Solicitudes totales", @@ -607,6 +651,7 @@ "resourcesErrorUpdate": "Error al cambiar el recurso", "resourcesErrorUpdateDescription": "Se ha producido un error al actualizar el recurso", "access": "Acceder", + "accessControl": "Control de acceso", "shareLink": "{resource} Compartir Enlace", "resourceSelect": "Seleccionar recurso", "shareLinks": "Compartir enlaces", @@ -687,7 +732,7 @@ "resourceRoleDescription": "Los administradores siempre pueden acceder a este recurso.", "resourceUsersRoles": "Controles de acceso", "resourceUsersRolesDescription": "Configurar qué usuarios y roles pueden visitar este recurso", - "resourceUsersRolesSubmit": "Guardar usuarios y roles", + "resourceUsersRolesSubmit": "Guardar controles de acceso", "resourceWhitelistSave": "Guardado correctamente", "resourceWhitelistSaveDescription": "Se han guardado los ajustes de la lista blanca", "ssoUse": "Usar Plataforma SSO", @@ -719,22 +764,35 @@ "countries": "Países", "accessRoleCreate": "Crear rol", "accessRoleCreateDescription": "Crear un nuevo rol para agrupar usuarios y administrar sus permisos.", + "accessRoleEdit": "Editar rol", + "accessRoleEditDescription": "Editar información de rol.", "accessRoleCreateSubmit": "Crear rol", "accessRoleCreated": "Rol creado", "accessRoleCreatedDescription": "El rol se ha creado correctamente.", "accessRoleErrorCreate": "Error al crear el rol", "accessRoleErrorCreateDescription": "Se ha producido un error al crear el rol.", + "accessRoleUpdateSubmit": "Actualizar rol", + "accessRoleUpdated": "Rol actualizado", + "accessRoleUpdatedDescription": "El rol se ha actualizado correctamente.", + "accessApprovalUpdated": "Aprobación procesada", + "accessApprovalApprovedDescription": "Establezca la decisión de Solicitud de Aprobación a aprobar.", + "accessApprovalDeniedDescription": "Define la decisión de Solicitud de Aprobación a denegar.", + "accessRoleErrorUpdate": "Error al actualizar el rol", + "accessRoleErrorUpdateDescription": "Se ha producido un error al actualizar el rol.", + "accessApprovalErrorUpdate": "Error al procesar la aprobación", + "accessApprovalErrorUpdateDescription": "Se ha producido un error al procesar la aprobación.", "accessRoleErrorNewRequired": "Se requiere un nuevo rol", "accessRoleErrorRemove": "Error al eliminar el rol", "accessRoleErrorRemoveDescription": "Ocurrió un error mientras se eliminaba el rol.", "accessRoleName": "Nombre del Rol", - "accessRoleQuestionRemove": "Estás a punto de eliminar el rol {name} . No puedes deshacer esta acción.", + "accessRoleQuestionRemove": "Estás a punto de eliminar el rol `{name}`. No puedes deshacer esta acción.", "accessRoleRemove": "Quitar rol", "accessRoleRemoveDescription": "Eliminar un rol de la organización", "accessRoleRemoveSubmit": "Quitar rol", "accessRoleRemoved": "Rol eliminado", "accessRoleRemovedDescription": "El rol se ha eliminado correctamente.", "accessRoleRequiredRemove": "Antes de eliminar este rol, seleccione un nuevo rol al que transferir miembros existentes.", + "network": "Red", "manage": "Gestionar", "sitesNotFound": "Sitios no encontrados.", "pangolinServerAdmin": "Admin Servidor - Pangolin", @@ -750,6 +808,9 @@ "sitestCountIncrease": "Aumentar el número de sitios", "idpManage": "Administrar proveedores de identidad", "idpManageDescription": "Ver y administrar proveedores de identidad en el sistema", + "idpGlobalModeBanner": "Los proveedores de identidad (IdPs) por organización están deshabilitados en este servidor. Está utilizando IdPs globales (compartidos entre todas las organizaciones). Administra los IdPs globales en el panel de administración. Para habilitar los IdPs por organización, edita la configuración del servidor y establece el modo de IdP en org. Consulta la documentación. Si deseas seguir utilizando IdPs globales y hacer que esto desaparezca de las configuraciones de la organización, establece explícitamente el modo en global en la configuración.", + "idpGlobalModeBannerUpgradeRequired": "Los proveedores de identidad (IdPs) por organización están deshabilitados en este servidor. Está utilizando IdPs globales (compartidos entre todas las organizaciones). Administra los IdPs globales en el panel de administración. Para usar proveedores de identidad por organización, debes actualizar a la edición Empresarial.", + "idpGlobalModeBannerLicenseRequired": "Los proveedores de identidad (IdPs) por organización están deshabilitados en este servidor. Está utilizando identificadores globales (compartidos en todas las organizaciones). Gestionar identificaciones globales en el panel de administración. Para utilizar proveedores de identidad por organización, se requiere una licencia de empresa.", "idpDeletedDescription": "Proveedor de identidad eliminado correctamente", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "¿Está seguro que desea eliminar permanentemente el proveedor de identidad?", @@ -840,6 +901,7 @@ "orgPolicyConfig": "Configurar acceso para una organización", "idpUpdatedDescription": "Proveedor de identidad actualizado correctamente", "redirectUrl": "URL de redirección", + "orgIdpRedirectUrls": "Redirigir URL", "redirectUrlAbout": "Acerca de la URL de redirección", "redirectUrlAboutDescription": "Esta es la URL a la que los usuarios serán redireccionados después de la autenticación. Necesitas configurar esta URL en la configuración del proveedor de identidad.", "pangolinAuth": "Autenticación - Pangolin", @@ -945,11 +1007,11 @@ "pincodeAuth": "Código de autenticación", "pincodeSubmit2": "Enviar código", "passwordResetSubmit": "Reiniciar Solicitud", - "passwordResetAlreadyHaveCode": "Introduzca el código de restablecimiento de contraseña", + "passwordResetAlreadyHaveCode": "Ingresar código", "passwordResetSmtpRequired": "Póngase en contacto con su administrador", "passwordResetSmtpRequiredDescription": "Se requiere un código de restablecimiento de contraseña para restablecer su contraseña. Póngase en contacto con su administrador para obtener asistencia.", "passwordBack": "Volver a la contraseña", - "loginBack": "Volver a iniciar sesión", + "loginBack": "Volver a la página principal de acceso", "signup": "Regístrate", "loginStart": "Inicia sesión para empezar", "idpOidcTokenValidating": "Validando token OIDC", @@ -972,12 +1034,12 @@ "pangolinSetup": "Configuración - Pangolin", "orgNameRequired": "El nombre de la organización es obligatorio", "orgIdRequired": "El ID de la organización es obligatorio", + "orgIdMaxLength": "El ID de la organización debe tener como máximo 32 caracteres", "orgErrorCreate": "Se ha producido un error al crear el org", "pageNotFound": "Página no encontrada", "pageNotFoundDescription": "¡Vaya! La página que estás buscando no existe.", "overview": "Resumen", "home": "Inicio", - "accessControl": "Control de acceso", "settings": "Ajustes", "usersAll": "Todos los usuarios", "license": "Licencia", @@ -1035,15 +1097,24 @@ "updateOrgUser": "Actualizar usuario Org", "createOrgUser": "Crear usuario Org", "actionUpdateOrg": "Actualizar organización", + "actionRemoveInvitation": "Eliminar invitación", "actionUpdateUser": "Actualizar usuario", "actionGetUser": "Obtener usuario", "actionGetOrgUser": "Obtener usuario de la organización", "actionListOrgDomains": "Listar dominios de la organización", + "actionGetDomain": "Obtener dominio", + "actionCreateOrgDomain": "Crear dominio", + "actionUpdateOrgDomain": "Actualizar dominio", + "actionDeleteOrgDomain": "Eliminar dominio", + "actionGetDNSRecords": "Obtener registros DNS", + "actionRestartOrgDomain": "Reiniciar dominio", "actionCreateSite": "Crear sitio", "actionDeleteSite": "Eliminar sitio", "actionGetSite": "Obtener sitio", "actionListSites": "Listar sitios", "actionApplyBlueprint": "Aplicar plano", + "actionListBlueprints": "Listar blueprints", + "actionGetBlueprint": "Obtener blueprint", "setupToken": "Configuración de token", "setupTokenDescription": "Ingrese el token de configuración desde la consola del servidor.", "setupTokenRequired": "Se requiere el token de configuración", @@ -1077,6 +1148,7 @@ "actionRemoveUser": "Eliminar usuario", "actionListUsers": "Listar usuarios", "actionAddUserRole": "Añadir rol de usuario", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Generar token de acceso", "actionDeleteAccessToken": "Eliminar token de acceso", "actionListAccessTokens": "Lista de Tokens de Acceso", @@ -1104,6 +1176,10 @@ "actionUpdateIdpOrg": "Actualizar IDP Org", "actionCreateClient": "Crear cliente", "actionDeleteClient": "Eliminar cliente", + "actionArchiveClient": "Archivar cliente", + "actionUnarchiveClient": "Desarchivar cliente", + "actionBlockClient": "Bloquear cliente", + "actionUnblockClient": "Desbloquear cliente", "actionUpdateClient": "Actualizar cliente", "actionListClients": "Listar clientes", "actionGetClient": "Obtener cliente", @@ -1117,17 +1193,18 @@ "actionViewLogs": "Ver registros", "noneSelected": "Ninguno seleccionado", "orgNotFound2": "No se encontraron organizaciones.", - "searchProgress": "Buscar...", + "searchPlaceholder": "Buscar...", + "emptySearchOptions": "No se encontraron opciones", "create": "Crear", "orgs": "Organizaciones", - "loginError": "Se ha producido un error al iniciar sesión", - "loginRequiredForDevice": "Es necesario iniciar sesión para autenticar tu dispositivo.", + "loginError": "Ocurrió un error inesperado. Por favor, inténtelo de nuevo.", + "loginRequiredForDevice": "Es necesario iniciar sesión para tu dispositivo.", "passwordForgot": "¿Olvidaste tu contraseña?", "otpAuth": "Autenticación de dos factores", "otpAuthDescription": "Introduzca el código de su aplicación de autenticación o uno de sus códigos de copia de seguridad de un solo uso.", "otpAuthSubmit": "Enviar código", "idpContinue": "O continuar con", - "otpAuthBack": "Volver a iniciar sesión", + "otpAuthBack": "Volver a la contraseña", "navbar": "Menú de navegación", "navbarDescription": "Menú de navegación principal para la aplicación", "navbarDocsLink": "Documentación", @@ -1175,11 +1252,13 @@ "sidebarOverview": "Resumen", "sidebarHome": "Inicio", "sidebarSites": "Sitios", + "sidebarApprovals": "Solicitudes de aprobación", "sidebarResources": "Recursos", "sidebarProxyResources": "Público", "sidebarClientResources": "Privado", "sidebarAccessControl": "Control de acceso", "sidebarLogsAndAnalytics": "Registros y análisis", + "sidebarTeam": "Equipo", "sidebarUsers": "Usuarios", "sidebarAdmin": "Admin", "sidebarInvitations": "Invitaciones", @@ -1191,13 +1270,15 @@ "sidebarIdentityProviders": "Proveedores de identidad", "sidebarLicense": "Licencia", "sidebarClients": "Clientes", - "sidebarUserDevices": "Usuarios", + "sidebarUserDevices": "Dispositivos de usuario", "sidebarMachineClients": "Máquinas", "sidebarDomains": "Dominios", - "sidebarGeneral": "General", + "sidebarGeneral": "Gestionar", "sidebarLogAndAnalytics": "Registro y análisis", "sidebarBluePrints": "Planos", "sidebarOrganization": "Organización", + "sidebarManagement": "Gestión", + "sidebarBillingAndLicenses": "Facturación y licencias", "sidebarLogsAnalytics": "Analíticas", "blueprints": "Planos", "blueprintsDescription": "Aplicar configuraciones declarativas y ver ejecuciones anteriores", @@ -1219,7 +1300,6 @@ "parsedContents": "Contenido analizado (Sólo lectura)", "enableDockerSocket": "Habilitar Plano Docker", "enableDockerSocketDescription": "Activar el raspado de etiquetas de Socket Docker para etiquetas de planos. La ruta del Socket debe proporcionarse a Newt.", - "enableDockerSocketLink": "Saber más", "viewDockerContainers": "Ver contenedores Docker", "containersIn": "Contenedores en {siteName}", "selectContainerDescription": "Seleccione cualquier contenedor para usar como nombre de host para este objetivo. Haga clic en un puerto para usar un puerto.", @@ -1263,6 +1343,7 @@ "setupErrorCreateAdmin": "Se produjo un error al crear la cuenta de administrador del servidor.", "certificateStatus": "Estado del certificado", "loading": "Cargando", + "loadingAnalytics": "Cargando analíticas", "restart": "Reiniciar", "domains": "Dominios", "domainsDescription": "Crear y administrar dominios disponibles en la organización", @@ -1290,6 +1371,7 @@ "refreshError": "Error al actualizar datos", "verified": "Verificado", "pending": "Pendiente", + "pendingApproval": "Pendientes de aprobación", "sidebarBilling": "Facturación", "billing": "Facturación", "orgBillingDescription": "Administrar información de facturación y suscripciones", @@ -1308,8 +1390,11 @@ "accountSetupSuccess": "¡Configuración de cuenta completada! ¡Bienvenido a Pangolin!", "documentation": "Documentación", "saveAllSettings": "Guardar todos los ajustes", + "saveResourceTargets": "Guardar objetivos", + "saveResourceHttp": "Guardar ajustes de proxy", + "saveProxyProtocol": "Guardar configuraciones del protocolo de proxy", "settingsUpdated": "Ajustes actualizados", - "settingsUpdatedDescription": "Todos los ajustes han sido actualizados exitosamente", + "settingsUpdatedDescription": "Configuraciones actualizadas correctamente", "settingsErrorUpdate": "Error al actualizar ajustes", "settingsErrorUpdateDescription": "Ocurrió un error al actualizar ajustes", "sidebarCollapse": "Colapsar", @@ -1342,6 +1427,7 @@ "domainPickerNamespace": "Espacio de nombres: {namespace}", "domainPickerShowMore": "Mostrar más", "regionSelectorTitle": "Seleccionar Región", + "domainPickerRemoteExitNodeWarning": "Los dominios suministrados no son compatibles cuando los sitios se conectan a nodos de salida remotos. Para que los recursos estén disponibles en nodos remotos, utilice un dominio personalizado en su lugar.", "regionSelectorInfo": "Seleccionar una región nos ayuda a brindar un mejor rendimiento para tu ubicación. No tienes que estar en la misma región que tu servidor.", "regionSelectorPlaceholder": "Elige una región", "regionSelectorComingSoon": "Próximamente", @@ -1351,10 +1437,11 @@ "billingUsageLimitsOverview": "Descripción general de los límites de uso", "billingMonitorUsage": "Monitorea tu uso comparado con los límites configurados. Si necesitas que aumenten los límites, contáctanos a soporte@pangolin.net.", "billingDataUsage": "Uso de datos", - "billingOnlineTime": "Tiempo en línea del sitio", - "billingUsers": "Usuarios activos", - "billingDomains": "Dominios activos", - "billingRemoteExitNodes": "Nodos autogestionados activos", + "billingSites": "Sitios", + "billingUsers": "Usuarios", + "billingDomains": "Dominios", + "billingOrganizations": "Orgánico", + "billingRemoteExitNodes": "Nodos remotos", "billingNoLimitConfigured": "No se ha configurado ningún límite", "billingEstimatedPeriod": "Período de facturación estimado", "billingIncludedUsage": "Uso incluido", @@ -1379,15 +1466,24 @@ "billingFailedToGetPortalUrl": "Error al obtener la URL del portal", "billingPortalError": "Error del portal", "billingDataUsageInfo": "Se le cobran todos los datos transferidos a través de sus túneles seguros cuando se conectan a la nube. Esto incluye tanto tráfico entrante como saliente a través de todos sus sitios. Cuando alcance su límite, sus sitios se desconectarán hasta que actualice su plan o reduzca el uso. Los datos no se cargan cuando se usan nodos.", - "billingOnlineTimeInfo": "Se te cobrará en función del tiempo que tus sitios permanezcan conectados a la nube. Por ejemplo, 44.640 minutos equivale a un sitio que funciona 24/7 durante un mes completo. Cuando alcance su límite, sus sitios se desconectarán hasta que mejore su plan o reduzca el uso. No se cargará el tiempo al usar nodos.", - "billingUsersInfo": "Se le cobra por cada usuario en la organización. La facturación se calcula diariamente según el número de cuentas de usuario activas en su órgano.", - "billingDomainInfo": "Se le cobra por cada dominio en la organización. La facturación se calcula diariamente en función del número de cuentas de dominio activas en su órgano.", - "billingRemoteExitNodesInfo": "Se le cobra por cada nodo administrado en la organización. La facturación se calcula diariamente en función del número de nodos activos gestionados en su órgano.", + "billingSInfo": "Cuántos sitios puedes usar", + "billingUsersInfo": "Cuántos usuarios puedes usar", + "billingDomainInfo": "Cuántos dominios puedes usar", + "billingRemoteExitNodesInfo": "Cuántos nodos remotos puedes usar", + "billingLicenseKeys": "Claves de licencia", + "billingLicenseKeysDescription": "Administrar las suscripciones de su clave de licencia", + "billingLicenseSubscription": "Suscripción de licencia", + "billingInactive": "Inactivo", + "billingLicenseItem": "Licencia", + "billingQuantity": "Cantidad", + "billingTotal": "total", + "billingModifyLicenses": "Modificar suscripción de licencia", "domainNotFound": "Dominio no encontrado", "domainNotFoundDescription": "Este recurso está deshabilitado porque el dominio ya no existe en nuestro sistema. Por favor, establece un nuevo dominio para este recurso.", "failed": "Fallido", "createNewOrgDescription": "Crear una nueva organización", "organization": "Organización", + "primary": "Principal", "port": "Puerto", "securityKeyManage": "Gestionar llaves de seguridad", "securityKeyDescription": "Agregar o eliminar llaves de seguridad para autenticación sin contraseña", @@ -1403,7 +1499,7 @@ "securityKeyRemoveSuccess": "Llave de seguridad eliminada exitosamente", "securityKeyRemoveError": "Error al eliminar la llave de seguridad", "securityKeyLoadError": "Error al cargar las llaves de seguridad", - "securityKeyLogin": "Continuar con clave de seguridad", + "securityKeyLogin": "Usar clave de seguridad", "securityKeyAuthError": "Error al autenticar con llave de seguridad", "securityKeyRecommendation": "Considere registrar otra llave de seguridad en un dispositivo diferente para asegurarse de no quedar bloqueado de su cuenta.", "registering": "Registrando...", @@ -1459,11 +1555,47 @@ "resourcePortRequired": "Se requiere número de puerto para recursos no HTTP", "resourcePortNotAllowed": "El número de puerto no debe establecerse para recursos HTTP", "billingPricingCalculatorLink": "Calculadora de Precios", + "billingYourPlan": "Su plan", + "billingViewOrModifyPlan": "Ver o modificar su plan actual", + "billingViewPlanDetails": "Ver detalles del plan", + "billingUsageAndLimits": "Uso y límites", + "billingViewUsageAndLimits": "Ver los límites de tu plan y el uso actual", + "billingCurrentUsage": "Uso actual", + "billingMaximumLimits": "Límites máximos", + "billingRemoteNodes": "Nodos remotos", + "billingUnlimited": "Ilimitado", + "billingPaidLicenseKeys": "Claves de licencia pagadas", + "billingManageLicenseSubscription": "Administra tu suscripción para las claves de licencia autoalojadas pagadas", + "billingCurrentKeys": "Claves actuales", + "billingModifyCurrentPlan": "Modificar plan actual", + "billingConfirmUpgrade": "Confirmar actualización", + "billingConfirmDowngrade": "Confirmar descenso", + "billingConfirmUpgradeDescription": "Estás a punto de actualizar tu plan. Revisa los nuevos límites y precios a continuación.", + "billingConfirmDowngradeDescription": "Está a punto de rebajar su plan. Revise los nuevos límites y los precios a continuación.", + "billingPlanIncludes": "Plan Incluye", + "billingProcessing": "Procesando...", + "billingConfirmUpgradeButton": "Confirmar actualización", + "billingConfirmDowngradeButton": "Confirmar descenso", + "billingLimitViolationWarning": "El uso excede los nuevos límites del plan", + "billingLimitViolationDescription": "Su uso actual excede los límites de este plan. Después de degradar, todas las acciones se desactivarán hasta que reduzca el uso dentro de los nuevos límites. Por favor, revisa las siguientes características que están actualmente por encima de los límites. Límites en violación:", + "billingFeatureLossWarning": "Aviso de disponibilidad de funcionalidad", + "billingFeatureLossDescription": "Al degradar, las características no disponibles en el nuevo plan se desactivarán automáticamente. Algunas configuraciones y configuraciones pueden perderse. Por favor, revise la matriz de precios para entender qué características ya no estarán disponibles.", + "billingUsageExceedsLimit": "El uso actual ({current}) supera el límite ({limit})", + "billingPastDueTitle": "Pago vencido", + "billingPastDueDescription": "Su pago ha vencido. Por favor, actualice su método de pago para seguir utilizando las características actuales de su plan. Si no se resuelve, tu suscripción se cancelará y serás revertido al nivel gratuito.", + "billingUnpaidTitle": "Suscripción no pagada", + "billingUnpaidDescription": "Tu suscripción no está pagada y has sido revertido al nivel gratuito. Por favor, actualiza tu método de pago para restaurar tu suscripción.", + "billingIncompleteTitle": "Pago incompleto", + "billingIncompleteDescription": "Su pago está incompleto. Por favor, complete el proceso de pago para activar su suscripción.", + "billingIncompleteExpiredTitle": "Pago expirado", + "billingIncompleteExpiredDescription": "Tu pago nunca se completó y ha expirado. Has sido revertido al nivel gratuito. Suscríbete de nuevo para restaurar el acceso a las funciones de pago.", + "billingManageSubscription": "Administra tu suscripción", + "billingResolvePaymentIssue": "Por favor resuelva su problema de pago antes de actualizar o bajar de calificación", "signUpTerms": { "IAgreeToThe": "Estoy de acuerdo con los", "termsOfService": "términos del servicio", "and": "y", - "privacyPolicy": "política de privacidad" + "privacyPolicy": "política de privacidad." }, "signUpMarketing": { "keepMeInTheLoop": "Mantenerme en el bucle con noticias, actualizaciones y nuevas características por correo electrónico." @@ -1508,6 +1640,7 @@ "addNewTarget": "Agregar nuevo destino", "targetsList": "Lista de destinos", "advancedMode": "Modo avanzado", + "advancedSettings": "Configuración avanzada", "targetErrorDuplicateTargetFound": "Se encontró un destino duplicado", "healthCheckHealthy": "Saludable", "healthCheckUnhealthy": "No saludable", @@ -1529,6 +1662,26 @@ "IntervalSeconds": "Intervalo Saludable", "timeoutSeconds": "Tiempo agotado (seg)", "timeIsInSeconds": "El tiempo está en segundos", + "requireDeviceApproval": "Requiere aprobaciones del dispositivo", + "requireDeviceApprovalDescription": "Los usuarios con este rol necesitan nuevos dispositivos aprobados por un administrador antes de poder conectarse y acceder a los recursos.", + "sshAccess": "Acceso a SSH", + "roleAllowSsh": "Permitir SSH", + "roleAllowSshAllow": "Permitir", + "roleAllowSshDisallow": "Rechazar", + "roleAllowSshDescription": "Permitir a los usuarios con este rol conectarse a recursos a través de SSH. Cuando está desactivado, el rol no puede usar acceso SSH.", + "sshSudoMode": "Acceso Sudo", + "sshSudoModeNone": "Ninguna", + "sshSudoModeNoneDescription": "El usuario no puede ejecutar comandos con sudo.", + "sshSudoModeFull": "Sudo completo", + "sshSudoModeFullDescription": "El usuario puede ejecutar cualquier comando con sudo.", + "sshSudoModeCommands": "Comandos", + "sshSudoModeCommandsDescription": "El usuario sólo puede ejecutar los comandos especificados con sudo.", + "sshSudo": "Permitir sudo", + "sshSudoCommands": "Comandos Sudo", + "sshSudoCommandsDescription": "Lista separada por comas de comandos que el usuario puede ejecutar con sudo.", + "sshCreateHomeDir": "Crear directorio principal", + "sshUnixGroups": "Grupos Unix", + "sshUnixGroupsDescription": "Grupos Unix separados por comas para agregar el usuario en el host de destino.", "retryAttempts": "Intentos de Reintento", "expectedResponseCodes": "Códigos de respuesta esperados", "expectedResponseCodesDescription": "Código de estado HTTP que indica un estado saludable. Si se deja en blanco, se considera saludable de 200 a 300.", @@ -1569,6 +1722,8 @@ "resourcesTableNoInternalResourcesFound": "No se encontraron recursos internos.", "resourcesTableDestination": "Destino", "resourcesTableAlias": "Alias", + "resourcesTableAliasAddress": "Dirección del alias", + "resourcesTableAliasAddressInfo": "Esta dirección es parte de la subred de utilidad de la organización. Se utiliza para resolver registros de alias usando resolución DNS interna.", "resourcesTableClients": "Clientes", "resourcesTableAndOnlyAccessibleInternally": "y solo son accesibles internamente cuando se conectan con un cliente.", "resourcesTableNoTargets": "Sin objetivos", @@ -1616,9 +1771,8 @@ "createInternalResourceDialogResourceProperties": "Propiedades del recurso", "createInternalResourceDialogName": "Nombre", "createInternalResourceDialogSite": "Sitio", - "createInternalResourceDialogSelectSite": "Seleccionar sitio...", - "createInternalResourceDialogSearchSites": "Buscar sitios...", - "createInternalResourceDialogNoSitesFound": "Sitios no encontrados.", + "selectSite": "Seleccionar sitio...", + "noSitesFound": "Sitios no encontrados.", "createInternalResourceDialogProtocol": "Protocolo", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", @@ -1658,7 +1812,7 @@ "siteAddressDescription": "La dirección interna del sitio. Debe estar dentro de la subred de la organización.", "siteNameDescription": "El nombre mostrado del sitio que se puede cambiar más adelante.", "autoLoginExternalIdp": "Inicio de sesión automático con IDP externo", - "autoLoginExternalIdpDescription": "Redirigir inmediatamente al usuario al IDP externo para autenticación.", + "autoLoginExternalIdpDescription": "Redirigir inmediatamente al usuario al proveedor de identidad externo para autenticación.", "selectIdp": "Seleccionar IDP", "selectIdpPlaceholder": "Elegir un IDP...", "selectIdpRequired": "Por favor seleccione un IDP cuando el inicio de sesión automático esté habilitado.", @@ -1670,7 +1824,7 @@ "autoLoginErrorNoRedirectUrl": "No se recibió URL de redirección del proveedor de identidad.", "autoLoginErrorGeneratingUrl": "Error al generar URL de autenticación.", "remoteExitNodeManageRemoteExitNodes": "Nodos remotos", - "remoteExitNodeDescription": "Autoalojar uno o más nodos remotos para extender la conectividad de red y reducir la dependencia de la nube", + "remoteExitNodeDescription": "Aloje su propio nodo de retransmisión y proxy server sin depender de terceros.", "remoteExitNodes": "Nodos", "searchRemoteExitNodes": "Buscar nodos...", "remoteExitNodeAdd": "Añadir Nodo", @@ -1680,20 +1834,22 @@ "remoteExitNodeConfirmDelete": "Confirmar eliminar nodo", "remoteExitNodeDelete": "Eliminar Nodo", "sidebarRemoteExitNodes": "Nodos remotos", + "remoteExitNodeId": "ID", + "remoteExitNodeSecretKey": "Secreto", "remoteExitNodeCreate": { - "title": "Crear Nodo", - "description": "Crear un nuevo nodo para extender la conectividad de red", + "title": "Crear nodo remoto", + "description": "Crea un nuevo nodo de retransmisión y proxy server autogestionado", "viewAllButton": "Ver todos los nodos", "strategy": { "title": "Estrategia de Creación", - "description": "Elija esto para configurar manualmente el nodo o generar nuevas credenciales.", + "description": "Selecciona cómo quieres crear el nodo remoto", "adopt": { "title": "Adoptar Nodo", "description": "Elija esto si ya tiene las credenciales para el nodo." }, "generate": { "title": "Generar Claves", - "description": "Elija esto si desea generar nuevas claves para el nodo" + "description": "Elija esto si desea generar nuevas claves para el nodo." } }, "adopt": { @@ -1806,9 +1962,33 @@ "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subred", "subnetDescription": "La subred para la configuración de red de esta organización.", - "authPage": "Página Auth", - "authPageDescription": "Configurar la página de autenticación para la organización", + "customDomain": "Dominio personalizado", + "authPage": "Páginas de autenticación", + "authPageDescription": "Establecer un dominio personalizado para las páginas de autenticación de la organización", "authPageDomain": "Dominio de la página Auth", + "authPageBranding": "Marca personalizada", + "authPageBrandingDescription": "Configure la marca que aparece en las páginas de autenticación de esta organización", + "authPageBrandingUpdated": "Marca de la página de autenticación actualizada correctamente", + "authPageBrandingRemoved": "Marca de la página de autenticación eliminada correctamente", + "authPageBrandingRemoveTitle": "Eliminar marca de la página de autenticación", + "authPageBrandingQuestionRemove": "¿Está seguro de que desea eliminar la marca de las páginas de autenticación?", + "authPageBrandingDeleteConfirm": "Confirmar eliminación de la marca", + "brandingLogoURL": "URL del logotipo", + "brandingLogoURLOrPath": "URL o ruta de Logo", + "brandingLogoPathDescription": "Introduzca una URL o una ruta local.", + "brandingLogoURLDescription": "Introduzca una URL de acceso público a su imagen de logotipo.", + "brandingPrimaryColor": "Color primario", + "brandingLogoWidth": "Ancho (px)", + "brandingLogoHeight": "Altura (px)", + "brandingOrgTitle": "Título para la página de autenticación de la organización", + "brandingOrgDescription": "{orgName} será reemplazado por el nombre de la organización", + "brandingOrgSubtitle": "Subtítulo para la página de autenticación de la organización", + "brandingResourceTitle": "Título para la página de autenticación de recursos", + "brandingResourceSubtitle": "Subtítulo para la página de autenticación de recursos", + "brandingResourceDescription": "{resourceName} será reemplazado por el nombre de la organización", + "saveAuthPageDomain": "Guardar dominio", + "saveAuthPageBranding": "Guardar marca", + "removeAuthPageBranding": "Eliminar marca", "noDomainSet": "Ningún dominio establecido", "changeDomain": "Cambiar dominio", "selectDomain": "Seleccionar dominio", @@ -1817,7 +1997,7 @@ "setAuthPageDomain": "Establecer dominio Auth Page", "failedToFetchCertificate": "Error al obtener el certificado", "failedToRestartCertificate": "Error al reiniciar el certificado", - "addDomainToEnableCustomAuthPages": "Añadir un dominio para habilitar páginas de autenticación personalizadas para la organización", + "addDomainToEnableCustomAuthPages": "Los usuarios podrán acceder a la página de inicio de sesión de la organización y completar la autenticación de recursos utilizando este dominio.", "selectDomainForOrgAuthPage": "Seleccione un dominio para la página de autenticación de la organización", "domainPickerProvidedDomain": "Dominio proporcionado", "domainPickerFreeProvidedDomain": "Dominio proporcionado gratis", @@ -1832,11 +2012,27 @@ "domainPickerInvalidSubdomainCannotMakeValid": "No se ha podido hacer válido \"{sub}\" para {domain}.", "domainPickerSubdomainSanitized": "Subdominio saneado", "domainPickerSubdomainCorrected": "\"{sub}\" fue corregido a \"{sanitized}\"", - "orgAuthSignInTitle": "Iniciar sesión en la organización", + "orgAuthSignInTitle": "Inicio de sesión de organización", "orgAuthChooseIdpDescription": "Elige tu proveedor de identidad para continuar", "orgAuthNoIdpConfigured": "Esta organización no tiene ningún proveedor de identidad configurado. En su lugar puedes iniciar sesión con tu identidad de Pangolin.", "orgAuthSignInWithPangolin": "Iniciar sesión con Pangolin", + "orgAuthSignInToOrg": "Iniciar sesión en una organización", + "orgAuthSelectOrgTitle": "Inicio de sesión de organización", + "orgAuthSelectOrgDescription": "Ingrese el ID de su organización para continuar", + "orgAuthOrgIdPlaceholder": "tu-organización", + "orgAuthOrgIdHelp": "Ingrese el identificador único de su organización", + "orgAuthSelectOrgHelp": "Después de ingresar el ID de su organización, se le llevará a la página de inicio de sesión de su organización donde podrá usar SSO o sus credenciales de organización.", + "orgAuthRememberOrgId": "Recordar este ID de organización", + "orgAuthBackToSignIn": "Volver a iniciar sesión estándar", + "orgAuthNoAccount": "¿No tienes una cuenta?", "subscriptionRequiredToUse": "Se requiere una suscripción para utilizar esta función.", + "mustUpgradeToUse": "Debes actualizar tu suscripción para usar esta función.", + "subscriptionRequiredTierToUse": "Esta función requiere {tier} o superior.", + "upgradeToTierToUse": "Actualiza a {tier} o superior para usar esta función.", + "subscriptionTierTier1": "Inicio", + "subscriptionTierTier2": "Equipo", + "subscriptionTierTier3": "Negocio", + "subscriptionTierEnterprise": "Empresa", "idpDisabled": "Los proveedores de identidad están deshabilitados.", "orgAuthPageDisabled": "La página de autenticación de la organización está deshabilitada.", "domainRestartedDescription": "Verificación de dominio reiniciada con éxito", @@ -1850,6 +2046,8 @@ "enableTwoFactorAuthentication": "Habilitar autenticación de doble factor", "completeSecuritySteps": "Pasos de seguridad completos", "securitySettings": "Ajustes de seguridad", + "dangerSection": "Zona de peligro", + "dangerSectionDescription": "Eliminar permanentemente todos los datos asociados con esta organización", "securitySettingsDescription": "Configurar políticas de seguridad para la organización", "requireTwoFactorForAllUsers": "Requiere autenticación de doble factor para todos los usuarios", "requireTwoFactorDescription": "Cuando está activado, todos los usuarios internos de esta organización deben tener habilitada la autenticación de dos factores para acceder a la organización.", @@ -1887,7 +2085,7 @@ "securityPolicyChangeWarningText": "Esto afectará a todos los usuarios de la organización", "authPageErrorUpdateMessage": "Ocurrió un error mientras se actualizaban los ajustes de la página auth", "authPageErrorUpdate": "No se puede actualizar la página de autenticación", - "authPageUpdated": "Página auth actualizada correctamente", + "authPageDomainUpdated": "Dominio de la página de autenticación actualizado correctamente", "healthCheckNotAvailable": "Local", "rewritePath": "Reescribir Ruta", "rewritePathDescription": "Opcionalmente reescribe la ruta antes de reenviar al destino.", @@ -1915,8 +2113,15 @@ "beta": "Beta", "manageUserDevices": "Dispositivos de usuario", "manageUserDevicesDescription": "Ver y administrar dispositivos que los usuarios utilizan para conectarse a recursos privados", + "downloadClientBannerTitle": "Descargar cliente Pangolin", + "downloadClientBannerDescription": "Descargue el cliente Pangolin para su sistema para conectarse a la red Pangolin y acceder a recursos de forma privada.", "manageMachineClients": "Administrar clientes de máquinas", "manageMachineClientsDescription": "Crear y administrar clientes que servidores y sistemas utilizan para conectarse de forma privada a recursos", + "machineClientsBannerTitle": "Servidores y sistemas automatizados", + "machineClientsBannerDescription": "Los clientes de máquinas son para servidores y sistemas automatizados que no están asociados con un usuario específico. Se autentican con una ID y un secreto, y pueden ejecutarse con Pangolin CLI, Olm CLI o Olm como un contenedor.", + "machineClientsBannerPangolinCLI": "CLI de Pangolin", + "machineClientsBannerOlmCLI": "CLI de Olm", + "machineClientsBannerOlmContainer": "Contenedor de Olm", "clientsTableUserClients": "Usuario", "clientsTableMachineClients": "Maquina", "licenseTableValidUntil": "Válido hasta", @@ -2015,6 +2220,32 @@ } } }, + "newPricingLicenseForm": { + "title": "Obtener una licencia", + "description": "Elige un plan y dinos cómo planeas usar Pangolin.", + "chooseTier": "Elige tu plan", + "viewPricingLink": "Ver precios, características y límites", + "tiers": { + "starter": { + "title": "Interruptor", + "description": "Características de la empresa, 25 usuarios, 25 sitios y soporte comunitario." + }, + "scale": { + "title": "Escala", + "description": "Características de la empresa, 50 usuarios, 50 sitios y soporte prioritario." + } + }, + "personalUseOnly": "Solo uso personal (licencia gratuita, sin pago)", + "buttons": { + "continueToCheckout": "Continuar con el pago" + }, + "toasts": { + "checkoutError": { + "title": "Error de pago", + "description": "No se pudo iniciar el pago. Por favor, inténtelo de nuevo." + } + } + }, "priority": "Prioridad", "priorityDescription": "Las rutas de prioridad más alta son evaluadas primero. Prioridad = 100 significa orden automático (decisiones del sistema). Utilice otro número para hacer cumplir la prioridad manual.", "instanceName": "Nombre de instancia", @@ -2060,13 +2291,15 @@ "request": "Solicitud", "requests": "Solicitudes", "logs": "Registros", - "logsSettingsDescription": "Monitorear registros recogidos de esta orginización", + "logsSettingsDescription": "Monitorear registros recolectados de esta organización", "searchLogs": "Buscar registros...", "action": "Accin", "actor": "Actor", "timestamp": "Timestamp", "accessLogs": "Registros de acceso", "exportCsv": "Exportar CSV", + "exportError": "Error desconocido al exportar CSV", + "exportCsvTooltip": "Dentro del rango de tiempo", "actorId": "ID de Actor", "allowedByRule": "Permitido por regla", "allowedNoAuth": "No se permite autorización", @@ -2111,7 +2344,8 @@ "logRetentionEndOfFollowingYear": "Fin del año siguiente", "actionLogsDescription": "Ver un historial de acciones realizadas en esta organización", "accessLogsDescription": "Ver solicitudes de acceso a los recursos de esta organización", - "licenseRequiredToUse": "Se requiere una licencia Enterprise para utilizar esta función.", + "licenseRequiredToUse": "Se requiere una licencia Enterprise Edition o Pangolin Cloud para usar esta función. Reserve una demostración o prueba POC.", + "ossEnterpriseEditionRequired": "La Enterprise Edition es necesaria para utilizar esta función. Esta función también está disponible en Pangolin Cloud. Reserva una demostración o prueba POC.", "certResolver": "Resolver certificado", "certResolverDescription": "Seleccione la resolución de certificados a utilizar para este recurso.", "selectCertResolver": "Seleccionar Resolver Certificado", @@ -2120,7 +2354,7 @@ "unverified": "Sin verificar", "domainSetting": "Ajustes de dominio", "domainSettingDescription": "Configurar ajustes para el dominio", - "preferWildcardCertDescription": "Intento de generar un certificado comodín (requiere una resolución de certificados correctamente configurada).", + "preferWildcardCertDescription": "Intentar generar un certificado comodín (requiere un resolvedor de certificados configurado correctamente).", "recordName": "Nombre del registro", "auto": "Auto", "TTL": "TTL", @@ -2172,6 +2406,8 @@ "deviceCodeInvalidFormat": "El código debe tener 9 caracteres (por ejemplo, A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Código no válido o caducado", "deviceCodeVerifyFailed": "Error al verificar el código del dispositivo", + "deviceCodeValidating": "Validando código de dispositivo...", + "deviceCodeVerifying": "Verificando autorización del dispositivo...", "signedInAs": "Conectado como", "deviceCodeEnterPrompt": "Introduzca el código mostrado en el dispositivo", "continue": "Continuar", @@ -2184,7 +2420,7 @@ "deviceOrganizationsAccess": "Acceso a todas las organizaciones a las que su cuenta tiene acceso", "deviceAuthorize": "Autorizar a {applicationName}", "deviceConnected": "¡Dispositivo conectado!", - "deviceAuthorizedMessage": "El dispositivo está autorizado para acceder a su cuenta.", + "deviceAuthorizedMessage": "El dispositivo está autorizado para acceder a su cuenta. Por favor, vuelva a la aplicación cliente.", "pangolinCloud": "Nube de Pangolin", "viewDevices": "Ver dispositivos", "viewDevicesDescription": "Administra tus dispositivos conectados", @@ -2246,6 +2482,7 @@ "identifier": "Identifier", "deviceLoginUseDifferentAccount": "¿No tú? Utilice una cuenta diferente.", "deviceLoginDeviceRequestingAccessToAccount": "Un dispositivo está solicitando acceso a esta cuenta.", + "loginSelectAuthenticationMethod": "Seleccione un método de autenticación para continuar.", "noData": "Sin datos", "machineClients": "Clientes de la máquina", "install": "Instalar", @@ -2255,6 +2492,8 @@ "setupFailedToFetchSubnet": "No se pudo obtener la subred por defecto", "setupSubnetAdvanced": "Subred (Avanzado)", "setupSubnetDescription": "La subred de la red interna de esta organización.", + "setupUtilitySubnet": "Subred de utilidad (Avanzado)", + "setupUtilitySubnetDescription": "La subred de direcciones alias y el servidor DNS de esta organización.", "siteRegenerateAndDisconnect": "Regenerar y desconectar", "siteRegenerateAndDisconnectConfirmation": "¿Está seguro que desea regenerar las credenciales y desconectar este sitio?", "siteRegenerateAndDisconnectWarning": "Esto regenerará las credenciales y desconectará inmediatamente el sitio. El sitio tendrá que reiniciarse con las nuevas credenciales.", @@ -2270,5 +2509,179 @@ "remoteExitNodeRegenerateAndDisconnectWarning": "Esto regenerará las credenciales y desconectará inmediatamente el nodo de salida remoto. El nodo de salida remoto tendrá que reiniciarse con las nuevas credenciales.", "remoteExitNodeRegenerateCredentialsConfirmation": "¿Estás seguro de que quieres regenerar las credenciales para este nodo de salida remoto?", "remoteExitNodeRegenerateCredentialsWarning": "Esto regenerará las credenciales. El nodo de salida remoto permanecerá conectado hasta que lo reinicie manualmente y utilice las nuevas credenciales.", - "agent": "Agente" + "agent": "Agente", + "personalUseOnly": "Solo para uso personal", + "loginPageLicenseWatermark": "Esta instancia está licenciada solo para uso personal.", + "instanceIsUnlicensed": "Esta instancia no tiene licencia.", + "portRestrictions": "Restricciones de puerto", + "allPorts": "Todo", + "custom": "Personalizado", + "allPortsAllowed": "Todos los puertos permitidos", + "allPortsBlocked": "Todos los puertos bloqueados", + "tcpPortsDescription": "Especifique qué puertos TCP están permitidos para este recurso. Use '*' para todos los puertos, déjelo vacío para bloquear todos, o ingrese una lista separada por comas de puertos y rangos (por ejemplo, 80,443,8000-9000).", + "udpPortsDescription": "Especifique qué puertos UDP están permitidos para este recurso. Use '*' para todos los puertos, déjelo vacío para bloquear todos, o ingrese una lista separada por comas de puertos y rangos (por ejemplo, 53,123,500-600).", + "organizationLoginPageTitle": "Página de inicio de sesión de la organización", + "organizationLoginPageDescription": "Personaliza la página de inicio de sesión para esta organización", + "resourceLoginPageTitle": "Página de inicio de sesión de recursos", + "resourceLoginPageDescription": "Personaliza la página de inicio de sesión para recursos individuales", + "enterConfirmation": "Ingresar confirmación", + "blueprintViewDetails": "Detalles", + "defaultIdentityProvider": "Proveedor de identidad predeterminado", + "defaultIdentityProviderDescription": "Cuando se selecciona un proveedor de identidad por defecto, el usuario será redirigido automáticamente al proveedor de autenticación.", + "editInternalResourceDialogNetworkSettings": "Configuración de red", + "editInternalResourceDialogAccessPolicy": "Política de acceso", + "editInternalResourceDialogAddRoles": "Agregar roles", + "editInternalResourceDialogAddUsers": "Agregar usuarios", + "editInternalResourceDialogAddClients": "Agregar clientes", + "editInternalResourceDialogDestinationLabel": "Destino", + "editInternalResourceDialogDestinationDescription": "Especifique la dirección de destino para el recurso interno. Puede ser un nombre de host, dirección IP o rango CIDR dependiendo del modo seleccionado. Opcionalmente establezca un alias DNS interno para una identificación más fácil.", + "editInternalResourceDialogPortRestrictionsDescription": "Restringir el acceso a puertos TCP/UDP específicos o permitir/bloquear todos los puertos.", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "Control de acceso", + "editInternalResourceDialogAccessControlDescription": "Controla qué roles, usuarios y clientes de máquinas tienen acceso a este recurso cuando están conectados. Los administradores siempre tienen acceso.", + "editInternalResourceDialogPortRangeValidationError": "El rango de puertos debe ser \"*\" para todos los puertos, o una lista separada por comas de puertos y rangos (por ejemplo, \"80,443,8000-9000\"). Los puertos deben estar entre 1 y 65535.", + "internalResourceAuthDaemonStrategy": "Ubicación del demonio de autenticación SSSH", + "internalResourceAuthDaemonStrategyDescription": "Elija dónde se ejecuta el daemon de autenticación SSH: en el sitio (Newt) o en un host remoto.", + "internalResourceAuthDaemonDescription": "El daemon de autenticación SSSH maneja la firma de claves SSH y autenticación PAM para este recurso. Elija si se ejecuta en el sitio (Newt) o en un host remoto separado. Vea la documentación para más.", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "Seleccionar estrategia", + "internalResourceAuthDaemonStrategyLabel": "Ubicación", + "internalResourceAuthDaemonSite": "En el sitio", + "internalResourceAuthDaemonSiteDescription": "Auth daemon corre en el sitio (Newt).", + "internalResourceAuthDaemonRemote": "Host remoto", + "internalResourceAuthDaemonRemoteDescription": "El daemon Auth corre en un host que no es el sitio.", + "internalResourceAuthDaemonPort": "Puerto de demonio (opcional)", + "orgAuthWhatsThis": "¿Dónde puedo encontrar el ID de mi organización?", + "learnMore": "Más información", + "backToHome": "Volver a inicio", + "needToSignInToOrg": "¿Necesita usar el proveedor de identidad de su organización?", + "maintenanceMode": "Modo de mantenimiento", + "maintenanceModeDescription": "Muestra una página de mantenimiento a los visitantes", + "maintenanceModeType": "Tipo de modo de mantenimiento", + "showMaintenancePage": "Mostrar página de mantenimiento a los visitantes", + "enableMaintenanceMode": "Habilitar modo de mantenimiento", + "automatic": "Automático", + "automaticModeDescription": "Mostrar página de mantenimiento solo cuando todos los objetivos de backend están caídos o no saludables. Su recurso continúa funcionando normalmente siempre que al menos un objetivo esté saludable.", + "forced": "Forzado", + "forcedModeDescription": "Mostrar siempre la página de mantenimiento independientemente de la salud del backend. Use esto para mantenimiento planificado cuando desee evitar todo acceso.", + "warning:": "Advertencia:", + "forcedeModeWarning": "Todo el tráfico será dirigido a la página de mantenimiento. Sus recursos de backend no recibirán solicitudes.", + "pageTitle": "Título de la página", + "pageTitleDescription": "El encabezado principal visible en la página de mantenimiento", + "maintenancePageMessage": "Mensaje de mantenimiento", + "maintenancePageMessagePlaceholder": "¡Volveremos pronto! Nuestro sitio está actualmente en mantenimiento programado.", + "maintenancePageMessageDescription": "Mensaje detallado explicando el mantenimiento", + "maintenancePageTimeTitle": "Tiempo estimado de finalización (Opcional)", + "maintenanceTime": "Ej., 2 horas, 1 de noviembre a las 5:00 PM", + "maintenanceEstimatedTimeDescription": "Cuando espera que el mantenimiento esté terminado", + "editDomain": "Editar dominio", + "editDomainDescription": "Seleccione un dominio para su recurso", + "maintenanceModeDisabledTooltip": "Esta función requiere una licencia válida para ser habilitada.", + "maintenanceScreenTitle": "Servicio temporalmente no disponible", + "maintenanceScreenMessage": "Actualmente estamos experimentando dificultades técnicas. Por favor regrese pronto.", + "maintenanceScreenEstimatedCompletion": "Estimado completado:", + "createInternalResourceDialogDestinationRequired": "Se requiere destino", + "available": "Disponible", + "archived": "Archivado", + "noArchivedDevices": "No se encontraron dispositivos archivados", + "deviceArchived": "Dispositivo archivado", + "deviceArchivedDescription": "El dispositivo se ha archivado correctamente.", + "errorArchivingDevice": "Error al archivar dispositivo", + "failedToArchiveDevice": "Error al archivar el dispositivo", + "deviceQuestionArchive": "¿Está seguro que desea archivar este dispositivo?", + "deviceMessageArchive": "El dispositivo será archivado y eliminado de su lista de dispositivos activos.", + "deviceArchiveConfirm": "Archivar dispositivo", + "archiveDevice": "Archivar dispositivo", + "archive": "Archivar", + "deviceUnarchived": "Dispositivo desarchivado", + "deviceUnarchivedDescription": "El dispositivo se ha desarchivado correctamente.", + "errorUnarchivingDevice": "Error al desarchivar dispositivo", + "failedToUnarchiveDevice": "Error al desarchivar el dispositivo", + "unarchive": "Desarchivar", + "archiveClient": "Archivar cliente", + "archiveClientQuestion": "¿Está seguro que desea archivar este cliente?", + "archiveClientMessage": "El cliente será archivado y eliminado de su lista de clientes activos.", + "archiveClientConfirm": "Archivar cliente", + "blockClient": "Bloquear cliente", + "blockClientQuestion": "¿Estás seguro de que quieres bloquear a este cliente?", + "blockClientMessage": "El dispositivo será forzado a desconectarse si está conectado actualmente. Puede desbloquear el dispositivo más tarde.", + "blockClientConfirm": "Bloquear cliente", + "active": "Activo", + "usernameOrEmail": "Nombre de usuario o email", + "selectYourOrganization": "Seleccione su organización", + "signInTo": "Iniciar sesión en", + "signInWithPassword": "Continuar con la contraseña", + "noAuthMethodsAvailable": "No hay métodos de autenticación disponibles para esta organización.", + "enterPassword": "Introduzca su contraseña", + "enterMfaCode": "Introduzca el código de su aplicación de autenticación", + "securityKeyRequired": "Utilice su clave de seguridad para iniciar sesión.", + "needToUseAnotherAccount": "¿Necesitas usar una cuenta diferente?", + "loginLegalDisclaimer": "Al hacer clic en los botones de abajo, reconoces que has leído, comprendido, y acepta los Términos de Servicio y Política de Privacidad.", + "termsOfService": "Términos de Servicio", + "privacyPolicy": "Política de privacidad", + "userNotFoundWithUsername": "Ningún usuario encontrado con ese nombre de usuario.", + "verify": "Verificar", + "signIn": "Iniciar sesión", + "forgotPassword": "¿Olvidaste la contraseña?", + "orgSignInTip": "Si has iniciado sesión antes, puedes introducir tu nombre de usuario o correo electrónico arriba para autenticarte con el proveedor de identidad de tu organización. ¡Es más fácil!", + "continueAnyway": "Continuar de todos modos", + "dontShowAgain": "No volver a mostrar", + "orgSignInNotice": "¿Sabía usted?", + "signupOrgNotice": "¿Intentando iniciar sesión?", + "signupOrgTip": "¿Estás intentando iniciar sesión a través del proveedor de identidad de tu organización?", + "signupOrgLink": "Inicia sesión o regístrate con tu organización", + "verifyEmailLogInWithDifferentAccount": "Usar una cuenta diferente", + "logIn": "Iniciar sesión", + "deviceInformation": "Información del dispositivo", + "deviceInformationDescription": "Información sobre el dispositivo y el agente", + "deviceSecurity": "Seguridad del dispositivo", + "deviceSecurityDescription": "Información de postura de seguridad del dispositivo", + "platform": "Plataforma", + "macosVersion": "versión macOS", + "windowsVersion": "Versión de Windows", + "iosVersion": "Versión de iOS", + "androidVersion": "Versión de Android", + "osVersion": "Versión del SO", + "kernelVersion": "Versión de Kernel", + "deviceModel": "Modelo de dispositivo", + "serialNumber": "Número Serial", + "hostname": "Hostname", + "firstSeen": "Primer detectado", + "lastSeen": "Último Visto", + "biometricsEnabled": "Biometría habilitada", + "diskEncrypted": "Disco cifrado", + "firewallEnabled": "Cortafuegos activado", + "autoUpdatesEnabled": "Actualizaciones automáticas habilitadas", + "tpmAvailable": "TPM disponible", + "windowsAntivirusEnabled": "Antivirus activado", + "macosSipEnabled": "Protección de integridad del sistema (SIP)", + "macosGatekeeperEnabled": "Gatekeeper", + "macosFirewallStealthMode": "Modo Sigilo Firewall", + "linuxAppArmorEnabled": "AppArmor", + "linuxSELinuxEnabled": "SELinux", + "deviceSettingsDescription": "Ver información y ajustes del dispositivo", + "devicePendingApprovalDescription": "Este dispositivo está esperando su aprobación", + "deviceBlockedDescription": "Este dispositivo está actualmente bloqueado. No podrá conectarse a ningún recurso a menos que sea desbloqueado.", + "unblockClient": "Desbloquear cliente", + "unblockClientDescription": "El dispositivo ha sido desbloqueado", + "unarchiveClient": "Desarchivar cliente", + "unarchiveClientDescription": "El dispositivo ha sido desarchivado", + "block": "Bloque", + "unblock": "Desbloquear", + "deviceActions": "Acciones del dispositivo", + "deviceActionsDescription": "Administrar estado y acceso al dispositivo", + "devicePendingApprovalBannerDescription": "Este dispositivo está pendiente de aprobación. No podrá conectarse a recursos hasta que sea aprobado.", + "connected": "Conectado", + "disconnected": "Desconectado", + "approvalsEmptyStateTitle": "Aprobaciones de dispositivo no habilitadas", + "approvalsEmptyStateDescription": "Habilita las aprobaciones de dispositivos para que los roles requieran aprobación del administrador antes de que los usuarios puedan conectar nuevos dispositivos.", + "approvalsEmptyStateStep1Title": "Ir a roles", + "approvalsEmptyStateStep1Description": "Navega a la configuración de roles de tu organización para configurar las aprobaciones de dispositivos.", + "approvalsEmptyStateStep2Title": "Habilitar aprobaciones de dispositivo", + "approvalsEmptyStateStep2Description": "Editar un rol y habilitar la opción 'Requerir aprobaciones de dispositivos'. Los usuarios con este rol necesitarán la aprobación del administrador para nuevos dispositivos.", + "approvalsEmptyStatePreviewDescription": "Vista previa: Cuando está habilitado, las solicitudes de dispositivo pendientes aparecerán aquí para su revisión", + "approvalsEmptyStateButtonText": "Administrar roles", + "domainErrorTitle": "Estamos teniendo problemas para verificar su dominio" } diff --git a/messages/fr-FR.json b/messages/fr-FR.json index 63e2b5d8c..2b3368a07 100644 --- a/messages/fr-FR.json +++ b/messages/fr-FR.json @@ -1,5 +1,7 @@ { "setupCreate": "Créer l'organisation, le site et les ressources", + "headerAuthCompatibilityInfo": "Activez ceci pour forcer une réponse 401 Unauthorized lorsque le jeton d'authentification est manquant. Cela est nécessaire pour les navigateurs ou les bibliothèques HTTP spécifiques qui n'envoient pas de credentials sans un challenge du serveur.", + "headerAuthCompatibility": "Compatibilité étendue", "setupNewOrg": "Nouvelle organisation", "setupCreateOrg": "Créer une organisation", "setupCreateResources": "Créer des ressources", @@ -16,6 +18,8 @@ "componentsMember": "Vous {count, plural, =0 {n'} other {} }êtes membre {count, plural, =0 {d'aucune organisation} one {d'une organisation} other {de # organisations}}.", "componentsInvalidKey": "Clés de licence invalides ou expirées détectées. Veuillez respecter les conditions de licence pour continuer à utiliser toutes les fonctionnalités.", "dismiss": "Rejeter", + "subscriptionViolationMessage": "Vous dépassez vos limites pour votre forfait actuel. Corrigez le problème en supprimant des sites, des utilisateurs ou d'autres ressources pour rester dans votre forfait.", + "subscriptionViolationViewBilling": "Voir la facturation", "componentsLicenseViolation": "Violation de licence : ce serveur utilise {usedSites} nœuds, ce qui dépasse la limite autorisée de {maxSites} nœuds. Respectez les conditions de licence pour continuer à utiliser toutes les fonctionnalités.", "componentsSupporterMessage": "Merci de soutenir Pangolin en tant que {tier}!", "inviteErrorNotValid": "Nous sommes désolés, mais il semble que l'invitation à laquelle vous essayez d'accéder n'ait pas été acceptée ou ne soit plus valide.", @@ -51,6 +55,12 @@ "siteQuestionRemove": "Êtes-vous sûr de vouloir supprimer ce nœud de l'organisation ?", "siteManageSites": "Gérer les nœuds", "siteDescription": "Créer et gérer des sites pour activer la connectivité aux réseaux privés", + "sitesBannerTitle": "Se connecter à n'importe quel réseau", + "sitesBannerDescription": "Un site est une connexion à un réseau distant qui permet à Pangolin de fournir aux utilisateurs l'accès à des ressources, publiques ou privées, n'importe où. Installez le connecteur de réseau du site (Newt) partout où vous pouvez exécuter un binaire ou un conteneur pour établir la connexion.", + "sitesBannerButtonText": "Installer le site", + "approvalsBannerTitle": "Approuver ou refuser l'accès à l'appareil", + "approvalsBannerDescription": "Examinez et approuvez ou refusez les demandes d'accès à l'appareil des utilisateurs. Lorsque les autorisations de l'appareil sont requises, les utilisateurs doivent obtenir l'approbation de l'administrateur avant que leurs appareils puissent se connecter aux ressources de votre organisation.", + "approvalsBannerButtonText": "En savoir plus", "siteCreate": "Créer un nœud", "siteCreateDescription2": "Suivez les étapes ci-dessous pour créer et connecter un nouveau nœud", "siteCreateDescription": "Créer un nouveau site pour commencer à connecter des ressources", @@ -100,6 +110,7 @@ "siteTunnelDescription": "Déterminer comment vous voulez vous connecter au site", "siteNewtCredentials": "Identifiants", "siteNewtCredentialsDescription": "Voici comment le site s'authentifiera avec le serveur", + "remoteNodeCredentialsDescription": "Voici comment le nœud distant s'authentifiera avec le serveur", "siteCredentialsSave": "Enregistrer les informations d'identification", "siteCredentialsSaveDescription": "Vous ne pourrez voir cela qu'une seule fois. Assurez-vous de l'enregistrer dans un endroit sécurisé.", "siteInfo": "Informations du nœud", @@ -146,8 +157,12 @@ "shareErrorSelectResource": "Veuillez sélectionner une ressource", "proxyResourceTitle": "Gérer les ressources publiques", "proxyResourceDescription": "Créer et gérer des ressources accessibles au public via un navigateur web", + "proxyResourcesBannerTitle": "Accès public basé sur le Web", + "proxyResourcesBannerDescription": "Les ressources publiques sont des proxys HTTPS ou TCP/UDP accessibles par tout le monde sur Internet via un navigateur Web. Contrairement aux ressources privées, elles n'exigent pas de logiciel côté client et peuvent inclure des politiques d'accès basées sur l'identité et le contexte.", "clientResourceTitle": "Gérer les ressources privées", "clientResourceDescription": "Créer et gérer des ressources qui ne sont accessibles que via un client connecté", + "privateResourcesBannerTitle": "Accès privé sans confiance", + "privateResourcesBannerDescription": "Les ressources privées utilisent la sécurité sans confiance, garantissant que les utilisateurs et les machines ne peuvent accéder qu'aux ressources que vous accordez explicitement. Connectez les appareils utilisateur ou les clients machines à ces ressources via un réseau privé virtuel sécurisé.", "resourcesSearch": "Chercher des ressources...", "resourceAdd": "Ajouter une ressource", "resourceErrorDelte": "Erreur lors de la de suppression de la ressource", @@ -157,9 +172,10 @@ "resourceMessageRemove": "Une fois supprimée, la ressource ne sera plus accessible. Toutes les cibles associées à la ressource seront également supprimées.", "resourceQuestionRemove": "Êtes-vous sûr de vouloir retirer la ressource de l'organisation ?", "resourceHTTP": "Ressource HTTPS", - "resourceHTTPDescription": "Requêtes de proxy à l'application via HTTPS en utilisant un sous-domaine ou un domaine de base.", + "resourceHTTPDescription": "Proxy les demandes sur HTTPS en utilisant un nom de domaine entièrement qualifié.", "resourceRaw": "Ressource TCP/UDP brute", - "resourceRawDescription": "Demandes de proxy à l'application via TCP/UDP en utilisant un numéro de port. Cela ne fonctionne que lorsque les sites sont connectés à des nœuds.", + "resourceRawDescription": "Proxy les demandes sur TCP/UDP brut en utilisant un numéro de port.", + "resourceRawDescriptionCloud": "Requêtes de proxy sur TCP/UDP brute en utilisant un numéro de port. Nécessite des sites pour se connecter à un noeud distant.", "resourceCreate": "Créer une ressource", "resourceCreateDescription": "Suivez les étapes ci-dessous pour créer une nouvelle ressource", "resourceSeeAll": "Voir toutes les ressources", @@ -186,6 +202,7 @@ "protocolSelect": "Choisir un protocole", "resourcePortNumber": "Numéro de port", "resourcePortNumberDescription": "Le numéro de port externe pour les requêtes de proxy.", + "back": "Précédent", "cancel": "Abandonner", "resourceConfig": "Snippets de configuration", "resourceConfigDescription": "Copiez et collez ces extraits de configuration pour configurer la ressource TCP/UDP", @@ -231,6 +248,17 @@ "orgErrorDeleteMessage": "Une erreur s'est produite lors de la suppression de l'organisation.", "orgDeleted": "Organisation supprimée", "orgDeletedMessage": "L'organisation et ses données ont été supprimées.", + "deleteAccount": "Supprimer le compte", + "deleteAccountDescription": "Supprimer définitivement votre compte, toutes les organisations que vous possédez et toutes les données au sein de ces organisations. Cela ne peut pas être annulé.", + "deleteAccountButton": "Supprimer le compte", + "deleteAccountConfirmTitle": "Supprimer le compte", + "deleteAccountConfirmMessage": "Cela effacera définitivement votre compte, toutes les organisations que vous possédez et toutes les données au sein de ces organisations. Cela ne peut pas être annulé.", + "deleteAccountConfirmString": "supprimer le compte", + "deleteAccountSuccess": "Compte supprimé", + "deleteAccountSuccessMessage": "Votre compte a été supprimé.", + "deleteAccountError": "Échec de la suppression du compte", + "deleteAccountPreviewAccount": "Votre Compte", + "deleteAccountPreviewOrgs": "Organisations que vous possédez (et toutes leurs données)", "orgMissing": "ID d'organisation manquant", "orgMissingMessage": "Impossible de régénérer l'invitation sans un ID d'organisation.", "accessUsersManage": "Gérer les utilisateurs", @@ -247,6 +275,8 @@ "accessRolesSearch": "Chercher des rôles...", "accessRolesAdd": "Ajouter un rôle", "accessRoleDelete": "Supprimer le rôle", + "accessApprovalsManage": "Gérer les approbations", + "accessApprovalsDescription": "Voir et gérer les approbations en attente pour accéder à cette organisation", "description": "Libellé", "inviteTitle": "Invitations actives", "inviteDescription": "Gérer les invitations des autres utilisateurs à rejoindre l'organisation", @@ -440,6 +470,20 @@ "selectDuration": "Sélectionner la durée", "selectResource": "Sélectionner une ressource", "filterByResource": "Filtrer par ressource", + "selectApprovalState": "Sélectionnez l'État d'Approbation", + "filterByApprovalState": "Filtrer par État d'Approbation", + "approvalListEmpty": "Aucune approbation", + "approvalState": "État d'approbation", + "approvalLoadMore": "Charger plus", + "loadingApprovals": "Chargement des approbations", + "approve": "Approuver", + "approved": "Approuvé", + "denied": "Refusé", + "deniedApproval": "Approbation refusée", + "all": "Tous", + "deny": "Refuser", + "viewDetails": "Voir les détails", + "requestingNewDeviceApproval": "a demandé un nouvel appareil", "resetFilters": "Réinitialiser les filtres", "totalBlocked": "Demandes bloquées par le Pangolin", "totalRequests": "Total des demandes", @@ -607,6 +651,7 @@ "resourcesErrorUpdate": "Échec de la bascule de la ressource", "resourcesErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour de la ressource", "access": "Accès", + "accessControl": "Contrôle d'accès", "shareLink": "Lien de partage {resource}", "resourceSelect": "Sélectionner une ressource", "shareLinks": "Liens de partage", @@ -687,7 +732,7 @@ "resourceRoleDescription": "Les administrateurs peuvent toujours accéder à cette ressource.", "resourceUsersRoles": "Contrôles d'accès", "resourceUsersRolesDescription": "Configurer quels utilisateurs et rôles peuvent visiter cette ressource", - "resourceUsersRolesSubmit": "Enregistrer les utilisateurs et les rôles", + "resourceUsersRolesSubmit": "Enregistrer les contrôles d'accès", "resourceWhitelistSave": "Enregistré avec succès", "resourceWhitelistSaveDescription": "Les paramètres de la liste blanche ont été enregistrés", "ssoUse": "Utiliser la SSO de la plateforme", @@ -719,22 +764,35 @@ "countries": "Pays", "accessRoleCreate": "Créer un rôle", "accessRoleCreateDescription": "Créer un nouveau rôle pour regrouper les utilisateurs et gérer leurs permissions.", + "accessRoleEdit": "Modifier le rôle", + "accessRoleEditDescription": "Modifier les informations du rôle.", "accessRoleCreateSubmit": "Créer un rôle", "accessRoleCreated": "Rôle créé", "accessRoleCreatedDescription": "Le rôle a été créé avec succès.", "accessRoleErrorCreate": "Échec de la création du rôle", "accessRoleErrorCreateDescription": "Une erreur s'est produite lors de la création du rôle.", + "accessRoleUpdateSubmit": "Mettre à jour un rôle", + "accessRoleUpdated": "Rôle mis à jour", + "accessRoleUpdatedDescription": "Le rôle a été mis à jour avec succès.", + "accessApprovalUpdated": "Approbation traitée", + "accessApprovalApprovedDescription": "Définir la décision de la demande d'approbation à approuver.", + "accessApprovalDeniedDescription": "Définir la décision de la demande d'approbation comme refusée.", + "accessRoleErrorUpdate": "Impossible de mettre à jour le rôle", + "accessRoleErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour du rôle.", + "accessApprovalErrorUpdate": "Impossible de traiter l'approbation", + "accessApprovalErrorUpdateDescription": "Une erreur s'est produite lors du traitement de l'approbation.", "accessRoleErrorNewRequired": "Un nouveau rôle est requis", "accessRoleErrorRemove": "Échec de la suppression du rôle", "accessRoleErrorRemoveDescription": "Une erreur s'est produite lors de la suppression du rôle.", "accessRoleName": "Nom du rôle", - "accessRoleQuestionRemove": "Vous êtes sur le point de supprimer le rôle {name}. Cette action est irréversible.", + "accessRoleQuestionRemove": "Vous êtes sur le point de supprimer le rôle `{name}`. Vous ne pouvez pas annuler cette action.", "accessRoleRemove": "Supprimer le rôle", "accessRoleRemoveDescription": "Retirer un rôle de l'organisation", "accessRoleRemoveSubmit": "Supprimer le rôle", "accessRoleRemoved": "Rôle supprimé", "accessRoleRemovedDescription": "Le rôle a été supprimé avec succès.", "accessRoleRequiredRemove": "Avant de supprimer ce rôle, veuillez sélectionner un nouveau rôle pour transférer les membres existants.", + "network": "Réseau", "manage": "Gérer", "sitesNotFound": "Aucun site trouvé.", "pangolinServerAdmin": "Admin Serveur - Pangolin", @@ -750,6 +808,9 @@ "sitestCountIncrease": "Augmenter le nombre de sites", "idpManage": "Gérer les fournisseurs d'identité", "idpManageDescription": "Voir et gérer les fournisseurs d'identité dans le système", + "idpGlobalModeBanner": "Les fournisseurs d'identité (IdPs) par organisation sont désactivés sur ce serveur. Il utilise des IdPs globaux (partagés entre toutes les organisations). Gérez les IdPs globaux dans le panneau d'administration . Pour activer les IdPs par organisation, éditez la configuration du serveur et réglez le mode IdP sur org. Voir la documentation. Si vous voulez continuer à utiliser les IdPs globaux et faire disparaître cela des paramètres de l'organisation, définissez explicitement le mode à global dans la configuration.", + "idpGlobalModeBannerUpgradeRequired": "Les fournisseurs d'identité (IdPs) par organisation sont désactivés sur ce serveur. Il utilise des IdPs globaux (partagés entre toutes les organisations). Gérer les IdPs globaux dans le panneau d'administration . Pour utiliser les fournisseurs d'identité par organisation, vous devez passer à l'édition Entreprise.", + "idpGlobalModeBannerLicenseRequired": "Les fournisseurs d'identité (IdPs) par organisation sont désactivés sur ce serveur. Il utilise des IdPs globaux (partagés entre toutes les organisations). Gérer les IdPs globaux dans le panneau d'administration . Pour utiliser les fournisseurs d'identité par organisation, une licence d'entreprise est requise.", "idpDeletedDescription": "Fournisseur d'identité supprimé avec succès", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Êtes-vous sûr de vouloir supprimer définitivement le fournisseur d'identité?", @@ -840,6 +901,7 @@ "orgPolicyConfig": "Configurer l'accès pour une organisation", "idpUpdatedDescription": "Fournisseur d'identité mis à jour avec succès", "redirectUrl": "URL de redirection", + "orgIdpRedirectUrls": "URL de redirection", "redirectUrlAbout": "À propos de l'URL de redirection", "redirectUrlAboutDescription": "C'est l'URL vers laquelle les utilisateurs seront redirigés après l'authentification. Vous devez configurer cette URL dans les paramètres du fournisseur d'identité.", "pangolinAuth": "Auth - Pangolin", @@ -945,11 +1007,11 @@ "pincodeAuth": "Code d'authentification", "pincodeSubmit2": "Soumettre le code", "passwordResetSubmit": "Demander la réinitialisation", - "passwordResetAlreadyHaveCode": "Entrez le code de réinitialisation du mot de passe", + "passwordResetAlreadyHaveCode": "Entrer le code", "passwordResetSmtpRequired": "Veuillez contacter votre administrateur", "passwordResetSmtpRequiredDescription": "Un code de réinitialisation du mot de passe est requis pour réinitialiser votre mot de passe. Veuillez contacter votre administrateur pour obtenir de l'aide.", "passwordBack": "Retour au mot de passe", - "loginBack": "Retour à la connexion", + "loginBack": "Revenir à la page de connexion principale", "signup": "S'inscrire", "loginStart": "Connectez-vous pour commencer", "idpOidcTokenValidating": "Validation du jeton OIDC", @@ -972,12 +1034,12 @@ "pangolinSetup": "Configuration - Pangolin", "orgNameRequired": "Le nom de l'organisation est requis", "orgIdRequired": "L'ID de l'organisation est requis", + "orgIdMaxLength": "L'identifiant de l'organisation doit comporter au plus 32 caractères", "orgErrorCreate": "Une erreur s'est produite lors de la création de l'organisation", "pageNotFound": "Page non trouvée", "pageNotFoundDescription": "Oups! La page que vous recherchez n'existe pas.", "overview": "Vue d'ensemble", "home": "Accueil", - "accessControl": "Contrôle d'accès", "settings": "Paramètres", "usersAll": "Tous les utilisateurs", "license": "Licence", @@ -1035,15 +1097,24 @@ "updateOrgUser": "Mise à jour de l'utilisateur Org", "createOrgUser": "Créer un utilisateur Org", "actionUpdateOrg": "Mettre à jour l'organisation", + "actionRemoveInvitation": "Supprimer l'invitation", "actionUpdateUser": "Mettre à jour l'utilisateur", "actionGetUser": "Obtenir l'utilisateur", "actionGetOrgUser": "Obtenir l'utilisateur de l'organisation", "actionListOrgDomains": "Lister les domaines de l'organisation", + "actionGetDomain": "Obtenir un domaine", + "actionCreateOrgDomain": "Créer un domaine", + "actionUpdateOrgDomain": "Mettre à jour le domaine", + "actionDeleteOrgDomain": "Supprimer le domaine", + "actionGetDNSRecords": "Récupérer les enregistrements DNS", + "actionRestartOrgDomain": "Redémarrer le domaine", "actionCreateSite": "Créer un site", "actionDeleteSite": "Supprimer un site", "actionGetSite": "Obtenir un site", "actionListSites": "Lister les sites", "actionApplyBlueprint": "Appliquer la Config", + "actionListBlueprints": "Lister les plans", + "actionGetBlueprint": "Obtenez un plan", "setupToken": "Jeton de configuration", "setupTokenDescription": "Entrez le jeton de configuration depuis la console du serveur.", "setupTokenRequired": "Le jeton de configuration est requis.", @@ -1077,6 +1148,7 @@ "actionRemoveUser": "Supprimer un utilisateur", "actionListUsers": "Lister les utilisateurs", "actionAddUserRole": "Ajouter un rôle utilisateur", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Générer un jeton d'accès", "actionDeleteAccessToken": "Supprimer un jeton d'accès", "actionListAccessTokens": "Lister les jetons d'accès", @@ -1104,6 +1176,10 @@ "actionUpdateIdpOrg": "Mettre à jour une organisation IDP", "actionCreateClient": "Créer un client", "actionDeleteClient": "Supprimer le client", + "actionArchiveClient": "Archiver le client", + "actionUnarchiveClient": "Désarchiver le client", + "actionBlockClient": "Bloquer le client", + "actionUnblockClient": "Débloquer le client", "actionUpdateClient": "Mettre à jour le client", "actionListClients": "Liste des clients", "actionGetClient": "Obtenir le client", @@ -1117,17 +1193,18 @@ "actionViewLogs": "Voir les logs", "noneSelected": "Aucune sélection", "orgNotFound2": "Aucune organisation trouvée.", - "searchProgress": "Rechercher...", + "searchPlaceholder": "Recherche...", + "emptySearchOptions": "Aucune option trouvée", "create": "Créer", "orgs": "Organisations", - "loginError": "Une erreur s'est produite lors de la connexion", - "loginRequiredForDevice": "La connexion est requise pour authentifier votre appareil.", + "loginError": "Une erreur inattendue s'est produite. Veuillez réessayer.", + "loginRequiredForDevice": "La connexion est requise pour votre appareil.", "passwordForgot": "Mot de passe oublié ?", "otpAuth": "Authentification à deux facteurs", "otpAuthDescription": "Entrez le code de votre application d'authentification ou l'un de vos codes de secours à usage unique.", "otpAuthSubmit": "Soumettre le code", "idpContinue": "Ou continuer avec", - "otpAuthBack": "Retour à la connexion", + "otpAuthBack": "Retour au mot de passe", "navbar": "Menu de navigation", "navbarDescription": "Menu de navigation principal de l'application", "navbarDocsLink": "Documentation", @@ -1175,11 +1252,13 @@ "sidebarOverview": "Aperçu", "sidebarHome": "Domicile", "sidebarSites": "Nœuds", + "sidebarApprovals": "Demandes d'approbation", "sidebarResources": "Ressource", "sidebarProxyResources": "Publique", "sidebarClientResources": "Privé", "sidebarAccessControl": "Contrôle d'accès", "sidebarLogsAndAnalytics": "Journaux & Analytiques", + "sidebarTeam": "Equipe", "sidebarUsers": "Utilisateurs", "sidebarAdmin": "Administrateur", "sidebarInvitations": "Invitations", @@ -1191,13 +1270,15 @@ "sidebarIdentityProviders": "Fournisseurs d'identité", "sidebarLicense": "Licence", "sidebarClients": "Clients", - "sidebarUserDevices": "Utilisateurs", + "sidebarUserDevices": "Périphériques utilisateur", "sidebarMachineClients": "Machines", "sidebarDomains": "Domaines", - "sidebarGeneral": "Généraux", + "sidebarGeneral": "Gérer", "sidebarLogAndAnalytics": "Journaux & Analytiques", "sidebarBluePrints": "Configs", "sidebarOrganization": "Organisation", + "sidebarManagement": "Gestion", + "sidebarBillingAndLicenses": "Facturation & Licences", "sidebarLogsAnalytics": "Analyses", "blueprints": "Configs", "blueprintsDescription": "Appliquer les configurations déclaratives et afficher les exécutions précédentes", @@ -1219,7 +1300,6 @@ "parsedContents": "Contenu analysé (lecture seule)", "enableDockerSocket": "Activer la Config Docker", "enableDockerSocketDescription": "Activer le ramassage d'étiquettes de socket Docker pour les étiquettes de plan. Le chemin de socket doit être fourni à Newt.", - "enableDockerSocketLink": "En savoir plus", "viewDockerContainers": "Voir les conteneurs Docker", "containersIn": "Conteneurs en {siteName}", "selectContainerDescription": "Sélectionnez n'importe quel conteneur à utiliser comme nom d'hôte pour cette cible. Cliquez sur un port pour utiliser un port.", @@ -1263,6 +1343,7 @@ "setupErrorCreateAdmin": "Une erreur s'est produite lors de la création du compte administrateur du serveur.", "certificateStatus": "Statut du certificat", "loading": "Chargement", + "loadingAnalytics": "Chargement de l'analyse", "restart": "Redémarrer", "domains": "Domaines", "domainsDescription": "Créer et gérer les domaines disponibles dans l'organisation", @@ -1290,6 +1371,7 @@ "refreshError": "Échec de l'actualisation des données", "verified": "Vérifié", "pending": "En attente", + "pendingApproval": "En attente d'approbation", "sidebarBilling": "Facturation", "billing": "Facturation", "orgBillingDescription": "Gérer les informations de facturation et les abonnements", @@ -1308,8 +1390,11 @@ "accountSetupSuccess": "Configuration du compte terminée! Bienvenue chez Pangolin !", "documentation": "Documentation", "saveAllSettings": "Enregistrer tous les paramètres", + "saveResourceTargets": "Enregistrer les cibles", + "saveResourceHttp": "Enregistrer les paramètres de proxy", + "saveProxyProtocol": "Enregistrer les paramètres du protocole proxy", "settingsUpdated": "Paramètres mis à jour", - "settingsUpdatedDescription": "Tous les paramètres ont été mis à jour avec succès", + "settingsUpdatedDescription": "Paramètres mis à jour avec succès", "settingsErrorUpdate": "Échec de la mise à jour des paramètres", "settingsErrorUpdateDescription": "Une erreur s'est produite lors de la mise à jour des paramètres", "sidebarCollapse": "Réduire", @@ -1342,6 +1427,7 @@ "domainPickerNamespace": "Espace de noms : {namespace}", "domainPickerShowMore": "Afficher plus", "regionSelectorTitle": "Sélectionner Région", + "domainPickerRemoteExitNodeWarning": "Les domaines fournis ne sont pas pris en charge lorsque les sites se connectent à des nœuds de sortie distants. Pour que les ressources soient disponibles sur des nœuds distants, utilisez un domaine personnalisé à la place.", "regionSelectorInfo": "Sélectionner une région nous aide à offrir de meilleures performances pour votre localisation. Vous n'avez pas besoin d'être dans la même région que votre serveur.", "regionSelectorPlaceholder": "Choisissez une région", "regionSelectorComingSoon": "Bientôt disponible", @@ -1351,10 +1437,11 @@ "billingUsageLimitsOverview": "Vue d'ensemble des limites d'utilisation", "billingMonitorUsage": "Surveillez votre consommation par rapport aux limites configurées. Si vous avez besoin d'une augmentation des limites, veuillez nous contacter à support@pangolin.net.", "billingDataUsage": "Utilisation des données", - "billingOnlineTime": "Temps en ligne du site", - "billingUsers": "Utilisateurs actifs", - "billingDomains": "Domaines actifs", - "billingRemoteExitNodes": "Nœuds auto-hébergés actifs", + "billingSites": "Nœuds", + "billingUsers": "Utilisateurs", + "billingDomains": "Domaines", + "billingOrganizations": "Organes", + "billingRemoteExitNodes": "Nœuds distants", "billingNoLimitConfigured": "Aucune limite configurée", "billingEstimatedPeriod": "Période de facturation estimée", "billingIncludedUsage": "Utilisation incluse", @@ -1379,15 +1466,24 @@ "billingFailedToGetPortalUrl": "Échec pour obtenir l'URL du portail", "billingPortalError": "Erreur du portail", "billingDataUsageInfo": "Vous êtes facturé pour toutes les données transférées via vos tunnels sécurisés lorsque vous êtes connecté au cloud. Cela inclut le trafic entrant et sortant sur tous vos sites. Lorsque vous atteignez votre limite, vos sites se déconnecteront jusqu'à ce que vous mettiez à niveau votre plan ou réduisiez l'utilisation. Les données ne sont pas facturées lors de l'utilisation de nœuds.", - "billingOnlineTimeInfo": "Vous êtes facturé en fonction de la durée de connexion de vos sites au cloud. Par exemple, 44 640 minutes équivaut à un site fonctionnant 24/7 pendant un mois complet. Lorsque vous atteignez votre limite, vos sites se déconnecteront jusqu'à ce que vous mettiez à niveau votre forfait ou réduisiez votre consommation. Le temps n'est pas facturé lors de l'utilisation de nœuds.", - "billingUsersInfo": "Vous êtes facturé pour chaque utilisateur de l'organisation. La facturation est calculée quotidiennement en fonction du nombre de comptes d'utilisateurs actifs dans votre organisation.", - "billingDomainInfo": "Vous êtes facturé pour chaque domaine de l'organisation. La facturation est calculée quotidiennement en fonction du nombre de comptes de domaine actifs dans votre organisation.", - "billingRemoteExitNodesInfo": "Vous êtes facturé pour chaque noeud géré dans l'organisation. La facturation est calculée quotidiennement en fonction du nombre de nœuds gérés actifs dans votre organisation.", + "billingSInfo": "Combien de sites vous pouvez utiliser", + "billingUsersInfo": "Combien d'utilisateurs vous pouvez utiliser", + "billingDomainInfo": "Combien de domaines vous pouvez utiliser", + "billingRemoteExitNodesInfo": "Combien de nœuds distants vous pouvez utiliser", + "billingLicenseKeys": "Clés de licence", + "billingLicenseKeysDescription": "Gérer vos abonnements à la clé de licence", + "billingLicenseSubscription": "Abonnement à la licence", + "billingInactive": "Inactif", + "billingLicenseItem": "Article de la licence", + "billingQuantity": "Quantité", + "billingTotal": "total", + "billingModifyLicenses": "Modifier l'abonnement à la licence", "domainNotFound": "Domaine introuvable", "domainNotFoundDescription": "Cette ressource est désactivée car le domaine n'existe plus dans notre système. Veuillez définir un nouveau domaine pour cette ressource.", "failed": "Échec", "createNewOrgDescription": "Créer une nouvelle organisation", "organization": "Organisation", + "primary": "Primaire", "port": "Port", "securityKeyManage": "Gérer les clés de sécurité", "securityKeyDescription": "Ajouter ou supprimer des clés de sécurité pour l'authentification sans mot de passe", @@ -1403,7 +1499,7 @@ "securityKeyRemoveSuccess": "Clé de sécurité supprimée avec succès", "securityKeyRemoveError": "Échec de la suppression de la clé de sécurité", "securityKeyLoadError": "Échec du chargement des clés de sécurité", - "securityKeyLogin": "Continuer avec une clé de sécurité", + "securityKeyLogin": "Utiliser la clé de sécurité", "securityKeyAuthError": "Échec de l'authentification avec la clé de sécurité", "securityKeyRecommendation": "Envisagez d'enregistrer une autre clé de sécurité sur un appareil différent pour vous assurer de ne pas être bloqué de votre compte.", "registering": "Enregistrement...", @@ -1459,11 +1555,47 @@ "resourcePortRequired": "Le numéro de port est requis pour les ressources non-HTTP", "resourcePortNotAllowed": "Le numéro de port ne doit pas être défini pour les ressources HTTP", "billingPricingCalculatorLink": "Calculateur de prix", + "billingYourPlan": "Votre plan", + "billingViewOrModifyPlan": "Voir ou modifier votre forfait actuel", + "billingViewPlanDetails": "Voir les détails du plan", + "billingUsageAndLimits": "Utilisation et limites", + "billingViewUsageAndLimits": "Voir les limites de votre plan et l'utilisation actuelle", + "billingCurrentUsage": "Utilisation actuelle", + "billingMaximumLimits": "Limites maximum", + "billingRemoteNodes": "Nœuds distants", + "billingUnlimited": "Illimité", + "billingPaidLicenseKeys": "Clés de licence payantes", + "billingManageLicenseSubscription": "Gérer votre abonnement pour les clés de licence auto-hébergées payantes", + "billingCurrentKeys": "Clés actuelles", + "billingModifyCurrentPlan": "Modifier le plan actuel", + "billingConfirmUpgrade": "Confirmer la mise à niveau", + "billingConfirmDowngrade": "Confirmer la rétrogradation", + "billingConfirmUpgradeDescription": "Vous êtes sur le point de mettre à niveau votre offre. Examinez les nouvelles limites et les nouveaux prix ci-dessous.", + "billingConfirmDowngradeDescription": "Vous êtes sur le point de rétrograder votre forfait. Examinez les nouvelles limites et les prix ci-dessous.", + "billingPlanIncludes": "Le forfait comprend", + "billingProcessing": "Traitement en cours...", + "billingConfirmUpgradeButton": "Confirmer la mise à niveau", + "billingConfirmDowngradeButton": "Confirmer la rétrogradation", + "billingLimitViolationWarning": "Utilisation dépassée les nouvelles limites de plan", + "billingLimitViolationDescription": "Votre utilisation actuelle dépasse les limites de ce plan. Après rétrogradation, toutes les actions seront désactivées jusqu'à ce que vous réduisiez l'utilisation dans les nouvelles limites. Veuillez consulter les fonctionnalités ci-dessous qui dépassent actuellement les limites. Limites en violation :", + "billingFeatureLossWarning": "Avis de disponibilité des fonctionnalités", + "billingFeatureLossDescription": "En rétrogradant, les fonctionnalités non disponibles dans le nouveau plan seront automatiquement désactivées. Certains paramètres et configurations peuvent être perdus. Veuillez consulter la matrice de prix pour comprendre quelles fonctionnalités ne seront plus disponibles.", + "billingUsageExceedsLimit": "L'utilisation actuelle ({current}) dépasse la limite ({limit})", + "billingPastDueTitle": "Paiement en retard", + "billingPastDueDescription": "Votre paiement est échu. Veuillez mettre à jour votre méthode de paiement pour continuer à utiliser les fonctionnalités de votre plan actuel. Si non résolu, votre abonnement sera annulé et vous serez remis au niveau gratuit.", + "billingUnpaidTitle": "Abonnement impayé", + "billingUnpaidDescription": "Votre abonnement est impayé et vous avez été reversé au niveau gratuit. Veuillez mettre à jour votre méthode de paiement pour restaurer votre abonnement.", + "billingIncompleteTitle": "Paiement incomplet", + "billingIncompleteDescription": "Votre paiement est incomplet. Veuillez compléter le processus de paiement pour activer votre abonnement.", + "billingIncompleteExpiredTitle": "Paiement expiré", + "billingIncompleteExpiredDescription": "Votre paiement n'a jamais été complété et a expiré. Vous avez été restauré au niveau gratuit. Veuillez vous abonner à nouveau pour restaurer l'accès aux fonctionnalités payantes.", + "billingManageSubscription": "Gérer votre abonnement", + "billingResolvePaymentIssue": "Veuillez résoudre votre problème de paiement avant de procéder à la mise à niveau ou à la rétrogradation", "signUpTerms": { "IAgreeToThe": "Je suis d'accord avec", "termsOfService": "les conditions d'utilisation", "and": "et", - "privacyPolicy": "la politique de confidentialité" + "privacyPolicy": "politique de confidentialité." }, "signUpMarketing": { "keepMeInTheLoop": "Gardez-moi dans la boucle avec des nouvelles, des mises à jour et de nouvelles fonctionnalités par courriel." @@ -1508,6 +1640,7 @@ "addNewTarget": "Ajouter une nouvelle cible", "targetsList": "Liste des cibles", "advancedMode": "Mode Avancé", + "advancedSettings": "Paramètres avancés", "targetErrorDuplicateTargetFound": "Cible en double trouvée", "healthCheckHealthy": "Sain", "healthCheckUnhealthy": "En mauvaise santé", @@ -1529,6 +1662,26 @@ "IntervalSeconds": "Intervalle sain", "timeoutSeconds": "Délai d'attente (sec)", "timeIsInSeconds": "Le temps est exprimé en secondes", + "requireDeviceApproval": "Exiger les autorisations de l'appareil", + "requireDeviceApprovalDescription": "Les utilisateurs ayant ce rôle ont besoin de nouveaux périphériques approuvés par un administrateur avant de pouvoir se connecter et accéder aux ressources.", + "sshAccess": "Accès SSH", + "roleAllowSsh": "Autoriser SSH", + "roleAllowSshAllow": "Autoriser", + "roleAllowSshDisallow": "Interdire", + "roleAllowSshDescription": "Autoriser les utilisateurs avec ce rôle à se connecter aux ressources via SSH. Lorsque désactivé, le rôle ne peut pas utiliser les accès SSH.", + "sshSudoMode": "Accès Sudo", + "sshSudoModeNone": "Aucun", + "sshSudoModeNoneDescription": "L'utilisateur ne peut pas exécuter de commandes avec sudo.", + "sshSudoModeFull": "Sudo complet", + "sshSudoModeFullDescription": "L'utilisateur peut exécuter n'importe quelle commande avec sudo.", + "sshSudoModeCommands": "Commandes", + "sshSudoModeCommandsDescription": "L'utilisateur ne peut exécuter que les commandes spécifiées avec sudo.", + "sshSudo": "Autoriser sudo", + "sshSudoCommands": "Commandes Sudo", + "sshSudoCommandsDescription": "Liste des commandes séparées par des virgules que l'utilisateur est autorisé à exécuter avec sudo.", + "sshCreateHomeDir": "Créer un répertoire personnel", + "sshUnixGroups": "Groupes Unix", + "sshUnixGroupsDescription": "Groupes Unix séparés par des virgules pour ajouter l'utilisateur sur l'hôte cible.", "retryAttempts": "Tentatives de réessai", "expectedResponseCodes": "Codes de réponse attendus", "expectedResponseCodesDescription": "Code de statut HTTP indiquant un état de santé satisfaisant. Si non renseigné, 200-300 est considéré comme satisfaisant.", @@ -1569,6 +1722,8 @@ "resourcesTableNoInternalResourcesFound": "Aucune ressource interne trouvée.", "resourcesTableDestination": "Destination", "resourcesTableAlias": "Alias", + "resourcesTableAliasAddress": "Adresse de l'alias", + "resourcesTableAliasAddressInfo": "Cette adresse fait partie du sous-réseau utilitaire de l'organisation. Elle est utilisée pour résoudre les enregistrements d'alias en utilisant une résolution DNS interne.", "resourcesTableClients": "Clients", "resourcesTableAndOnlyAccessibleInternally": "et sont uniquement accessibles en interne lorsqu'elles sont connectées avec un client.", "resourcesTableNoTargets": "Aucune cible", @@ -1616,9 +1771,8 @@ "createInternalResourceDialogResourceProperties": "Propriétés de la ressource", "createInternalResourceDialogName": "Nom", "createInternalResourceDialogSite": "Site", - "createInternalResourceDialogSelectSite": "Sélectionner un site...", - "createInternalResourceDialogSearchSites": "Rechercher des sites...", - "createInternalResourceDialogNoSitesFound": "Aucun site trouvé.", + "selectSite": "Sélectionner un site...", + "noSitesFound": "Aucun site trouvé.", "createInternalResourceDialogProtocol": "Protocole", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", @@ -1658,7 +1812,7 @@ "siteAddressDescription": "L'adresse interne du site. Doit être dans le sous-réseau de l'organisation.", "siteNameDescription": "Le nom d'affichage du site qui peut être modifié plus tard.", "autoLoginExternalIdp": "Connexion automatique avec IDP externe", - "autoLoginExternalIdpDescription": "Rediriger immédiatement l'utilisateur vers l'IDP externe pour l'authentification.", + "autoLoginExternalIdpDescription": "Redirigez immédiatement l'utilisateur vers le fournisseur d'identité externe pour l'authentification.", "selectIdp": "Sélectionner l'IDP", "selectIdpPlaceholder": "Choisissez un IDP...", "selectIdpRequired": "Veuillez sélectionner un IDP lorsque la connexion automatique est activée.", @@ -1670,7 +1824,7 @@ "autoLoginErrorNoRedirectUrl": "Aucune URL de redirection reçue du fournisseur d'identité.", "autoLoginErrorGeneratingUrl": "Échec de la génération de l'URL d'authentification.", "remoteExitNodeManageRemoteExitNodes": "Nœuds distants", - "remoteExitNodeDescription": "Héberger un ou plusieurs nœuds distants pour étendre la connectivité réseau et réduire la dépendance sur le cloud", + "remoteExitNodeDescription": "Hébergez vous-même vos propres nœuds de relais et de serveur proxy distants", "remoteExitNodes": "Nœuds", "searchRemoteExitNodes": "Rechercher des nœuds...", "remoteExitNodeAdd": "Ajouter un noeud", @@ -1680,20 +1834,22 @@ "remoteExitNodeConfirmDelete": "Confirmer la suppression du noeud", "remoteExitNodeDelete": "Supprimer le noeud", "sidebarRemoteExitNodes": "Nœuds distants", + "remoteExitNodeId": "ID", + "remoteExitNodeSecretKey": "Clé secrète", "remoteExitNodeCreate": { - "title": "Créer un noeud", - "description": "Créer un nouveau nœud pour étendre la connectivité réseau", + "title": "Créer un nœud distant", + "description": "Créez un nouveau nœud de relais et de serveur proxy distant auto-hébergé", "viewAllButton": "Voir tous les nœuds", "strategy": { "title": "Stratégie de création", - "description": "Choisissez ceci pour configurer manuellement le noeud ou générer de nouveaux identifiants.", + "description": "Sélectionnez comment vous souhaitez créer le nœud distant", "adopt": { "title": "Adopter un nœud", "description": "Choisissez ceci si vous avez déjà les identifiants pour le noeud." }, "generate": { "title": "Générer des clés", - "description": "Choisissez ceci si vous voulez générer de nouvelles clés pour le noeud" + "description": "Choisissez ceci si vous voulez générer de nouvelles clés pour le noeud." } }, "adopt": { @@ -1806,9 +1962,33 @@ "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Sous-réseau", "subnetDescription": "Le sous-réseau de la configuration réseau de cette organisation.", - "authPage": "Page d'authentification", - "authPageDescription": "Configurer la page d'authentification de l'organisation", + "customDomain": "Domaine personnalisé", + "authPage": "Pages d'authentification", + "authPageDescription": "Définissez un domaine personnalisé pour les pages d'authentification de l'organisation", "authPageDomain": "Domaine de la page d'authentification", + "authPageBranding": "Marque personnalisée", + "authPageBrandingDescription": "Configurez la marque qui apparaît sur les pages d'authentification pour cette organisation", + "authPageBrandingUpdated": "Marque de la page d'authentification mise à jour avec succès", + "authPageBrandingRemoved": "Marque de la page d'authentification supprimée avec succès", + "authPageBrandingRemoveTitle": "Supprimer la marque de la page d'authentification", + "authPageBrandingQuestionRemove": "Êtes-vous sûr de vouloir supprimer la marque des pages d'authentification ?", + "authPageBrandingDeleteConfirm": "Confirmer la suppression de la marque", + "brandingLogoURL": "URL du logo", + "brandingLogoURLOrPath": "URL du logo ou du chemin d'accès", + "brandingLogoPathDescription": "Entrez une URL ou un chemin local.", + "brandingLogoURLDescription": "Entrez une URL accessible au public à votre image de logo.", + "brandingPrimaryColor": "Couleur principale", + "brandingLogoWidth": "Largeur (px)", + "brandingLogoHeight": "Hauteur (px)", + "brandingOrgTitle": "Titre pour la page d'authentification de l'organisation", + "brandingOrgDescription": "{orgName} sera remplacé par le nom de l'organisation", + "brandingOrgSubtitle": "Sous-titre pour la page d'authentification de l'organisation", + "brandingResourceTitle": "Titre pour la page d'authentification de la ressource", + "brandingResourceSubtitle": "Sous-titre pour la page d'authentification de la ressource", + "brandingResourceDescription": "{resourceName} sera remplacé par le nom de l'organisation", + "saveAuthPageDomain": "Enregistrer le domaine", + "saveAuthPageBranding": "Enregistrer la marque", + "removeAuthPageBranding": "Supprimer la marque", "noDomainSet": "Aucun domaine défini", "changeDomain": "Changer de domaine", "selectDomain": "Sélectionner un domaine", @@ -1817,7 +1997,7 @@ "setAuthPageDomain": "Définir le domaine de la page d'authentification", "failedToFetchCertificate": "Impossible de récupérer le certificat", "failedToRestartCertificate": "Échec du redémarrage du certificat", - "addDomainToEnableCustomAuthPages": "Ajouter un domaine pour activer les pages d'authentification personnalisées pour l'organisation", + "addDomainToEnableCustomAuthPages": "Les utilisateurs pourront accéder à la page de connexion de l'organisation et compléter l'authentification de la ressource en utilisant ce domaine.", "selectDomainForOrgAuthPage": "Sélectionnez un domaine pour la page d'authentification de l'organisation", "domainPickerProvidedDomain": "Domaine fourni", "domainPickerFreeProvidedDomain": "Domaine fourni gratuitement", @@ -1832,11 +2012,27 @@ "domainPickerInvalidSubdomainCannotMakeValid": "La «{sub}» n'a pas pu être validée pour {domain}.", "domainPickerSubdomainSanitized": "Sous-domaine nettoyé", "domainPickerSubdomainCorrected": "\"{sub}\" a été corrigé à \"{sanitized}\"", - "orgAuthSignInTitle": "Se connecter à l'organisation", + "orgAuthSignInTitle": "Connexion à l'organisation", "orgAuthChooseIdpDescription": "Choisissez votre fournisseur d'identité pour continuer", "orgAuthNoIdpConfigured": "Cette organisation n'a aucun fournisseur d'identité configuré. Vous pouvez vous connecter avec votre identité Pangolin à la place.", "orgAuthSignInWithPangolin": "Se connecter avec Pangolin", + "orgAuthSignInToOrg": "Se connecter à une organisation", + "orgAuthSelectOrgTitle": "Connexion à l'organisation", + "orgAuthSelectOrgDescription": "Entrez votre identifiant d'organisation pour continuer", + "orgAuthOrgIdPlaceholder": "votre-organisation", + "orgAuthOrgIdHelp": "Entrez l'identifiant unique de votre organisation", + "orgAuthSelectOrgHelp": "Après avoir entré votre identifiant d'organisation, vous serez dirigé vers la page de connexion de votre organisation où vous pourrez utiliser l'authentification unique (SSO) ou vos identifiants d'organisation.", + "orgAuthRememberOrgId": "Mémoriser cet identifiant d'organisation", + "orgAuthBackToSignIn": "Retour à la connexion standard", + "orgAuthNoAccount": "Vous n'avez pas de compte ?", "subscriptionRequiredToUse": "Un abonnement est requis pour utiliser cette fonctionnalité.", + "mustUpgradeToUse": "Vous devez mettre à jour votre abonnement pour utiliser cette fonctionnalité.", + "subscriptionRequiredTierToUse": "Cette fonctionnalité nécessite {tier} ou supérieur.", + "upgradeToTierToUse": "Passez à {tier} ou plus pour utiliser cette fonctionnalité.", + "subscriptionTierTier1": "Domicile", + "subscriptionTierTier2": "Equipe", + "subscriptionTierTier3": "Entreprise", + "subscriptionTierEnterprise": "Entreprise", "idpDisabled": "Les fournisseurs d'identité sont désactivés.", "orgAuthPageDisabled": "La page d'authentification de l'organisation est désactivée.", "domainRestartedDescription": "La vérification du domaine a été redémarrée avec succès", @@ -1850,6 +2046,8 @@ "enableTwoFactorAuthentication": "Activer l'authentification à deux facteurs", "completeSecuritySteps": "Compléter les étapes de sécurité", "securitySettings": "Paramètres de sécurité", + "dangerSection": "Zone dangereuse", + "dangerSectionDescription": "Supprimez définitivement toutes les données associées à cette organisation", "securitySettingsDescription": "Configurer les politiques de sécurité de l'organisation", "requireTwoFactorForAllUsers": "Exiger une authentification à deux facteurs pour tous les utilisateurs", "requireTwoFactorDescription": "Lorsque cette option est activée, tous les utilisateurs internes de cette organisation doivent avoir l'authentification à deux facteurs pour accéder à l'organisation.", @@ -1887,7 +2085,7 @@ "securityPolicyChangeWarningText": "Cela affectera tous les utilisateurs de l'organisation", "authPageErrorUpdateMessage": "Une erreur s'est produite lors de la mise à jour de la page d\u000027authentification", "authPageErrorUpdate": "Impossible de mettre à jour la page d'authentification", - "authPageUpdated": "Page d\u000027authentification mise à jour avec succès", + "authPageDomainUpdated": "Domaine de la page d'authentification mis à jour avec succès", "healthCheckNotAvailable": "Locale", "rewritePath": "Réécrire le chemin", "rewritePathDescription": "Réécrivez éventuellement le chemin avant de le transmettre à la cible.", @@ -1915,8 +2113,15 @@ "beta": "Bêta", "manageUserDevices": "Périphériques utilisateur", "manageUserDevicesDescription": "Voir et gérer les appareils que les utilisateurs utilisent pour se connecter en privé aux ressources", + "downloadClientBannerTitle": "Télécharger le client Pangolin", + "downloadClientBannerDescription": "Téléchargez le client Pangolin pour votre système afin de vous connecter au réseau Pangolin et accéder aux ressources de manière privée.", "manageMachineClients": "Gérer les clients de la machine", "manageMachineClientsDescription": "Créer et gérer des clients que les serveurs et les systèmes utilisent pour se connecter en privé aux ressources", + "machineClientsBannerTitle": "Serveurs & Systèmes automatisés", + "machineClientsBannerDescription": "Les clients de machine sont conçus pour les serveurs et les systèmes automatisés qui ne sont pas associés à un utilisateur spécifique. Ils s'authentifient avec un identifiant et une clé secrète, et peuvent être exécutés avec Pangolin CLI, Olm CLI ou Olm en tant que conteneur.", + "machineClientsBannerPangolinCLI": "Pangolin CLI", + "machineClientsBannerOlmCLI": "Olm CLI", + "machineClientsBannerOlmContainer": "Conteneur Olm", "clientsTableUserClients": "Utilisateur", "clientsTableMachineClients": "Machine", "licenseTableValidUntil": "Valable jusqu'au", @@ -2015,6 +2220,32 @@ } } }, + "newPricingLicenseForm": { + "title": "Obtenir une licence", + "description": "Choisissez un plan et dites-nous comment vous comptez utiliser Pangolin.", + "chooseTier": "Choisissez votre forfait", + "viewPricingLink": "Voir les prix, les fonctionnalités et les limites", + "tiers": { + "starter": { + "title": "Démarrage", + "description": "Fonctionnalités d'entreprise, 25 utilisateurs, 25 sites et un support communautaire." + }, + "scale": { + "title": "Échelle", + "description": "Fonctionnalités d'entreprise, 50 utilisateurs, 50 sites et une prise en charge prioritaire." + } + }, + "personalUseOnly": "Utilisation personnelle uniquement (licence gratuite — sans checkout)", + "buttons": { + "continueToCheckout": "Continuer vers le paiement" + }, + "toasts": { + "checkoutError": { + "title": "Erreur de paiement", + "description": "Impossible de commencer la commande. Veuillez réessayer." + } + } + }, "priority": "Priorité", "priorityDescription": "Les routes de haute priorité sont évaluées en premier. La priorité = 100 signifie l'ordre automatique (décision du système). Utilisez un autre nombre pour imposer la priorité manuelle.", "instanceName": "Nom de l'instance", @@ -2060,13 +2291,15 @@ "request": "Demander", "requests": "Requêtes", "logs": "Journaux", - "logsSettingsDescription": "Surveiller les logs collectés à partir de cette organisation", + "logsSettingsDescription": "Surveiller les journaux collectés de cette organisation", "searchLogs": "Rechercher dans les journaux...", "action": "Action", "actor": "Acteur", "timestamp": "Horodatage", "accessLogs": "Journaux d'accès", "exportCsv": "Exporter CSV", + "exportError": "Erreur inconnue lors de l'exportation du CSV", + "exportCsvTooltip": "Dans la plage de temps", "actorId": "ID de l'acteur", "allowedByRule": "Autorisé par la règle", "allowedNoAuth": "Aucune authentification autorisée", @@ -2111,7 +2344,8 @@ "logRetentionEndOfFollowingYear": "Fin de l'année suivante", "actionLogsDescription": "Voir l'historique des actions effectuées dans cette organisation", "accessLogsDescription": "Voir les demandes d'authentification d'accès aux ressources de cette organisation", - "licenseRequiredToUse": "Une licence Entreprise est nécessaire pour utiliser cette fonctionnalité.", + "licenseRequiredToUse": "Une licence Enterprise Edition ou Pangolin Cloud est requise pour utiliser cette fonctionnalité. Réservez une démonstration ou une évaluation de POC.", + "ossEnterpriseEditionRequired": "La version Enterprise Edition est requise pour utiliser cette fonctionnalité. Cette fonctionnalité est également disponible dans Pangolin Cloud. Réservez une démo ou un essai POC.", "certResolver": "Résolveur de certificat", "certResolverDescription": "Sélectionnez le solveur de certificat à utiliser pour cette ressource.", "selectCertResolver": "Sélectionnez le résolveur de certificat", @@ -2120,7 +2354,7 @@ "unverified": "Non vérifié", "domainSetting": "Paramètres de domaine", "domainSettingDescription": "Configurer les paramètres du domaine", - "preferWildcardCertDescription": "Tentative de génération d'un certificat générique (nécessite un résolveur de certificat correctement configuré).", + "preferWildcardCertDescription": "Tenter de générer un certificat wildcard (requiert un résolveur de certificat correctement configuré).", "recordName": "Nom de l'enregistrement", "auto": "Automatique", "TTL": "TTL", @@ -2172,6 +2406,8 @@ "deviceCodeInvalidFormat": "Le code doit contenir 9 caractères (par exemple, A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Code invalide ou expiré", "deviceCodeVerifyFailed": "Impossible de vérifier le code de l'appareil", + "deviceCodeValidating": "Validation du code de l'appareil...", + "deviceCodeVerifying": "Vérification de l'autorisation de l'appareil...", "signedInAs": "Connecté en tant que", "deviceCodeEnterPrompt": "Entrez le code affiché sur l'appareil", "continue": "Continuer", @@ -2184,7 +2420,7 @@ "deviceOrganizationsAccess": "Accès à toutes les organisations auxquelles votre compte a accès", "deviceAuthorize": "Autoriser {applicationName}", "deviceConnected": "Appareil connecté !", - "deviceAuthorizedMessage": "L'appareil est autorisé à accéder à votre compte.", + "deviceAuthorizedMessage": "L'appareil est autorisé à accéder à votre compte. Veuillez retourner à l'application client.", "pangolinCloud": "Nuage de Pangolin", "viewDevices": "Voir les appareils", "viewDevicesDescription": "Gérer vos appareils connectés", @@ -2246,6 +2482,7 @@ "identifier": "Identifiant", "deviceLoginUseDifferentAccount": "Pas vous ? Utilisez un autre compte.", "deviceLoginDeviceRequestingAccessToAccount": "Un appareil demande l'accès à ce compte.", + "loginSelectAuthenticationMethod": "Sélectionnez une méthode d'authentification pour continuer.", "noData": "Aucune donnée", "machineClients": "Clients Machines", "install": "Installer", @@ -2255,6 +2492,8 @@ "setupFailedToFetchSubnet": "Impossible de récupérer le sous-réseau par défaut", "setupSubnetAdvanced": "Sous-réseau (Avancé)", "setupSubnetDescription": "Le sous-réseau du réseau interne de cette organisation.", + "setupUtilitySubnet": "Sous-réseau utilitaire (Avancé)", + "setupUtilitySubnetDescription": "Le sous-réseau pour les adresses alias de cette organisation et le serveur DNS.", "siteRegenerateAndDisconnect": "Régénérer et déconnecter", "siteRegenerateAndDisconnectConfirmation": "Êtes-vous sûr de vouloir régénérer les identifiants et déconnecter ce site ?", "siteRegenerateAndDisconnectWarning": "Cela va régénérer les identifiants et déconnecter immédiatement le site. Le site devra être redémarré avec les nouveaux identifiants.", @@ -2270,5 +2509,179 @@ "remoteExitNodeRegenerateAndDisconnectWarning": "Cela va régénérer les identifiants et déconnecter immédiatement le noeud de sortie distant. Le noeud de sortie distant devra être redémarré avec les nouveaux identifiants.", "remoteExitNodeRegenerateCredentialsConfirmation": "Êtes-vous sûr de vouloir régénérer les informations d'identification pour ce noeud de sortie distant ?", "remoteExitNodeRegenerateCredentialsWarning": "Cela va régénérer les identifiants. Le noeud de sortie distant restera connecté jusqu'à ce que vous le redémarriez manuellement et utilisez les nouveaux identifiants.", - "agent": "Agent" + "agent": "Agent", + "personalUseOnly": "Pour usage personnel uniquement", + "loginPageLicenseWatermark": "Cette instance est sous licence pour un usage personnel uniquement.", + "instanceIsUnlicensed": "Cette instance n'est pas sous licence.", + "portRestrictions": "Restrictions de port", + "allPorts": "Tous", + "custom": "Personnalisé", + "allPortsAllowed": "Tous les ports autorisés", + "allPortsBlocked": "Tous les ports bloqués", + "tcpPortsDescription": "Indiquez les ports TCP autorisés pour cette ressource. Utilisez '*' pour tous les ports, laissez vide pour tout bloquer, ou entrez une liste de ports et de plages séparés par des virgules (par exemple, 80,443,8000-9000).", + "udpPortsDescription": "Indiquez les ports UDP autorisés pour cette ressource. Utilisez '*' pour tous les ports, laissez vide pour tout bloquer, ou entrez une liste de ports et de plages séparés par des virgules (par exemple, 53,123,500-600).", + "organizationLoginPageTitle": "Page de connexion de l'organisation", + "organizationLoginPageDescription": "Personnalisez la page de connexion pour cette organisation", + "resourceLoginPageTitle": "Page de connexion de la ressource", + "resourceLoginPageDescription": "Personnalisez la page de connexion pour les ressources individuelles", + "enterConfirmation": "Entrez la confirmation", + "blueprintViewDetails": "Détails", + "defaultIdentityProvider": "Fournisseur d'identité par défaut", + "defaultIdentityProviderDescription": "Lorsqu'un fournisseur d'identité par défaut est sélectionné, l'utilisateur sera automatiquement redirigé vers le fournisseur pour authentification.", + "editInternalResourceDialogNetworkSettings": "Paramètres réseau", + "editInternalResourceDialogAccessPolicy": "Politique d'accès", + "editInternalResourceDialogAddRoles": "Ajouter des rôles", + "editInternalResourceDialogAddUsers": "Ajouter des utilisateurs", + "editInternalResourceDialogAddClients": "Ajouter des clients", + "editInternalResourceDialogDestinationLabel": "Destination", + "editInternalResourceDialogDestinationDescription": "Indiquez l'adresse de destination pour la ressource interne. Cela peut être un nom d'hôte, une adresse IP ou une plage CIDR selon le mode sélectionné. Définissez éventuellement un alias DNS interne pour une identification plus facile.", + "editInternalResourceDialogPortRestrictionsDescription": "Restreindre l'accès à des ports TCP/UDP spécifiques ou autoriser/bloquer tous les ports.", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "Contrôle d'accès", + "editInternalResourceDialogAccessControlDescription": "Contrôlez quels rôles, utilisateurs et clients de machine ont accès à cette ressource lorsqu'ils sont connectés. Les administrateurs ont toujours accès.", + "editInternalResourceDialogPortRangeValidationError": "La plage de ports doit être \"*\" pour tous les ports, ou une liste de ports et de plages séparés par des virgules (par exemple, \"80,443,8000-9000\"). Les ports doivent être compris entre 1 et 65535.", + "internalResourceAuthDaemonStrategy": "Emplacement du démon d'authentification SSH", + "internalResourceAuthDaemonStrategyDescription": "Choisissez où le démon d'authentification SSH s'exécute : sur le site (Newt) ou sur un hôte distant.", + "internalResourceAuthDaemonDescription": "Le démon d'authentification SSH gère la signature des clés SSH et l'authentification PAM pour cette ressource. Choisissez s'il fonctionne sur le site (Newt) ou sur un hôte distant séparé. Voir la documentation pour plus d'informations.", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "Choisir une stratégie", + "internalResourceAuthDaemonStrategyLabel": "Localisation", + "internalResourceAuthDaemonSite": "Sur le site", + "internalResourceAuthDaemonSiteDescription": "Le démon Auth fonctionne sur le site (Newt).", + "internalResourceAuthDaemonRemote": "Hôte distant", + "internalResourceAuthDaemonRemoteDescription": "Le démon Auth fonctionne sur un hôte qui n'est pas le site.", + "internalResourceAuthDaemonPort": "Port du démon (optionnel)", + "orgAuthWhatsThis": "Où puis-je trouver mon identifiant d'organisation ?", + "learnMore": "En savoir plus", + "backToHome": "Retour à l'accueil", + "needToSignInToOrg": "Besoin d'utiliser le fournisseur d'identité de votre organisation ?", + "maintenanceMode": "Mode de maintenance", + "maintenanceModeDescription": "Afficher une page de maintenance aux visiteurs", + "maintenanceModeType": "Type de mode de maintenance", + "showMaintenancePage": "Afficher une page de maintenance aux visiteurs", + "enableMaintenanceMode": "Activer le mode de maintenance", + "automatic": "Automatique", + "automaticModeDescription": "Afficher la page de maintenance uniquement lorsque toutes les cibles backend sont en panne ou dégradées. Votre ressource continue à fonctionner normalement tant qu'au moins une cible est en bonne santé.", + "forced": "Forcé", + "forcedModeDescription": "Toujours afficher la page de maintenance indépendamment de l'état des backend. Utilisez ceci pour une maintenance planifiée lorsque vous souhaitez empêcher tout accès.", + "warning:": "Attention :", + "forcedeModeWarning": "Tout le trafic sera dirigé vers la page de maintenance. Vos ressources backend ne recevront aucune demande.", + "pageTitle": "Titre de la page", + "pageTitleDescription": "Le titre principal affiché sur la page de maintenance", + "maintenancePageMessage": "Message de maintenance", + "maintenancePageMessagePlaceholder": "Nous serons bientôt de retour ! Notre site est actuellement en maintenance planifiée.", + "maintenancePageMessageDescription": "Message détaillé expliquant la maintenance", + "maintenancePageTimeTitle": "Temps d'achèvement estimé (facultatif)", + "maintenanceTime": "par exemple, 2 heures, le 1er nov. à 17:00", + "maintenanceEstimatedTimeDescription": "Quand vous attendez que la maintenance soit terminée", + "editDomain": "Modifier le domaine", + "editDomainDescription": "Sélectionnez un domaine pour votre ressource", + "maintenanceModeDisabledTooltip": "Cette fonctionnalité nécessite une licence valide pour être activée.", + "maintenanceScreenTitle": "Service temporairement indisponible", + "maintenanceScreenMessage": "Nous rencontrons actuellement des difficultés techniques. Veuillez vérifier ultérieurement.", + "maintenanceScreenEstimatedCompletion": "Achèvement estimé :", + "createInternalResourceDialogDestinationRequired": "La destination est requise", + "available": "Disponible", + "archived": "Archivé", + "noArchivedDevices": "Aucun périphérique archivé trouvé", + "deviceArchived": "Appareil archivé", + "deviceArchivedDescription": "L'appareil a été archivé avec succès.", + "errorArchivingDevice": "Erreur lors de l'archivage du périphérique", + "failedToArchiveDevice": "Impossible d'archiver l'appareil", + "deviceQuestionArchive": "Êtes-vous sûr de vouloir archiver cet appareil ?", + "deviceMessageArchive": "Le périphérique sera archivé et retiré de la liste des périphériques actifs.", + "deviceArchiveConfirm": "Dispositif d'archivage", + "archiveDevice": "Dispositif d'archivage", + "archive": "Archive", + "deviceUnarchived": "Appareil désarchivé", + "deviceUnarchivedDescription": "L'appareil a été désarchivé avec succès.", + "errorUnarchivingDevice": "Erreur lors de la désarchivage du périphérique", + "failedToUnarchiveDevice": "Échec de la désarchivage de l'appareil", + "unarchive": "Désarchiver", + "archiveClient": "Archiver le client", + "archiveClientQuestion": "Êtes-vous sûr de vouloir archiver ce client?", + "archiveClientMessage": "Le client sera archivé et retiré de votre liste de clients actifs.", + "archiveClientConfirm": "Archiver le client", + "blockClient": "Bloquer le client", + "blockClientQuestion": "Êtes-vous sûr de vouloir bloquer ce client?", + "blockClientMessage": "L'appareil sera forcé de se déconnecter si vous êtes actuellement connecté. Vous pourrez débloquer l'appareil plus tard.", + "blockClientConfirm": "Bloquer le client", + "active": "Actif", + "usernameOrEmail": "Nom d'utilisateur ou email", + "selectYourOrganization": "Sélectionnez votre organisation", + "signInTo": "Se connecter à", + "signInWithPassword": "Continuer avec le mot de passe", + "noAuthMethodsAvailable": "Aucune méthode d'authentification disponible pour cette organisation.", + "enterPassword": "Entrez votre mot de passe", + "enterMfaCode": "Entrez le code de votre application d'authentification", + "securityKeyRequired": "Veuillez utiliser votre clé de sécurité pour vous connecter.", + "needToUseAnotherAccount": "Besoin d'un autre compte ?", + "loginLegalDisclaimer": "En cliquant sur les boutons ci-dessous, vous reconnaissez avoir lu, compris et accepté les Conditions d'utilisation et la Politique de confidentialité.", + "termsOfService": "Conditions d'utilisation", + "privacyPolicy": "Politique de confidentialité", + "userNotFoundWithUsername": "Aucun utilisateur trouvé avec ce nom d'utilisateur.", + "verify": "Vérifier", + "signIn": "Se connecter", + "forgotPassword": "Mot de passe oublié ?", + "orgSignInTip": "Si vous vous êtes déjà connecté, vous pouvez entrer votre nom d'utilisateur ou votre e-mail ci-dessus pour vous authentifier auprès du fournisseur d'identité de votre organisation. C'est plus facile !", + "continueAnyway": "Continuer quand même", + "dontShowAgain": "Ne plus afficher", + "orgSignInNotice": "Le saviez-vous ?", + "signupOrgNotice": "Vous essayez de vous connecter ?", + "signupOrgTip": "Essayez-vous de vous connecter par l'intermédiaire du fournisseur d'identité de votre organisme?", + "signupOrgLink": "Connectez-vous ou inscrivez-vous avec votre organisation à la place", + "verifyEmailLogInWithDifferentAccount": "Utiliser un compte différent", + "logIn": "Se connecter", + "deviceInformation": "Informations sur l'appareil", + "deviceInformationDescription": "Informations sur l'appareil et l'agent", + "deviceSecurity": "Sécurité de l'appareil", + "deviceSecurityDescription": "Informations sur la posture de sécurité de l'appareil", + "platform": "Plateforme", + "macosVersion": "Version macOS", + "windowsVersion": "Version de Windows", + "iosVersion": "Version iOS", + "androidVersion": "Version d'Android", + "osVersion": "Version du système d'exploitation", + "kernelVersion": "Version du noyau", + "deviceModel": "Modèle de l'appareil", + "serialNumber": "Numéro de série", + "hostname": "Hostname", + "firstSeen": "Première vue", + "lastSeen": "Dernière vue", + "biometricsEnabled": "biométrique activée", + "diskEncrypted": "Disque chiffré", + "firewallEnabled": "Pare-feu activé", + "autoUpdatesEnabled": "Mises à jour automatiques activées", + "tpmAvailable": "TPM disponible", + "windowsAntivirusEnabled": "Antivirus activé", + "macosSipEnabled": "Protection contre l'intégrité du système (SIP)", + "macosGatekeeperEnabled": "Gatekeeper", + "macosFirewallStealthMode": "Mode furtif du pare-feu", + "linuxAppArmorEnabled": "Armure d'application", + "linuxSELinuxEnabled": "SELinux", + "deviceSettingsDescription": "Afficher les informations et les paramètres de l'appareil", + "devicePendingApprovalDescription": "Cet appareil est en attente d'approbation", + "deviceBlockedDescription": "Cet appareil est actuellement bloqué. Il ne pourra se connecter à aucune ressource à moins d'être débloqué.", + "unblockClient": "Débloquer le client", + "unblockClientDescription": "L'appareil a été débloqué", + "unarchiveClient": "Désarchiver le client", + "unarchiveClientDescription": "L'appareil a été désarchivé", + "block": "Bloquer", + "unblock": "Débloquer", + "deviceActions": "Actions de l'appareil", + "deviceActionsDescription": "Gérer le statut et l'accès de l'appareil", + "devicePendingApprovalBannerDescription": "Cet appareil est en attente d'approbation. Il ne sera pas en mesure de se connecter aux ressources jusqu'à ce qu'il soit approuvé.", + "connected": "Connecté", + "disconnected": "Déconnecté", + "approvalsEmptyStateTitle": "Approbations de l'appareil non activées", + "approvalsEmptyStateDescription": "Activer les autorisations de l'appareil pour les rôles qui nécessitent l'approbation de l'administrateur avant que les utilisateurs puissent connecter de nouveaux appareils.", + "approvalsEmptyStateStep1Title": "Aller aux Rôles", + "approvalsEmptyStateStep1Description": "Accédez aux paramètres de rôles de votre organisation pour configurer les autorisations de l'appareil.", + "approvalsEmptyStateStep2Title": "Activer les autorisations de l'appareil", + "approvalsEmptyStateStep2Description": "Modifier un rôle et activer l'option 'Exiger les autorisations de l'appareil'. Les utilisateurs avec ce rôle auront besoin de l'approbation de l'administrateur pour les nouveaux appareils.", + "approvalsEmptyStatePreviewDescription": "Aperçu: Lorsque cette option est activée, les demandes de périphérique en attente apparaîtront ici pour vérification", + "approvalsEmptyStateButtonText": "Gérer les rôles", + "domainErrorTitle": "Nous avons des difficultés à vérifier votre domaine" } diff --git a/messages/it-IT.json b/messages/it-IT.json index 441c96769..6891cd290 100644 --- a/messages/it-IT.json +++ b/messages/it-IT.json @@ -1,5 +1,7 @@ { "setupCreate": "Creare l'organizzazione, il sito e le risorse", + "headerAuthCompatibilityInfo": "Abilita questo per forzare una risposta 401 Unauthorized quando manca un token di autenticazione. Questo è richiesto per browser o librerie HTTP specifiche che non inviano credenziali senza una sfida del server.", + "headerAuthCompatibility": "Compatibilità estesa", "setupNewOrg": "Nuova Organizzazione", "setupCreateOrg": "Crea Organizzazione", "setupCreateResources": "Crea Risorse", @@ -16,6 +18,8 @@ "componentsMember": "Sei un membro di {count, plural, =0 {nessuna organizzazione} one {un'organizzazione} other {# organizzazioni}}.", "componentsInvalidKey": "Rilevata chiave di licenza non valida o scaduta. Segui i termini di licenza per continuare a utilizzare tutte le funzionalità.", "dismiss": "Ignora", + "subscriptionViolationMessage": "Hai superato i tuoi limiti per il tuo piano attuale. Correggi il problema rimuovendo siti, utenti o altre risorse per rimanere all'interno del tuo piano.", + "subscriptionViolationViewBilling": "Visualizza fatturazione", "componentsLicenseViolation": "Violazione della licenza: Questo server sta usando i siti {usedSites} che superano il suo limite concesso in licenza per i siti {maxSites} . Segui i termini di licenza per continuare a usare tutte le funzionalità.", "componentsSupporterMessage": "Grazie per aver supportato Pangolin come {tier}!", "inviteErrorNotValid": "Siamo spiacenti, ma sembra che l'invito che stai cercando di accedere non sia stato accettato o non sia più valido.", @@ -51,6 +55,12 @@ "siteQuestionRemove": "Sei sicuro di voler rimuovere il sito dall'organizzazione?", "siteManageSites": "Gestisci Siti", "siteDescription": "Creare e gestire siti per abilitare la connettività a reti private", + "sitesBannerTitle": "Connetti Qualsiasi Rete", + "sitesBannerDescription": "Un sito è una connessione a una rete remota che consente a Pangolin di fornire accesso alle risorse, pubbliche o private, agli utenti ovunque. Installa il connettore di rete del sito (Newt) ovunque tu possa eseguire un binario o un container per stabilire la connessione.", + "sitesBannerButtonText": "Installa Sito", + "approvalsBannerTitle": "Approva o nega l'accesso al dispositivo", + "approvalsBannerDescription": "Controlla e approva o nega le richieste di accesso al dispositivo da parte degli utenti. Quando le approvazioni del dispositivo sono richieste, gli utenti devono ottenere l'approvazione dell'amministratore prima che i loro dispositivi possano connettersi alle risorse della vostra organizzazione.", + "approvalsBannerButtonText": "Scopri di più", "siteCreate": "Crea Sito", "siteCreateDescription2": "Segui i passaggi qui sotto per creare e collegare un nuovo sito", "siteCreateDescription": "Crea un nuovo sito per iniziare a connettere le risorse", @@ -100,6 +110,7 @@ "siteTunnelDescription": "Determinare come si desidera connettersi al sito", "siteNewtCredentials": "Credenziali", "siteNewtCredentialsDescription": "Questo è come il sito si autenticerà con il server", + "remoteNodeCredentialsDescription": "Questo è come il nodo remoto si autenticherà con il server", "siteCredentialsSave": "Salva le credenziali", "siteCredentialsSaveDescription": "Potrai vederlo solo una volta. Assicurati di copiarlo in un luogo sicuro.", "siteInfo": "Informazioni Sito", @@ -146,8 +157,12 @@ "shareErrorSelectResource": "Seleziona una risorsa", "proxyResourceTitle": "Gestisci Risorse Pubbliche", "proxyResourceDescription": "Creare e gestire risorse accessibili al pubblico tramite un browser web", + "proxyResourcesBannerTitle": "Accesso Pubblico Basato sul Web", + "proxyResourcesBannerDescription": "Le risorse pubbliche sono proxy HTTPS o TCP/UDP accessibili a chiunque su Internet tramite un browser web. A differenza delle risorse private, non richiedono software lato client e possono includere politiche di accesso basate su identità e contesto.", "clientResourceTitle": "Gestisci Risorse Private", "clientResourceDescription": "Crea e gestisci risorse accessibili solo tramite un client connesso", + "privateResourcesBannerTitle": "Accesso Privato Zero-Trust", + "privateResourcesBannerDescription": "Le risorse private utilizzano la sicurezza zero-trust, assicurandosi che gli utenti e le macchine possano accedere solo alle risorse a cui hai concesso esplicitamente l'accesso. Collega i dispositivi utente o i client macchina per accedere a queste risorse tramite una rete privata virtuale sicura.", "resourcesSearch": "Cerca risorse...", "resourceAdd": "Aggiungi Risorsa", "resourceErrorDelte": "Errore nell'eliminare la risorsa", @@ -157,9 +172,10 @@ "resourceMessageRemove": "Una volta rimossa, la risorsa non sarà più accessibile. Tutti gli obiettivi associati alla risorsa saranno rimossi.", "resourceQuestionRemove": "Sei sicuro di voler rimuovere la risorsa dall'organizzazione?", "resourceHTTP": "Risorsa HTTPS", - "resourceHTTPDescription": "Richieste proxy per l'applicazione tramite HTTPS utilizzando un sottodominio o un dominio base.", + "resourceHTTPDescription": "Richieste proxy su HTTPS usando un nome di dominio completo.", "resourceRaw": "Risorsa Raw TCP/UDP", - "resourceRawDescription": "Le richieste proxy all'app tramite TCP/UDP utilizzando un numero di porta. Funziona solo quando i siti sono connessi ai nodi.", + "resourceRawDescription": "Richieste proxy su TCP/UDP grezzo utilizzando un numero di porta.", + "resourceRawDescriptionCloud": "Richiesta proxy su TCP/UDP grezzo utilizzando un numero di porta. Richiede siti per connettersi a un nodo remoto.", "resourceCreate": "Crea Risorsa", "resourceCreateDescription": "Segui i passaggi seguenti per creare una nuova risorsa", "resourceSeeAll": "Vedi Tutte Le Risorse", @@ -186,6 +202,7 @@ "protocolSelect": "Seleziona un protocollo", "resourcePortNumber": "Numero Porta", "resourcePortNumberDescription": "Il numero di porta esterna per le richieste di proxy.", + "back": "Indietro", "cancel": "Annulla", "resourceConfig": "Snippet Di Configurazione", "resourceConfigDescription": "Copia e incolla questi snippet di configurazione per configurare la risorsa TCP/UDP", @@ -231,6 +248,17 @@ "orgErrorDeleteMessage": "Si è verificato un errore durante l'eliminazione dell'organizzazione.", "orgDeleted": "Organizzazione eliminata", "orgDeletedMessage": "L'organizzazione e i suoi dati sono stati eliminati.", + "deleteAccount": "Elimina Account", + "deleteAccountDescription": "Elimina definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questo non può essere annullato.", + "deleteAccountButton": "Elimina Account", + "deleteAccountConfirmTitle": "Elimina Account", + "deleteAccountConfirmMessage": "Questo cancellerà definitivamente il tuo account, tutte le organizzazioni che possiedi e tutti i dati all'interno di tali organizzazioni. Questo non può essere annullato.", + "deleteAccountConfirmString": "elimina account", + "deleteAccountSuccess": "Account Eliminato", + "deleteAccountSuccessMessage": "Il tuo account è stato eliminato.", + "deleteAccountError": "Impossibile eliminare l'account", + "deleteAccountPreviewAccount": "Il Tuo Account", + "deleteAccountPreviewOrgs": "Organizzazioni che possiedi (e tutti i loro dati)", "orgMissing": "ID Organizzazione Mancante", "orgMissingMessage": "Impossibile rigenerare l'invito senza un ID organizzazione.", "accessUsersManage": "Gestisci Utenti", @@ -247,6 +275,8 @@ "accessRolesSearch": "Ricerca ruoli...", "accessRolesAdd": "Aggiungi Ruolo", "accessRoleDelete": "Elimina Ruolo", + "accessApprovalsManage": "Gestisci Approvazioni", + "accessApprovalsDescription": "Visualizza e gestisci le approvazioni in attesa per accedere a questa organizzazione", "description": "Descrizione", "inviteTitle": "Inviti Aperti", "inviteDescription": "Gestisci gli inviti per gli altri utenti a unirsi all'organizzazione", @@ -440,6 +470,20 @@ "selectDuration": "Seleziona durata", "selectResource": "Seleziona Risorsa", "filterByResource": "Filtra Per Risorsa", + "selectApprovalState": "Seleziona Stato Di Approvazione", + "filterByApprovalState": "Filtra Per Stato Di Approvazione", + "approvalListEmpty": "Nessuna approvazione", + "approvalState": "Stato Di Approvazione", + "approvalLoadMore": "Carica altro", + "loadingApprovals": "Caricamento Approvazioni", + "approve": "Approva", + "approved": "Approvato", + "denied": "Negato", + "deniedApproval": "Omologazione Negata", + "all": "Tutti", + "deny": "Nega", + "viewDetails": "Visualizza Dettagli", + "requestingNewDeviceApproval": "ha richiesto un nuovo dispositivo", "resetFilters": "Ripristina Filtri", "totalBlocked": "Richieste Bloccate Da Pangolino", "totalRequests": "Totale Richieste", @@ -607,6 +651,7 @@ "resourcesErrorUpdate": "Impossibile attivare/disattivare la risorsa", "resourcesErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento della risorsa", "access": "Accesso", + "accessControl": "Controllo Accessi", "shareLink": "Link di Condivisione {resource}", "resourceSelect": "Seleziona risorsa", "shareLinks": "Link di Condivisione", @@ -687,7 +732,7 @@ "resourceRoleDescription": "Gli amministratori possono sempre accedere a questa risorsa.", "resourceUsersRoles": "Controlli di Accesso", "resourceUsersRolesDescription": "Configura quali utenti e ruoli possono visitare questa risorsa", - "resourceUsersRolesSubmit": "Salva Utenti e Ruoli", + "resourceUsersRolesSubmit": "Salva Controlli di Accesso", "resourceWhitelistSave": "Salvato con successo", "resourceWhitelistSaveDescription": "Le impostazioni della lista autorizzazioni sono state salvate", "ssoUse": "Usa SSO della Piattaforma", @@ -719,22 +764,35 @@ "countries": "Paesi", "accessRoleCreate": "Crea Ruolo", "accessRoleCreateDescription": "Crea un nuovo ruolo per raggruppare gli utenti e gestire i loro permessi.", + "accessRoleEdit": "Modifica Ruolo", + "accessRoleEditDescription": "Modifica informazioni sul ruolo.", "accessRoleCreateSubmit": "Crea Ruolo", "accessRoleCreated": "Ruolo creato", "accessRoleCreatedDescription": "Il ruolo è stato creato con successo.", "accessRoleErrorCreate": "Impossibile creare il ruolo", "accessRoleErrorCreateDescription": "Si è verificato un errore durante la creazione del ruolo.", + "accessRoleUpdateSubmit": "Aggiorna Ruolo", + "accessRoleUpdated": "Ruolo aggiornato", + "accessRoleUpdatedDescription": "Il ruolo è stato aggiornato con successo.", + "accessApprovalUpdated": "Approvazione trattata", + "accessApprovalApprovedDescription": "Impostare la decisione di richiesta di approvazione da approvare.", + "accessApprovalDeniedDescription": "Imposta la decisione di richiesta di approvazione negata.", + "accessRoleErrorUpdate": "Impossibile aggiornare il ruolo", + "accessRoleErrorUpdateDescription": "Si è verificato un errore nell'aggiornamento del ruolo.", + "accessApprovalErrorUpdate": "Impossibile elaborare l'approvazione", + "accessApprovalErrorUpdateDescription": "Si è verificato un errore durante l'elaborazione dell'approvazione.", "accessRoleErrorNewRequired": "Nuovo ruolo richiesto", "accessRoleErrorRemove": "Impossibile rimuovere il ruolo", "accessRoleErrorRemoveDescription": "Si è verificato un errore durante la rimozione del ruolo.", "accessRoleName": "Nome Del Ruolo", - "accessRoleQuestionRemove": "Stai per eliminare il ruolo {name}. Non puoi annullare questa azione.", + "accessRoleQuestionRemove": "Stai per eliminare il ruolo `{name}`. Non puoi annullare questa azione.", "accessRoleRemove": "Rimuovi Ruolo", "accessRoleRemoveDescription": "Rimuovi un ruolo dall'organizzazione", "accessRoleRemoveSubmit": "Rimuovi Ruolo", "accessRoleRemoved": "Ruolo rimosso", "accessRoleRemovedDescription": "Il ruolo è stato rimosso con successo.", "accessRoleRequiredRemove": "Prima di eliminare questo ruolo, seleziona un nuovo ruolo a cui trasferire i membri esistenti.", + "network": "Rete", "manage": "Gestisci", "sitesNotFound": "Nessun sito trovato.", "pangolinServerAdmin": "Server Admin - Pangolina", @@ -750,6 +808,9 @@ "sitestCountIncrease": "Aumenta conteggio siti", "idpManage": "Gestisci Provider di Identità", "idpManageDescription": "Visualizza e gestisci i provider di identità nel sistema", + "idpGlobalModeBanner": "I provider di identità (IdP) per organizzazione sono disabilitati su questo server. Sta utilizzando IdP globali (condivisi in tutte le organizzazioni). Gestisci IdP globali nel pannello di amministrazione . Per abilitare IdP per organizzazione, modificare la configurazione del server e impostare la modalità IdP su org. Vedere i documenti. Se si desidera continuare a utilizzare IdP globali e far sparire questo dalle impostazioni dell'organizzazione, impostare esplicitamente la modalità globale nella configurazione.", + "idpGlobalModeBannerUpgradeRequired": "I provider di identità (IdP) per organizzazione sono disabilitati su questo server. Utilizza IdP globali (condivisi tra tutte le organizzazioni). Gestisci gli IdP globali nel pannello di amministrazione . Per utilizzare i provider di identità per organizzazione, è necessario aggiornare all'edizione Enterprise.", + "idpGlobalModeBannerLicenseRequired": "I provider di identità (IdP) per organizzazione sono disabilitati su questo server. Utilizza IdP globali (condivisi tra tutte le organizzazioni). Gestisci IdP globali nel pannello di amministrazione . Per utilizzare provider di identità per organizzazione, è richiesta una licenza Enterprise.", "idpDeletedDescription": "Provider di identità eliminato con successo", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Sei sicuro di voler eliminare definitivamente il provider di identità?", @@ -840,6 +901,7 @@ "orgPolicyConfig": "Configura l'accesso per un'organizzazione", "idpUpdatedDescription": "Provider di identità aggiornato con successo", "redirectUrl": "URL di Reindirizzamento", + "orgIdpRedirectUrls": "Reindirizza URL", "redirectUrlAbout": "Informazioni sull'URL di Reindirizzamento", "redirectUrlAboutDescription": "Questo è l'URL a cui gli utenti saranno reindirizzati dopo l'autenticazione. È necessario configurare questo URL nelle impostazioni del provider di identità.", "pangolinAuth": "Autenticazione - Pangolina", @@ -863,7 +925,7 @@ "inviteAlready": "Sembra che sei stato invitato!", "inviteAlreadyDescription": "Per accettare l'invito, devi accedere o creare un account.", "signupQuestion": "Hai già un account?", - "login": "Accedi", + "login": "Log In", "resourceNotFound": "Risorsa Non Trovata", "resourceNotFoundDescription": "La risorsa che stai cercando di accedere non esiste.", "pincodeRequirementsLength": "Il PIN deve essere esattamente di 6 cifre", @@ -943,13 +1005,13 @@ "passwordExpiryDescription": "Questa organizzazione richiede di cambiare la password ogni {maxDays} giorni.", "changePasswordNow": "Cambia Password Ora", "pincodeAuth": "Codice Autenticatore", - "pincodeSubmit2": "Invia Codice", + "pincodeSubmit2": "Invia codice", "passwordResetSubmit": "Richiedi Reset", - "passwordResetAlreadyHaveCode": "Inserisci Il Codice Di Ripristino Della Password", + "passwordResetAlreadyHaveCode": "Inserisci Codice", "passwordResetSmtpRequired": "Si prega di contattare l'amministratore", "passwordResetSmtpRequiredDescription": "Per reimpostare la password è necessario un codice di reimpostazione della password. Si prega di contattare l'amministratore per assistenza.", "passwordBack": "Torna alla Password", - "loginBack": "Torna al login", + "loginBack": "Torna alla pagina di accesso principale", "signup": "Registrati", "loginStart": "Accedi per iniziare", "idpOidcTokenValidating": "Convalida token OIDC", @@ -972,12 +1034,12 @@ "pangolinSetup": "Configurazione - Pangolin", "orgNameRequired": "Il nome dell'organizzazione è obbligatorio", "orgIdRequired": "L'ID dell'organizzazione è obbligatorio", + "orgIdMaxLength": "L'ID dell'organizzazione deve contenere al massimo 32 caratteri", "orgErrorCreate": "Si è verificato un errore durante la creazione dell'organizzazione", "pageNotFound": "Pagina Non Trovata", "pageNotFoundDescription": "Oops! La pagina che stai cercando non esiste.", "overview": "Panoramica", "home": "Home", - "accessControl": "Controllo Accessi", "settings": "Impostazioni", "usersAll": "Tutti Gli Utenti", "license": "Licenza", @@ -1035,15 +1097,24 @@ "updateOrgUser": "Aggiorna Utente Org", "createOrgUser": "Crea Utente Org", "actionUpdateOrg": "Aggiorna Organizzazione", + "actionRemoveInvitation": "Rimuovi Invito", "actionUpdateUser": "Aggiorna Utente", "actionGetUser": "Ottieni Utente", "actionGetOrgUser": "Ottieni Utente Organizzazione", "actionListOrgDomains": "Elenca Domini Organizzazione", + "actionGetDomain": "Ottieni Dominio", + "actionCreateOrgDomain": "Crea Dominio", + "actionUpdateOrgDomain": "Aggiorna Dominio", + "actionDeleteOrgDomain": "Elimina Dominio", + "actionGetDNSRecords": "Ottieni Record DNS", + "actionRestartOrgDomain": "Riavvia Dominio", "actionCreateSite": "Crea Sito", "actionDeleteSite": "Elimina Sito", "actionGetSite": "Ottieni Sito", "actionListSites": "Elenca Siti", "actionApplyBlueprint": "Applica Progetto", + "actionListBlueprints": "Elenco Blueprints", + "actionGetBlueprint": "Ottieni Blueprint", "setupToken": "Configura Token", "setupTokenDescription": "Inserisci il token di configurazione dalla console del server.", "setupTokenRequired": "Il token di configurazione è richiesto", @@ -1077,6 +1148,7 @@ "actionRemoveUser": "Rimuovi Utente", "actionListUsers": "Elenca Utenti", "actionAddUserRole": "Aggiungi Ruolo Utente", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Genera Token di Accesso", "actionDeleteAccessToken": "Elimina Token di Accesso", "actionListAccessTokens": "Elenca Token di Accesso", @@ -1104,6 +1176,10 @@ "actionUpdateIdpOrg": "Aggiorna Org IDP", "actionCreateClient": "Crea Client", "actionDeleteClient": "Elimina Client", + "actionArchiveClient": "Archivia Client", + "actionUnarchiveClient": "Annulla Archiviazione Client", + "actionBlockClient": "Blocca Client", + "actionUnblockClient": "Sblocca Client", "actionUpdateClient": "Aggiorna Client", "actionListClients": "Elenco Clienti", "actionGetClient": "Ottieni Client", @@ -1117,17 +1193,18 @@ "actionViewLogs": "Visualizza Log", "noneSelected": "Nessuna selezione", "orgNotFound2": "Nessuna organizzazione trovata.", - "searchProgress": "Ricerca...", + "searchPlaceholder": "Cerca...", + "emptySearchOptions": "Nessuna opzione trovata", "create": "Crea", "orgs": "Organizzazioni", - "loginError": "Si è verificato un errore durante l'accesso", - "loginRequiredForDevice": "È richiesto il login per autenticare il dispositivo.", + "loginError": "Si è verificato un errore imprevisto. Riprova.", + "loginRequiredForDevice": "Il login è richiesto per il tuo dispositivo.", "passwordForgot": "Password dimenticata?", "otpAuth": "Autenticazione a Due Fattori", "otpAuthDescription": "Inserisci il codice dalla tua app di autenticazione o uno dei tuoi codici di backup monouso.", "otpAuthSubmit": "Invia Codice", "idpContinue": "O continua con", - "otpAuthBack": "Torna al Login", + "otpAuthBack": "Torna alla Password", "navbar": "Menu di Navigazione", "navbarDescription": "Menu di navigazione principale dell'applicazione", "navbarDocsLink": "Documentazione", @@ -1175,11 +1252,13 @@ "sidebarOverview": "Panoramica", "sidebarHome": "Home", "sidebarSites": "Siti", + "sidebarApprovals": "Richieste Di Approvazione", "sidebarResources": "Risorse", "sidebarProxyResources": "Pubblico", "sidebarClientResources": "Privato", "sidebarAccessControl": "Controllo Accesso", "sidebarLogsAndAnalytics": "Registri E Analisi", + "sidebarTeam": "Squadra", "sidebarUsers": "Utenti", "sidebarAdmin": "Amministratore", "sidebarInvitations": "Inviti", @@ -1191,13 +1270,15 @@ "sidebarIdentityProviders": "Fornitori Di Identità", "sidebarLicense": "Licenza", "sidebarClients": "Client", - "sidebarUserDevices": "Utenti", + "sidebarUserDevices": "Dispositivi Utente", "sidebarMachineClients": "Macchine", "sidebarDomains": "Domini", - "sidebarGeneral": "Generale", + "sidebarGeneral": "Gestisci", "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Progetti", "sidebarOrganization": "Organizzazione", + "sidebarManagement": "Gestione", + "sidebarBillingAndLicenses": "Fatturazione E Licenze", "sidebarLogsAnalytics": "Analisi", "blueprints": "Progetti", "blueprintsDescription": "Applica le configurazioni dichiarative e visualizza le partite precedenti", @@ -1219,7 +1300,6 @@ "parsedContents": "Sommario Analizzato (Solo Lettura)", "enableDockerSocket": "Abilita Progetto Docker", "enableDockerSocketDescription": "Abilita la raschiatura dell'etichetta Docker Socket per le etichette dei progetti. Il percorso del socket deve essere fornito a Newt.", - "enableDockerSocketLink": "Scopri di più", "viewDockerContainers": "Visualizza Contenitori Docker", "containersIn": "Contenitori in {siteName}", "selectContainerDescription": "Seleziona qualsiasi contenitore da usare come hostname per questo obiettivo. Fai clic su una porta per usare una porta.", @@ -1263,6 +1343,7 @@ "setupErrorCreateAdmin": "Si è verificato un errore durante la creazione dell'account amministratore del server.", "certificateStatus": "Stato del Certificato", "loading": "Caricamento", + "loadingAnalytics": "Caricamento Delle Analisi", "restart": "Riavvia", "domains": "Domini", "domainsDescription": "Creare e gestire i domini disponibili nell'organizzazione", @@ -1290,6 +1371,7 @@ "refreshError": "Impossibile aggiornare i dati", "verified": "Verificato", "pending": "In attesa", + "pendingApproval": "Approvazione In Attesa", "sidebarBilling": "Fatturazione", "billing": "Fatturazione", "orgBillingDescription": "Gestisci le informazioni di fatturazione e gli abbonamenti", @@ -1308,8 +1390,11 @@ "accountSetupSuccess": "Configurazione dell'account completata! Benvenuto su Pangolin!", "documentation": "Documentazione", "saveAllSettings": "Salva Tutte le Impostazioni", + "saveResourceTargets": "Salva Target", + "saveResourceHttp": "Salva Impostazioni Proxy", + "saveProxyProtocol": "Salva impostazioni protocollo proxy", "settingsUpdated": "Impostazioni aggiornate", - "settingsUpdatedDescription": "Tutte le impostazioni sono state aggiornate con successo", + "settingsUpdatedDescription": "Impostazioni aggiornate con successo", "settingsErrorUpdate": "Impossibile aggiornare le impostazioni", "settingsErrorUpdateDescription": "Si è verificato un errore durante l'aggiornamento delle impostazioni", "sidebarCollapse": "Comprimi", @@ -1342,6 +1427,7 @@ "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Mostra Altro", "regionSelectorTitle": "Seleziona regione", + "domainPickerRemoteExitNodeWarning": "I domini forniti non sono supportati quando i siti si connettono a nodi di uscita remoti. Affinché le risorse siano disponibili su nodi remoti, utilizza invece un dominio personalizzato.", "regionSelectorInfo": "Selezionare una regione ci aiuta a fornire migliori performance per la tua posizione. Non devi necessariamente essere nella stessa regione del tuo server.", "regionSelectorPlaceholder": "Scegli una regione", "regionSelectorComingSoon": "Prossimamente", @@ -1351,10 +1437,11 @@ "billingUsageLimitsOverview": "Panoramica dei Limiti di Utilizzo", "billingMonitorUsage": "Monitora il tuo utilizzo rispetto ai limiti configurati. Se hai bisogno di aumentare i limiti, contattaci all'indirizzo support@pangolin.net.", "billingDataUsage": "Utilizzo dei Dati", - "billingOnlineTime": "Tempo Online del Sito", - "billingUsers": "Utenti Attivi", - "billingDomains": "Domini Attivi", - "billingRemoteExitNodes": "Nodi Self-hosted Attivi", + "billingSites": "Siti", + "billingUsers": "Utenti", + "billingDomains": "Domini", + "billingOrganizations": "Organi", + "billingRemoteExitNodes": "Nodi Remoti", "billingNoLimitConfigured": "Nessun limite configurato", "billingEstimatedPeriod": "Periodo di Fatturazione Stimato", "billingIncludedUsage": "Utilizzo Incluso", @@ -1379,15 +1466,24 @@ "billingFailedToGetPortalUrl": "Errore durante l'ottenimento dell'URL del portale", "billingPortalError": "Errore del Portale", "billingDataUsageInfo": "Hai addebitato tutti i dati trasferiti attraverso i tunnel sicuri quando sei connesso al cloud. Questo include sia il traffico in entrata e in uscita attraverso tutti i siti. Quando si raggiunge il limite, i siti si disconnetteranno fino a quando non si aggiorna il piano o si riduce l'utilizzo. I dati non vengono caricati quando si utilizzano nodi.", - "billingOnlineTimeInfo": "Ti viene addebitato in base al tempo in cui i tuoi siti rimangono connessi al cloud. Ad esempio, 44,640 minuti è uguale a un sito in esecuzione 24/7 per un mese intero. Quando raggiungi il tuo limite, i tuoi siti si disconnetteranno fino a quando non aggiorni il tuo piano o riduci l'utilizzo. Il tempo non viene caricato quando si usano i nodi.", - "billingUsersInfo": "Sei addebitato per ogni utente nell'organizzazione. La fatturazione viene calcolata quotidianamente in base al numero di account utente attivi nel tuo org.", - "billingDomainInfo": "Sei addebitato per ogni dominio nell'organizzazione. La fatturazione viene calcolata quotidianamente in base al numero di account di dominio attivi nel tuo org.", - "billingRemoteExitNodesInfo": "Sei addebitato per ogni nodo gestito nell'organizzazione. La fatturazione viene calcolata quotidianamente in base al numero di nodi gestiti attivi nel tuo org.", + "billingSInfo": "Quanti siti puoi usare", + "billingUsersInfo": "Quanti utenti puoi usare", + "billingDomainInfo": "Quanti domini puoi usare", + "billingRemoteExitNodesInfo": "Quanti nodi remoti puoi usare", + "billingLicenseKeys": "Chiavi di Licenza", + "billingLicenseKeysDescription": "Gestisci le sottoscrizioni alla chiave di licenza", + "billingLicenseSubscription": "Abbonamento Licenza", + "billingInactive": "Inattivo", + "billingLicenseItem": "Elemento Licenza", + "billingQuantity": "Quantità", + "billingTotal": "totale", + "billingModifyLicenses": "Modifica Abbonamento Licenza", "domainNotFound": "Domini Non Trovati", "domainNotFoundDescription": "Questa risorsa è disabilitata perché il dominio non esiste più nel nostro sistema. Si prega di impostare un nuovo dominio per questa risorsa.", "failed": "Fallito", "createNewOrgDescription": "Crea una nuova organizzazione", "organization": "Organizzazione", + "primary": "Principale", "port": "Porta", "securityKeyManage": "Gestisci chiavi di sicurezza", "securityKeyDescription": "Aggiungi o rimuovi chiavi di sicurezza per l'autenticazione senza password", @@ -1403,7 +1499,7 @@ "securityKeyRemoveSuccess": "Chiave di sicurezza rimossa con successo", "securityKeyRemoveError": "Errore durante la rimozione della chiave di sicurezza", "securityKeyLoadError": "Errore durante il caricamento delle chiavi di sicurezza", - "securityKeyLogin": "Continua con la chiave di sicurezza", + "securityKeyLogin": "Usa Chiave Di Sicurezza", "securityKeyAuthError": "Errore durante l'autenticazione con chiave di sicurezza", "securityKeyRecommendation": "Considera di registrare un'altra chiave di sicurezza su un dispositivo diverso per assicurarti di non rimanere bloccato fuori dal tuo account.", "registering": "Registrazione in corso...", @@ -1459,11 +1555,47 @@ "resourcePortRequired": "Numero di porta richiesto per risorse non-HTTP", "resourcePortNotAllowed": "Il numero di porta non deve essere impostato per risorse HTTP", "billingPricingCalculatorLink": "Calcolatore di Prezzi", + "billingYourPlan": "Il Tuo Piano", + "billingViewOrModifyPlan": "Visualizza o modifica il tuo piano corrente", + "billingViewPlanDetails": "Visualizza Dettagli Piano", + "billingUsageAndLimits": "Utilizzo e limiti", + "billingViewUsageAndLimits": "Visualizza i limiti del tuo piano e l'utilizzo corrente", + "billingCurrentUsage": "Utilizzo Corrente", + "billingMaximumLimits": "Limiti Massimi", + "billingRemoteNodes": "Nodi Remoti", + "billingUnlimited": "Illimitato", + "billingPaidLicenseKeys": "Chiavi Di Licenza Pagate", + "billingManageLicenseSubscription": "Gestisci il tuo abbonamento per le chiavi di licenza self-hosted a pagamento", + "billingCurrentKeys": "Tasti Attuali", + "billingModifyCurrentPlan": "Modifica Il Piano Corrente", + "billingConfirmUpgrade": "Conferma Aggiornamento", + "billingConfirmDowngrade": "Conferma Downgrade", + "billingConfirmUpgradeDescription": "Stai per aggiornare il tuo piano. Controlla i nuovi limiti e prezzi qui sotto.", + "billingConfirmDowngradeDescription": "Stai per effettuare il downgrade del tuo piano. Controlla i nuovi limiti e i prezzi qui sotto.", + "billingPlanIncludes": "Piano Include", + "billingProcessing": "Elaborazione...", + "billingConfirmUpgradeButton": "Conferma Aggiornamento", + "billingConfirmDowngradeButton": "Conferma Downgrade", + "billingLimitViolationWarning": "Utilizzo Supera I Nuovi Limiti Del Piano", + "billingLimitViolationDescription": "Il tuo utilizzo attuale supera i limiti di questo piano. Dopo il downgrading, tutte le azioni saranno disabilitate fino a ridurre l'utilizzo entro i nuovi limiti. Si prega di rivedere le caratteristiche qui sotto che sono attualmente oltre i limiti. Limiti di violazione:", + "billingFeatureLossWarning": "Avviso Di Disponibilità Caratteristica", + "billingFeatureLossDescription": "Con il downgrading, le funzioni non disponibili nel nuovo piano saranno disattivate automaticamente. Alcune impostazioni e configurazioni potrebbero andare perse. Controlla la matrice dei prezzi per capire quali funzioni non saranno più disponibili.", + "billingUsageExceedsLimit": "L'utilizzo corrente ({current}) supera il limite ({limit})", + "billingPastDueTitle": "Pagamento Scaduto", + "billingPastDueDescription": "Il pagamento è scaduto. Si prega di aggiornare il metodo di pagamento per continuare a utilizzare le funzioni del piano corrente. Se non risolto, il tuo abbonamento verrà annullato e verrai ripristinato al livello gratuito.", + "billingUnpaidTitle": "Abbonamento Non Pagato", + "billingUnpaidDescription": "Il tuo abbonamento non è pagato e sei stato restituito al livello gratuito. Per favore aggiorna il metodo di pagamento per ripristinare l'abbonamento.", + "billingIncompleteTitle": "Pagamento Incompleto", + "billingIncompleteDescription": "Il pagamento è incompleto. Si prega di completare il processo di pagamento per attivare il tuo abbonamento.", + "billingIncompleteExpiredTitle": "Pagamento Scaduto", + "billingIncompleteExpiredDescription": "Il tuo pagamento non è mai stato completato ed è scaduto. Sei stato ripristinato al livello gratuito. Si prega di iscriversi nuovamente per ripristinare l'accesso alle funzionalità a pagamento.", + "billingManageSubscription": "Gestisci il tuo abbonamento", + "billingResolvePaymentIssue": "Si prega di risolvere il problema di pagamento prima di aggiornare o declassare", "signUpTerms": { "IAgreeToThe": "Accetto i", "termsOfService": "termini di servizio", "and": "e", - "privacyPolicy": "informativa sulla privacy" + "privacyPolicy": "informativa sulla privacy." }, "signUpMarketing": { "keepMeInTheLoop": "Tienimi in loop con notizie, aggiornamenti e nuove funzionalità via e-mail." @@ -1508,6 +1640,7 @@ "addNewTarget": "Aggiungi Nuovo Target", "targetsList": "Elenco dei Target", "advancedMode": "Modalità Avanzata", + "advancedSettings": "Impostazioni Avanzate", "targetErrorDuplicateTargetFound": "Target duplicato trovato", "healthCheckHealthy": "Sano", "healthCheckUnhealthy": "Non Sano", @@ -1529,6 +1662,26 @@ "IntervalSeconds": "Intervallo Sano", "timeoutSeconds": "Timeout (sec)", "timeIsInSeconds": "Il tempo è in secondi", + "requireDeviceApproval": "Richiede Approvazioni Dispositivo", + "requireDeviceApprovalDescription": "Gli utenti con questo ruolo hanno bisogno di nuovi dispositivi approvati da un amministratore prima di poter connettersi e accedere alle risorse.", + "sshAccess": "Accesso SSH", + "roleAllowSsh": "Consenti SSH", + "roleAllowSshAllow": "Consenti", + "roleAllowSshDisallow": "Non Consentire", + "roleAllowSshDescription": "Consenti agli utenti con questo ruolo di connettersi alle risorse tramite SSH. Quando disabilitato, il ruolo non può utilizzare l'accesso SSH.", + "sshSudoMode": "Accesso Sudo", + "sshSudoModeNone": "Nessuno", + "sshSudoModeNoneDescription": "L'utente non può eseguire comandi con sudo.", + "sshSudoModeFull": "Sudo Completo", + "sshSudoModeFullDescription": "L'utente può eseguire qualsiasi comando con sudo.", + "sshSudoModeCommands": "Comandi", + "sshSudoModeCommandsDescription": "L'utente può eseguire solo i comandi specificati con sudo.", + "sshSudo": "Consenti sudo", + "sshSudoCommands": "Comandi Sudo", + "sshSudoCommandsDescription": "Elenco di comandi separati da virgole che l'utente può eseguire con sudo.", + "sshCreateHomeDir": "Crea Cartella Home", + "sshUnixGroups": "Gruppi Unix", + "sshUnixGroupsDescription": "Gruppi Unix separati da virgole per aggiungere l'utente sull'host di destinazione.", "retryAttempts": "Tentativi di Riprova", "expectedResponseCodes": "Codici di Risposta Attesi", "expectedResponseCodesDescription": "Codice di stato HTTP che indica lo stato di salute. Se lasciato vuoto, considerato sano è compreso tra 200-300.", @@ -1569,6 +1722,8 @@ "resourcesTableNoInternalResourcesFound": "Nessuna risorsa interna trovata.", "resourcesTableDestination": "Destinazione", "resourcesTableAlias": "Alias", + "resourcesTableAliasAddress": "Indirizzo Alias", + "resourcesTableAliasAddressInfo": "Questo indirizzo fa parte della subnet di utilità dell'organizzazione. È usato per risolvere i record alias usando la risoluzione DNS interna.", "resourcesTableClients": "Client", "resourcesTableAndOnlyAccessibleInternally": "e sono accessibili solo internamente quando connessi con un client.", "resourcesTableNoTargets": "Nessun obiettivo", @@ -1616,9 +1771,8 @@ "createInternalResourceDialogResourceProperties": "Proprietà della Risorsa", "createInternalResourceDialogName": "Nome", "createInternalResourceDialogSite": "Sito", - "createInternalResourceDialogSelectSite": "Seleziona sito...", - "createInternalResourceDialogSearchSites": "Cerca siti...", - "createInternalResourceDialogNoSitesFound": "Nessun sito trovato.", + "selectSite": "Seleziona sito...", + "noSitesFound": "Nessun sito trovato.", "createInternalResourceDialogProtocol": "Protocollo", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", @@ -1670,7 +1824,7 @@ "autoLoginErrorNoRedirectUrl": "Nessun URL di reindirizzamento ricevuto dal provider di identità.", "autoLoginErrorGeneratingUrl": "Impossibile generare l'URL di autenticazione.", "remoteExitNodeManageRemoteExitNodes": "Nodi Remoti", - "remoteExitNodeDescription": "Self-host uno o più nodi remoti per estendere la connettività di rete e ridurre la dipendenza dal cloud", + "remoteExitNodeDescription": "Ospita in proprio i tuoi nodi server di relay e proxy remoti", "remoteExitNodes": "Nodi", "searchRemoteExitNodes": "Cerca nodi...", "remoteExitNodeAdd": "Aggiungi Nodo", @@ -1680,20 +1834,22 @@ "remoteExitNodeConfirmDelete": "Conferma Eliminazione Nodo", "remoteExitNodeDelete": "Elimina Nodo", "sidebarRemoteExitNodes": "Nodi Remoti", + "remoteExitNodeId": "ID", + "remoteExitNodeSecretKey": "Segreto", "remoteExitNodeCreate": { - "title": "Crea Nodo", - "description": "Crea un nuovo nodo per estendere la connettività di rete", + "title": "Crea Nodo Remoto", + "description": "Crea un nuovo nodo server proxy e relay remoto ospitato in proprio", "viewAllButton": "Visualizza Tutti I Nodi", "strategy": { "title": "Strategia di Creazione", - "description": "Scegli questa opzione per configurare manualmente il nodo o generare nuove credenziali.", + "description": "Seleziona come desideri creare il nodo remoto", "adopt": { "title": "Adotta Nodo", "description": "Scegli questo se hai già le credenziali per il nodo." }, "generate": { "title": "Genera Chiavi", - "description": "Scegli questa opzione se vuoi generare nuove chiavi per il nodo" + "description": "Scegli questa opzione se vuoi generare nuove chiavi per il nodo." } }, "adopt": { @@ -1806,9 +1962,33 @@ "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Sottorete", "subnetDescription": "La sottorete per la configurazione di rete di questa organizzazione.", - "authPage": "Pagina Autenticazione", - "authPageDescription": "Configura la pagina di autenticazione per l'organizzazione", + "customDomain": "Dominio Personalizzato", + "authPage": "Pagine di Autenticazione", + "authPageDescription": "Imposta un dominio personalizzato per le pagine di autenticazione dell'organizzazione", "authPageDomain": "Dominio Pagina Auth", + "authPageBranding": "Branding Personalizzato", + "authPageBrandingDescription": "Configura il branding che appare sulle pagine di autenticazione per questa organizzazione", + "authPageBrandingUpdated": "Branding della pagina di autenticazione aggiornato con successo", + "authPageBrandingRemoved": "Branding della pagina di autenticazione rimosso con successo", + "authPageBrandingRemoveTitle": "Rimuovere Branding Pagina di Autenticazione", + "authPageBrandingQuestionRemove": "Sei sicuro di voler rimuovere il branding per le pagine di autenticazione?", + "authPageBrandingDeleteConfirm": "Conferma Eliminazione Branding", + "brandingLogoURL": "URL Logo", + "brandingLogoURLOrPath": "URL o percorso del logo", + "brandingLogoPathDescription": "Inserisci un URL o un percorso locale.", + "brandingLogoURLDescription": "Inserisci un URL accessibile al pubblico per la tua immagine del logo.", + "brandingPrimaryColor": "Colore Primario", + "brandingLogoWidth": "Larghezza (px)", + "brandingLogoHeight": "Altezza (px)", + "brandingOrgTitle": "Titolo per la Pagina di Autenticazione dell'Organizzazione", + "brandingOrgDescription": "{orgName} sarà sostituito con il nome dell'organizzazione", + "brandingOrgSubtitle": "Sottotitolo per la Pagina di Autenticazione dell'Organizzazione", + "brandingResourceTitle": "Titolo per la Pagina di Autenticazione della Risorsa", + "brandingResourceSubtitle": "Sottotitolo per la Pagina di Autenticazione della Risorsa", + "brandingResourceDescription": "{resourceName} sarà sostituito con il nome dell'organizzazione", + "saveAuthPageDomain": "Salva Dominio", + "saveAuthPageBranding": "Salva Branding", + "removeAuthPageBranding": "Rimuovi Branding", "noDomainSet": "Nessun dominio impostato", "changeDomain": "Cambia Dominio", "selectDomain": "Seleziona Dominio", @@ -1817,7 +1997,7 @@ "setAuthPageDomain": "Imposta Dominio Pagina Autenticazione", "failedToFetchCertificate": "Recupero del certificato non riuscito", "failedToRestartCertificate": "Riavvio del certificato non riuscito", - "addDomainToEnableCustomAuthPages": "Aggiungi un dominio per abilitare le pagine di autenticazione personalizzate per l'organizzazione", + "addDomainToEnableCustomAuthPages": "Gli utenti potranno accedere alla pagina di accesso dell'organizzazione e completare l'autenticazione delle risorse utilizzando questo dominio.", "selectDomainForOrgAuthPage": "Seleziona un dominio per la pagina di autenticazione dell'organizzazione", "domainPickerProvidedDomain": "Dominio Fornito", "domainPickerFreeProvidedDomain": "Dominio Fornito Gratuito", @@ -1832,11 +2012,27 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" non può essere reso valido per {domain}.", "domainPickerSubdomainSanitized": "Sottodominio igienizzato", "domainPickerSubdomainCorrected": "\"{sub}\" è stato corretto in \"{sanitized}\"", - "orgAuthSignInTitle": "Accedi all'organizzazione", + "orgAuthSignInTitle": "Accedi all'Organizzazione", "orgAuthChooseIdpDescription": "Scegli il tuo provider di identità per continuare", "orgAuthNoIdpConfigured": "Questa organizzazione non ha nessun provider di identità configurato. Puoi accedere con la tua identità Pangolin.", "orgAuthSignInWithPangolin": "Accedi con Pangolino", + "orgAuthSignInToOrg": "Accedi a un'organizzazione", + "orgAuthSelectOrgTitle": "Accesso Organizzazione", + "orgAuthSelectOrgDescription": "Inserisci l'ID dell'organizzazione per continuare", + "orgAuthOrgIdPlaceholder": "la-tua-organizzazione", + "orgAuthOrgIdHelp": "Inserisci l'identificatore univoco della tua organizzazione", + "orgAuthSelectOrgHelp": "Dopo aver inserito l'ID dell'organizzazione, verrai indirizzato alla pagina di accesso dell'organizzazione dove puoi usare SSO o le credenziali dell'organizzazione.", + "orgAuthRememberOrgId": "Ricorda questo ID organizzazione", + "orgAuthBackToSignIn": "Torna alla modalità di accesso standard", + "orgAuthNoAccount": "Non hai un account?", "subscriptionRequiredToUse": "Per utilizzare questa funzionalità è necessario un abbonamento.", + "mustUpgradeToUse": "Devi aggiornare il tuo abbonamento per utilizzare questa funzionalità.", + "subscriptionRequiredTierToUse": "Questa funzione richiede {tier} o superiore.", + "upgradeToTierToUse": "Aggiorna ad {tier} o superiore per utilizzare questa funzionalità.", + "subscriptionTierTier1": "Home", + "subscriptionTierTier2": "Squadra", + "subscriptionTierTier3": "Business", + "subscriptionTierEnterprise": "Impresa", "idpDisabled": "I provider di identità sono disabilitati.", "orgAuthPageDisabled": "La pagina di autenticazione dell'organizzazione è disabilitata.", "domainRestartedDescription": "Verifica del dominio riavviata con successo", @@ -1850,6 +2046,8 @@ "enableTwoFactorAuthentication": "Abilita autenticazione a due fattori", "completeSecuritySteps": "Passi Di Sicurezza Completa", "securitySettings": "Impostazioni Di Sicurezza", + "dangerSection": "Zona Pericolosa", + "dangerSectionDescription": "Elimina permanentemente tutti i dati associati a questa organizzazione", "securitySettingsDescription": "Configura i criteri di sicurezza per l'organizzazione", "requireTwoFactorForAllUsers": "Richiede l'autenticazione a due fattori per tutti gli utenti", "requireTwoFactorDescription": "Se abilitata, tutti gli utenti interni di questa organizzazione devono avere un'autenticazione a due fattori abilitata per accedere all'organizzazione.", @@ -1887,7 +2085,7 @@ "securityPolicyChangeWarningText": "Questo influenzerà tutti gli utenti dell'organizzazione", "authPageErrorUpdateMessage": "Si è verificato un errore durante l'aggiornamento delle impostazioni della pagina di autenticazione", "authPageErrorUpdate": "Impossibile aggiornare la pagina di autenticazione", - "authPageUpdated": "Pagina di autenticazione aggiornata con successo", + "authPageDomainUpdated": "Dominio della pagina di autenticazione aggiornato con successo", "healthCheckNotAvailable": "Locale", "rewritePath": "Riscrivi percorso", "rewritePathDescription": "Riscrivi eventualmente il percorso prima di inoltrarlo al target.", @@ -1915,8 +2113,15 @@ "beta": "Beta", "manageUserDevices": "Dispositivi Utente", "manageUserDevicesDescription": "Visualizza e gestisci i dispositivi che gli utenti utilizzano per connettersi privatamente alle risorse", + "downloadClientBannerTitle": "Scarica il Client Pangolin", + "downloadClientBannerDescription": "Scarica il client Pangolin per il tuo sistema per connetterti alla rete Pangolin e accedere alle risorse in modo privato.", "manageMachineClients": "Gestisci Client Machine", "manageMachineClientsDescription": "Creare e gestire client che server e sistemi utilizzano per connettersi privatamente alle risorse", + "machineClientsBannerTitle": "Server e Sistemi Automatizzati", + "machineClientsBannerDescription": "I client macchina sono destinati ai server e ai sistemi automatizzati che non sono associati a un utente specifico. Si autenticano con un ID e un segreto e possono funzionare con la CLI di Pangolin, la CLI di Olm o Olm come container.", + "machineClientsBannerPangolinCLI": "CLI Pangolin", + "machineClientsBannerOlmCLI": "CLI Olm", + "machineClientsBannerOlmContainer": "Container Olm", "clientsTableUserClients": "Utente", "clientsTableMachineClients": "Macchina", "licenseTableValidUntil": "Valido Fino A", @@ -2015,6 +2220,32 @@ } } }, + "newPricingLicenseForm": { + "title": "Ottieni una licenza", + "description": "Scegli un piano e ci dica come intendi usare Pangolin.", + "chooseTier": "Scegli il tuo piano", + "viewPricingLink": "Vedi prezzi, funzionalità e limiti", + "tiers": { + "starter": { + "title": "Avviatore", + "description": "Caratteristiche aziendali, 25 utenti, 25 siti e supporto alla comunità." + }, + "scale": { + "title": "Scala", + "description": "Funzionalità aziendali, 50 utenti, 50 siti e supporto prioritario." + } + }, + "personalUseOnly": "Solo uso personale (licenza gratuita — nessun checkout)", + "buttons": { + "continueToCheckout": "Continua al Checkout" + }, + "toasts": { + "checkoutError": { + "title": "Errore di pagamento", + "description": "Impossibile avviare il checkout. Per favore riprova." + } + } + }, "priority": "Priorità", "priorityDescription": "I percorsi prioritari più alti sono valutati prima. Priorità = 100 significa ordinamento automatico (decidi di sistema). Usa un altro numero per applicare la priorità manuale.", "instanceName": "Nome Istanza", @@ -2060,13 +2291,15 @@ "request": "Richiesta", "requests": "Richieste", "logs": "Registri", - "logsSettingsDescription": "Monitora i registri raccolti da questa orginizzazione", + "logsSettingsDescription": "Monitora i log raccolti da questa organizzazione", "searchLogs": "Cerca registro...", "action": "Azione", "actor": "Attore", "timestamp": "Timestamp", "accessLogs": "Log Accesso", "exportCsv": "Esporta CSV", + "exportError": "Errore sconosciuto durante l'esportazione del CSV", + "exportCsvTooltip": "All'interno dell'intervallo di tempo", "actorId": "Id Attore", "allowedByRule": "Consentito dalla regola", "allowedNoAuth": "Non Consentito Auth", @@ -2111,7 +2344,8 @@ "logRetentionEndOfFollowingYear": "Fine dell'anno successivo", "actionLogsDescription": "Visualizza una cronologia delle azioni eseguite in questa organizzazione", "accessLogsDescription": "Visualizza le richieste di autenticazione di accesso per le risorse in questa organizzazione", - "licenseRequiredToUse": "Per utilizzare questa funzione è necessaria una licenza Enterprise.", + "licenseRequiredToUse": "Per utilizzare questa funzione è necessaria una licenza Enterprise Edition o Pangolin Cloud . Prenota una demo o una prova POC.", + "ossEnterpriseEditionRequired": "L' Enterprise Edition è necessaria per utilizzare questa funzione. Questa funzione è disponibile anche in Pangolin Cloud. Prenota una demo o una prova POC.", "certResolver": "Risolutore Di Certificato", "certResolverDescription": "Selezionare il risolutore di certificati da usare per questa risorsa.", "selectCertResolver": "Seleziona Risolutore Di Certificato", @@ -2120,7 +2354,7 @@ "unverified": "Non Verificato", "domainSetting": "Impostazioni Dominio", "domainSettingDescription": "Configura le impostazioni per il dominio", - "preferWildcardCertDescription": "Tentativo di generare un certificato jolly (richiede un risolutore di certificati correttamente configurato).", + "preferWildcardCertDescription": "Tentativo di generare un certificato wildcard (richiede un resolver di certificato configurato correttamente).", "recordName": "Nome Record", "auto": "Automatico", "TTL": "TTL", @@ -2172,6 +2406,8 @@ "deviceCodeInvalidFormat": "Il codice deve contenere 9 caratteri (es. A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Codice non valido o scaduto", "deviceCodeVerifyFailed": "Impossibile verificare il codice del dispositivo", + "deviceCodeValidating": "Convalida codice dispositivo...", + "deviceCodeVerifying": "Verifica autorizzazione dispositivo...", "signedInAs": "Accesso come", "deviceCodeEnterPrompt": "Inserisci il codice visualizzato sul dispositivo", "continue": "Continua", @@ -2184,7 +2420,7 @@ "deviceOrganizationsAccess": "Accesso a tutte le organizzazioni a cui il tuo account ha accesso", "deviceAuthorize": "Autorizza {applicationName}", "deviceConnected": "Dispositivo Connesso!", - "deviceAuthorizedMessage": "Il dispositivo è autorizzato ad accedere al tuo account.", + "deviceAuthorizedMessage": "Il dispositivo è autorizzato ad accedere al tuo account. Ritorna all'applicazione client.", "pangolinCloud": "Pangolin Cloud", "viewDevices": "Visualizza Dispositivi", "viewDevicesDescription": "Gestisci i tuoi dispositivi connessi", @@ -2246,6 +2482,7 @@ "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Non tu? Usa un account diverso.", "deviceLoginDeviceRequestingAccessToAccount": "Un dispositivo sta richiedendo l'accesso a questo account.", + "loginSelectAuthenticationMethod": "Selezionare un metodo di autenticazione per continuare.", "noData": "Nessun Dato", "machineClients": "Machine Clients", "install": "Installa", @@ -2255,6 +2492,8 @@ "setupFailedToFetchSubnet": "Recupero della sottorete predefinita non riuscito", "setupSubnetAdvanced": "Subnet (avanzato)", "setupSubnetDescription": "La subnet per la rete interna di questa organizzazione.", + "setupUtilitySubnet": "Sottorete di Utilità (Avanzata)", + "setupUtilitySubnetDescription": "La sottorete per gli indirizzi alias e il server DNS di questa organizzazione.", "siteRegenerateAndDisconnect": "Rigenera e disconnetti", "siteRegenerateAndDisconnectConfirmation": "Sei sicuro di voler rigenerare le credenziali e disconnettere questo sito?", "siteRegenerateAndDisconnectWarning": "Questo rigenererà le credenziali e disconnetterà immediatamente il sito. Il sito dovrà essere riavviato con le nuove credenziali.", @@ -2270,5 +2509,179 @@ "remoteExitNodeRegenerateAndDisconnectWarning": "Questo rigenererà le credenziali e disconnetterà immediatamente il nodo di uscita remoto. Il nodo di uscita remoto dovrà essere riavviato con le nuove credenziali.", "remoteExitNodeRegenerateCredentialsConfirmation": "Sei sicuro di voler rigenerare le credenziali per questo nodo di uscita remoto?", "remoteExitNodeRegenerateCredentialsWarning": "Questo rigenererà le credenziali. Il nodo di uscita remoto rimarrà connesso finché non lo riavvierai manualmente e userai le nuove credenziali.", - "agent": "Agente" + "agent": "Agente", + "personalUseOnly": "Solo per uso personale", + "loginPageLicenseWatermark": "Questa istanza è concessa in licenza solo per uso personale.", + "instanceIsUnlicensed": "Questa istanza non è concessa in licenza.", + "portRestrictions": "Restrizioni sulle porte", + "allPorts": "Tutti", + "custom": "Personalizzato", + "allPortsAllowed": "Tutte le porte consentite", + "allPortsBlocked": "Tutte le porte bloccate", + "tcpPortsDescription": "Specifica quali porte TCP sono consentite per questa risorsa. Usa '*' per tutte le porte, lascia vuoto per bloccare tutto o inserisci un elenco di porte e intervalli separato da virgole (ad es. 80,443,8000-9000).", + "udpPortsDescription": "Specifica quali porte UDP sono consentite per questa risorsa. Usa '*' per tutte le porte, lascia vuoto per bloccare tutto o inserisci un elenco di porte e intervalli separato da virgole (ad es. 53,123,500-600).", + "organizationLoginPageTitle": "Pagina di Accesso dell'Organizzazione", + "organizationLoginPageDescription": "Personalizza la pagina di accesso per questa organizzazione", + "resourceLoginPageTitle": "Pagina di Accesso delle Risorse", + "resourceLoginPageDescription": "Personalizza la pagina di accesso per risorse individuali", + "enterConfirmation": "Inserisci conferma", + "blueprintViewDetails": "Dettagli", + "defaultIdentityProvider": "Provider di Identità Predefinito", + "defaultIdentityProviderDescription": "Quando viene selezionato un provider di identità predefinito, l'utente verrà automaticamente reindirizzato al provider per l'autenticazione.", + "editInternalResourceDialogNetworkSettings": "Impostazioni di Rete", + "editInternalResourceDialogAccessPolicy": "Politica di Accesso", + "editInternalResourceDialogAddRoles": "Aggiungi Ruoli", + "editInternalResourceDialogAddUsers": "Aggiungi Utenti", + "editInternalResourceDialogAddClients": "Aggiungi Clienti", + "editInternalResourceDialogDestinationLabel": "Destinazione", + "editInternalResourceDialogDestinationDescription": "Specifica l'indirizzo di destinazione per la risorsa interna. Può essere un hostname, indirizzo IP o un intervallo CIDR a seconda della modalità selezionata. Opzionalmente imposta un alias DNS interno per una più facile identificazione.", + "editInternalResourceDialogPortRestrictionsDescription": "Limita l'accesso a porte TCP/UDP specifiche o consenti/blocca tutte le porte.", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "Controllo Accesso", + "editInternalResourceDialogAccessControlDescription": "Controlla quali ruoli, utenti e client macchina hanno accesso a questa risorsa quando connessi. Gli amministratori hanno sempre accesso.", + "editInternalResourceDialogPortRangeValidationError": "Il range delle porte deve essere \"*\" per tutte le porte, o un elenco di porte e intervalli separato da virgole (ad es. \"80,443,8000-9000\"). Le porte devono essere tra 1 e 65535.", + "internalResourceAuthDaemonStrategy": "Posizione Demone Autenticazione SSH", + "internalResourceAuthDaemonStrategyDescription": "Scegli dove funziona il demone di autenticazione SSH: sul sito (Newt) o su un host remoto.", + "internalResourceAuthDaemonDescription": "Il demone di autenticazione SSH gestisce la firma della chiave SSH e l'autenticazione PAM per questa risorsa. Scegli se viene eseguito sul sito (Newt) o su un host remoto separato. Vedi la documentazione per ulteriori informazioni.", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "Seleziona Strategia", + "internalResourceAuthDaemonStrategyLabel": "Posizione", + "internalResourceAuthDaemonSite": "Sul Sito", + "internalResourceAuthDaemonSiteDescription": "Il demone Auth viene eseguito sul sito (Nuovo).", + "internalResourceAuthDaemonRemote": "Host Remoto", + "internalResourceAuthDaemonRemoteDescription": "Il demone di autenticazione viene eseguito su un host che non è il sito.", + "internalResourceAuthDaemonPort": "Porta Demone (facoltativa)", + "orgAuthWhatsThis": "Dove posso trovare l'ID della mia organizzazione?", + "learnMore": "Scopri di più", + "backToHome": "Torna alla home", + "needToSignInToOrg": "Hai bisogno di utilizzare il provider di identità della tua organizzazione?", + "maintenanceMode": "Modalità di Manutenzione", + "maintenanceModeDescription": "Visualizza una pagina di manutenzione ai visitatori", + "maintenanceModeType": "Tipo di Modalità di Manutenzione", + "showMaintenancePage": "Mostra una pagina di manutenzione ai visitatori", + "enableMaintenanceMode": "Abilita Modalità di Manutenzione", + "automatic": "Automatico", + "automaticModeDescription": "Mostra pagina di manutenzione solo quando tutti i target del backend sono inattivi o non in salute. La tua risorsa continua a funzionare normalmente finché almeno un target è in salute.", + "forced": "Forzato", + "forcedModeDescription": "Mostra sempre la pagina di manutenzione indipendentemente dalla salute del backend. Usa questo per la manutenzione programmata quando vuoi impedire tutto l'accesso.", + "warning:": "Avviso:", + "forcedeModeWarning": "Tutto il traffico verrà indirizzato alla pagina di manutenzione. Le risorse del tuo backend non riceveranno richieste.", + "pageTitle": "Titolo Pagina", + "pageTitleDescription": "L'intestazione principale visualizzata sulla pagina di manutenzione", + "maintenancePageMessage": "Messaggio di Manutenzione", + "maintenancePageMessagePlaceholder": "Torneremo presto! Il nostro sito è attualmente in manutenzione programmata.", + "maintenancePageMessageDescription": "Messaggio dettagliato che spiega la manutenzione", + "maintenancePageTimeTitle": "Tempo di Completamento Stimato (Opzionale)", + "maintenanceTime": "es. 2 ore, 1 novembre alle 17:00", + "maintenanceEstimatedTimeDescription": "Quando prevedi che la manutenzione sarà completata", + "editDomain": "Modifica Dominio", + "editDomainDescription": "Seleziona un dominio per la tua risorsa", + "maintenanceModeDisabledTooltip": "Questa funzione richiede una licenza valida per essere abilitata.", + "maintenanceScreenTitle": "Servizio Temporaneamente Non Disponibile", + "maintenanceScreenMessage": "Stiamo attualmente riscontrando difficoltà tecniche. Si prega di ricontrollare a breve.", + "maintenanceScreenEstimatedCompletion": "Completamento Stimato:", + "createInternalResourceDialogDestinationRequired": "Destinazione richiesta", + "available": "Disponibile", + "archived": "Archiviato", + "noArchivedDevices": "Nessun dispositivo archiviato trovato", + "deviceArchived": "Dispositivo archiviato", + "deviceArchivedDescription": "Il dispositivo è stato archiviato con successo.", + "errorArchivingDevice": "Errore nell'archiviazione del dispositivo", + "failedToArchiveDevice": "Impossibile archiviare il dispositivo", + "deviceQuestionArchive": "È sicuro di voler archiviare questo dispositivo?", + "deviceMessageArchive": "Il dispositivo verrà archiviato e rimosso dalla lista dei dispositivi attivi.", + "deviceArchiveConfirm": "Archivia Dispositivo", + "archiveDevice": "Archivia Dispositivo", + "archive": "Archivio", + "deviceUnarchived": "Dispositivo non archiviato", + "deviceUnarchivedDescription": "Il dispositivo è stato disarchiviato con successo.", + "errorUnarchivingDevice": "Errore nel disarchiviare il dispositivo", + "failedToUnarchiveDevice": "Disarchiviazione del dispositivo non riuscita", + "unarchive": "Disarchivia", + "archiveClient": "Archivia Client", + "archiveClientQuestion": "È sicuro di voler archiviare questo client?", + "archiveClientMessage": "Il client verrà archiviato e rimosso dalla lista dei client attivi.", + "archiveClientConfirm": "Archivia Client", + "blockClient": "Blocca Client", + "blockClientQuestion": "Sei sicuro di voler bloccare questo client?", + "blockClientMessage": "Il dispositivo sarà forzato a disconnettersi se attualmente connesso. Puoi sbloccare il dispositivo più tardi.", + "blockClientConfirm": "Blocca Client", + "active": "Attivo", + "usernameOrEmail": "Nome utente o Email", + "selectYourOrganization": "Seleziona la tua organizzazione", + "signInTo": "Accedi a", + "signInWithPassword": "Continua con la password", + "noAuthMethodsAvailable": "Nessun metodo di autenticazione disponibile per questa organizzazione.", + "enterPassword": "Inserisci la tua password", + "enterMfaCode": "Inserisci il codice dalla tua app di autenticazione", + "securityKeyRequired": "Utilizza la tua chiave di sicurezza per accedere.", + "needToUseAnotherAccount": "Hai bisogno di utilizzare un account diverso?", + "loginLegalDisclaimer": "Facendo clic sui pulsanti qui sotto, si riconosce di aver letto, capire, e accettare i Termini di Servizio e Privacy Policy.", + "termsOfService": "Termini di servizio", + "privacyPolicy": "Politica Sulla Privacy", + "userNotFoundWithUsername": "Nessun utente trovato con questo nome utente.", + "verify": "Verifica", + "signIn": "Accedi", + "forgotPassword": "Password dimenticata?", + "orgSignInTip": "Se hai effettuato l'accesso prima, puoi inserire il tuo nome utente o email qui sopra per autenticarti con il provider di identità della tua organizzazione. È più facile!", + "continueAnyway": "Continua comunque", + "dontShowAgain": "Non mostrare più", + "orgSignInNotice": "Lo sapevate?", + "signupOrgNotice": "Cercando di accedere?", + "signupOrgTip": "Stai cercando di accedere tramite il provider di identità della tua organizzazione?", + "signupOrgLink": "Accedi o registrati con la tua organizzazione", + "verifyEmailLogInWithDifferentAccount": "Usa un account diverso", + "logIn": "Log In", + "deviceInformation": "Informazioni Sul Dispositivo", + "deviceInformationDescription": "Informazioni sul dispositivo e sull'agente", + "deviceSecurity": "Sicurezza Del Dispositivo", + "deviceSecurityDescription": "Informazioni postura sicurezza dispositivo", + "platform": "Piattaforma", + "macosVersion": "versione macOS", + "windowsVersion": "Versione Windows", + "iosVersion": "Versione iOS", + "androidVersion": "Versione Android", + "osVersion": "Versione OS", + "kernelVersion": "Versione Del Kernel", + "deviceModel": "Modello Di Dispositivo", + "serialNumber": "Numero D'Ordine", + "hostname": "Hostname", + "firstSeen": "Prima Visto", + "lastSeen": "Visto L'Ultima", + "biometricsEnabled": "Biometria Abilitata", + "diskEncrypted": "Cifratura Del Disco", + "firewallEnabled": "Firewall Abilitato", + "autoUpdatesEnabled": "Aggiornamenti Automatici Abilitati", + "tpmAvailable": "TPM Disponibile", + "windowsAntivirusEnabled": "Antivirus Abilitato", + "macosSipEnabled": "Protezione Dell'Integrità Del Sistema (Sip)", + "macosGatekeeperEnabled": "Gatekeeper", + "macosFirewallStealthMode": "Modo Furtivo Del Firewall", + "linuxAppArmorEnabled": "AppArmor", + "linuxSELinuxEnabled": "SELinux", + "deviceSettingsDescription": "Visualizza informazioni e impostazioni del dispositivo", + "devicePendingApprovalDescription": "Questo dispositivo è in attesa di approvazione", + "deviceBlockedDescription": "Questo dispositivo è attualmente bloccato. Non sarà in grado di connettersi a nessuna risorsa a meno che non sia sbloccato.", + "unblockClient": "Sblocca Client", + "unblockClientDescription": "Il dispositivo è stato sbloccato", + "unarchiveClient": "Annulla Archiviazione Client", + "unarchiveClientDescription": "Il dispositivo è stato disarchiviato", + "block": "Blocca", + "unblock": "Sblocca", + "deviceActions": "Azioni Dispositivo", + "deviceActionsDescription": "Gestisci lo stato del dispositivo e l'accesso", + "devicePendingApprovalBannerDescription": "Questo dispositivo è in attesa di approvazione. Non sarà in grado di connettersi alle risorse fino all'approvazione.", + "connected": "Connesso", + "disconnected": "Disconnesso", + "approvalsEmptyStateTitle": "Approvazioni Dispositivo Non Abilitato", + "approvalsEmptyStateDescription": "Abilita le approvazioni del dispositivo per i ruoli per richiedere l'approvazione dell'amministratore prima che gli utenti possano collegare nuovi dispositivi.", + "approvalsEmptyStateStep1Title": "Vai ai ruoli", + "approvalsEmptyStateStep1Description": "Vai alle impostazioni dei ruoli della tua organizzazione per configurare le approvazioni del dispositivo.", + "approvalsEmptyStateStep2Title": "Abilita Approvazioni Dispositivo", + "approvalsEmptyStateStep2Description": "Modifica un ruolo e abilita l'opzione 'Richiedi l'approvazione del dispositivo'. Gli utenti con questo ruolo avranno bisogno dell'approvazione dell'amministratore per i nuovi dispositivi.", + "approvalsEmptyStatePreviewDescription": "Anteprima: quando abilitato, le richieste di dispositivo in attesa appariranno qui per la revisione", + "approvalsEmptyStateButtonText": "Gestisci Ruoli", + "domainErrorTitle": "Stiamo avendo problemi a verificare il tuo dominio" } diff --git a/messages/ko-KR.json b/messages/ko-KR.json index 82a12e4e2..02915abd7 100644 --- a/messages/ko-KR.json +++ b/messages/ko-KR.json @@ -1,5 +1,7 @@ { "setupCreate": "조직, 사이트 및 리소스를 생성합니다.", + "headerAuthCompatibilityInfo": "인증 토큰이 없을 때 401 Unauthorized 응답을 강제하도록 설정합니다. 서버 챌린지 없이 자격 증명을 제공하지 않는 브라우저나 특정 HTTP 라이브러리에 필요합니다.", + "headerAuthCompatibility": "확장된 호환성", "setupNewOrg": "새 조직", "setupCreateOrg": "조직 생성", "setupCreateResources": "리소스 생성", @@ -16,6 +18,8 @@ "componentsMember": "당신은 {count, plural, =0 {조직이 없습니다} one {하나의 조직} other {# 개의 조직}}의 구성원입니다.", "componentsInvalidKey": "유효하지 않거나 만료된 라이센스 키가 감지되었습니다. 모든 기능을 계속 사용하려면 라이센스 조건을 따르십시오.", "dismiss": "해제", + "subscriptionViolationMessage": "현재 계획의 한계를 초과했습니다. 사이트, 사용자 또는 기타 리소스를 제거하여 계획 내에 머물도록 해결하세요.", + "subscriptionViolationViewBilling": "청구 보기", "componentsLicenseViolation": "라이센스 위반: 이 서버는 {usedSites} 사이트를 사용하고 있으며, 이는 {maxSites} 사이트의 라이센스 한도를 초과합니다. 모든 기능을 계속 사용하려면 라이센스 조건을 따르십시오.", "componentsSupporterMessage": "{tier}로 판골린을 지원해 주셔서 감사합니다!", "inviteErrorNotValid": "죄송하지만, 접근하려는 초대가 수락되지 않았거나 더 이상 유효하지 않은 것 같습니다.", @@ -51,6 +55,12 @@ "siteQuestionRemove": "조직에서 사이트를 제거하시겠습니까?", "siteManageSites": "사이트 관리", "siteDescription": "프라이빗 네트워크로의 연결을 활성화하려면 사이트를 생성하고 관리하세요.", + "sitesBannerTitle": "모든 네트워크 연결", + "sitesBannerDescription": "사이트는 원격 네트워크와의 연결로 Pangolin이 어디서나 사용자에게 공공 및 개인 리소스에 대한 접근을 제공할 수 있게 해 줍니다. 연결을 설정하려면 바이너리 또는 컨테이너로 실행할 수 있는 어디서든 사이트 네트워크 커넥터(Newt)를 설치하세요.", + "sitesBannerButtonText": "사이트 설치", + "approvalsBannerTitle": "장치 접근 승인 또는 거부", + "approvalsBannerDescription": "사용자의 장치 접근 요청을 검토하고 승인하거나 거부하세요. 장치 승인 요구 시, 관리자의 승인이 필요합니다.", + "approvalsBannerButtonText": "자세히 알아보기", "siteCreate": "사이트 생성", "siteCreateDescription2": "아래 단계를 따라 새 사이트를 생성하고 연결하십시오", "siteCreateDescription": "리소스를 연결하기 위해 새 사이트를 생성하세요.", @@ -100,6 +110,7 @@ "siteTunnelDescription": "사이트에 연결하는 방법을 결정하세요.", "siteNewtCredentials": "자격 증명", "siteNewtCredentialsDescription": "이것이 사이트가 서버와 인증하는 방법입니다.", + "remoteNodeCredentialsDescription": "원격 노드가 서버와 인증하는 방법입니다.", "siteCredentialsSave": "자격 증명 저장", "siteCredentialsSaveDescription": "이것은 한 번만 볼 수 있습니다. 안전한 장소에 복사해 두세요.", "siteInfo": "사이트 정보", @@ -146,8 +157,12 @@ "shareErrorSelectResource": "리소스를 선택하세요", "proxyResourceTitle": "공개 리소스 관리", "proxyResourceDescription": "웹 브라우저를 통해 공용으로 접근할 수 있는 리소스를 생성하고 관리하세요.", + "proxyResourcesBannerTitle": "웹 기반 공공 접근", + "proxyResourcesBannerDescription": "공공 자원은 누구나 웹 브라우저를 통해 접근 가능한 HTTPS 또는 TCP/UDP 프록시입니다. 개인 자원과 달리 클라이언트 측 소프트웨어가 필요하지 않으며, 아이덴티티 및 컨텍스트 인지 접근 정책을 포함할 수 있습니다.", "clientResourceTitle": "개인 리소스 관리", "clientResourceDescription": "연결된 클라이언트를 통해서만 접근할 수 있는 리소스를 생성하고 관리하세요.", + "privateResourcesBannerTitle": "제로 트러스트 개인 접근", + "privateResourcesBannerDescription": "개인 리소스는 제로 트러스트 보안을 사용하여, 사용자와 장치가 명시적으로 허용된 리소스에만 접근할 수 있도록 보장합니다. 이러한 리소스에 접근하려면 안전한 가상 사설 네트워크를 통해 사용자 장치 또는 머신 클라이언트를 연결하십시오.", "resourcesSearch": "리소스 검색...", "resourceAdd": "리소스 추가", "resourceErrorDelte": "리소스 삭제 중 오류 발생", @@ -157,9 +172,10 @@ "resourceMessageRemove": "제거되면 리소스에 더 이상 접근할 수 없습니다. 리소스와 연결된 모든 대상도 제거됩니다.", "resourceQuestionRemove": "조직에서 리소스를 제거하시겠습니까?", "resourceHTTP": "HTTPS 리소스", - "resourceHTTPDescription": "서브도메인이나 기본 도메인을 사용하여 HTTPS를 통해 앱에 대한 요청을 프록시합니다.", + "resourceHTTPDescription": "완전한 도메인 이름을 사용해 RAW 또는 HTTPS로 프록시 요청을 수행합니다.", "resourceRaw": "원시 TCP/UDP 리소스", - "resourceRawDescription": "TCP/UDP를 사용하여 포트 번호를 통해 앱에 요청을 프록시합니다. 이 기능은 사이트가 노드에 연결될 때만 작동합니다.", + "resourceRawDescription": "포트 번호를 사용하여 RAW TCP/UDP로 요청을 프록시합니다.", + "resourceRawDescriptionCloud": "포트 번호를 사용하여 원격 노드에 연결해야 합니다. 원격 노드에서 리소스를 사용하려면 사용자 지정 도메인을 사용하십시오.", "resourceCreate": "리소스 생성", "resourceCreateDescription": "아래 단계를 따라 새 리소스를 생성하세요.", "resourceSeeAll": "모든 리소스 보기", @@ -186,6 +202,7 @@ "protocolSelect": "프로토콜 선택", "resourcePortNumber": "포트 번호", "resourcePortNumberDescription": "요청을 프록시하기 위한 외부 포트 번호입니다.", + "back": "뒤로", "cancel": "취소", "resourceConfig": "구성 스니펫", "resourceConfigDescription": "TCP/UDP 리소스를 설정하기 위해 이 구성 스니펫을 복사하여 붙여넣습니다.", @@ -231,6 +248,17 @@ "orgErrorDeleteMessage": "조직을 삭제하는 중 오류가 발생했습니다.", "orgDeleted": "조직이 삭제되었습니다.", "orgDeletedMessage": "조직과 그 데이터가 삭제되었습니다.", + "deleteAccount": "계정 삭제", + "deleteAccountDescription": "계정, 소유한 모든 조직 및 조직 내의 모든 데이터를 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.", + "deleteAccountButton": "계정 삭제", + "deleteAccountConfirmTitle": "계정 삭제", + "deleteAccountConfirmMessage": "이 작업은 귀하의 계정, 소유한 모든 조직 및 조직 내 모든 데이터를 영구적으로 삭제합니다. 이 작업은 되돌릴 수 없습니다.", + "deleteAccountConfirmString": "계정 삭제", + "deleteAccountSuccess": "계정 삭제됨", + "deleteAccountSuccessMessage": "계정이 삭제되었습니다.", + "deleteAccountError": "계정 삭제 실패", + "deleteAccountPreviewAccount": "귀하의 계정", + "deleteAccountPreviewOrgs": "귀하가 소유한 조직(포함된 모든 데이터)", "orgMissing": "조직 ID가 누락되었습니다", "orgMissingMessage": "조직 ID 없이 초대장을 재생성할 수 없습니다.", "accessUsersManage": "사용자 관리", @@ -247,6 +275,8 @@ "accessRolesSearch": "역할 검색...", "accessRolesAdd": "역할 추가", "accessRoleDelete": "역할 삭제", + "accessApprovalsManage": "승인 관리", + "accessApprovalsDescription": "이 조직의 접근 승인 대기를 보고 관리하세요.", "description": "설명", "inviteTitle": "열린 초대", "inviteDescription": "다른 사용자가 조직에 참여하도록 초대장을 관리합니다.", @@ -440,6 +470,20 @@ "selectDuration": "지속 시간 선택", "selectResource": "리소스 선택", "filterByResource": "리소스별 필터", + "selectApprovalState": "승인 상태 선택", + "filterByApprovalState": "승인 상태로 필터링", + "approvalListEmpty": "승인이 없습니다.", + "approvalState": "승인 상태", + "approvalLoadMore": "더 불러오기", + "loadingApprovals": "승인 불러오는 중", + "approve": "승인", + "approved": "승인됨", + "denied": "거부됨", + "deniedApproval": "승인 거부됨", + "all": "모두", + "deny": "거부", + "viewDetails": "세부 정보 보기", + "requestingNewDeviceApproval": "새 장치를 요청함", "resetFilters": "필터 재설정", "totalBlocked": "Pangolin으로 차단된 요청", "totalRequests": "총 요청 수", @@ -607,6 +651,7 @@ "resourcesErrorUpdate": "리소스를 전환하는 데 실패했습니다.", "resourcesErrorUpdateDescription": "리소스를 업데이트하는 동안 오류가 발생했습니다.", "access": "접속", + "accessControl": "액세스 제어", "shareLink": "{resource} 공유 링크", "resourceSelect": "리소스 선택", "shareLinks": "공유 링크", @@ -687,7 +732,7 @@ "resourceRoleDescription": "관리자는 항상 이 리소스에 접근할 수 있습니다.", "resourceUsersRoles": "접근 제어", "resourceUsersRolesDescription": "이 리소스를 방문할 수 있는 사용자 및 역할을 구성하십시오", - "resourceUsersRolesSubmit": "사용자 및 역할 저장", + "resourceUsersRolesSubmit": "접근 제어 저장", "resourceWhitelistSave": "성공적으로 저장되었습니다.", "resourceWhitelistSaveDescription": "허용 목록 설정이 저장되었습니다.", "ssoUse": "플랫폼 SSO 사용", @@ -719,22 +764,35 @@ "countries": "국가", "accessRoleCreate": "역할 생성", "accessRoleCreateDescription": "사용자를 그룹화하고 권한을 관리하기 위해 새 역할을 생성하세요.", + "accessRoleEdit": "역할 편집", + "accessRoleEditDescription": "역할 정보 편집.", "accessRoleCreateSubmit": "역할 생성", "accessRoleCreated": "역할이 생성되었습니다.", "accessRoleCreatedDescription": "역할이 성공적으로 생성되었습니다.", "accessRoleErrorCreate": "역할 생성 실패", "accessRoleErrorCreateDescription": "역할 생성 중 오류가 발생했습니다.", + "accessRoleUpdateSubmit": "역할 업데이트", + "accessRoleUpdated": "역할 업데이트됨", + "accessRoleUpdatedDescription": "역할이 성공적으로 업데이트되었습니다.", + "accessApprovalUpdated": "승인 처리됨", + "accessApprovalApprovedDescription": "승인 요청을 승인으로 설정.", + "accessApprovalDeniedDescription": "승인 요청을 거부로 설정.", + "accessRoleErrorUpdate": "역할 업데이트 실패", + "accessRoleErrorUpdateDescription": "역할 업데이트 중 오류 발생.", + "accessApprovalErrorUpdate": "승인 처리 실패", + "accessApprovalErrorUpdateDescription": "승인 처리 중 오류가 발생했습니다.", "accessRoleErrorNewRequired": "새 역할이 필요합니다.", "accessRoleErrorRemove": "역할 제거에 실패했습니다.", "accessRoleErrorRemoveDescription": "역할을 제거하는 동안 오류가 발생했습니다.", "accessRoleName": "역할 이름", - "accessRoleQuestionRemove": "{name} 역할을 삭제하려고 합니다. 이 작업은 취소할 수 없습니다.", + "accessRoleQuestionRemove": "`{name}` 역할을 삭제하려고 합니다. 이 작업은 되돌릴 수 없습니다.", "accessRoleRemove": "역할 제거", "accessRoleRemoveDescription": "조직에서 역할 제거", "accessRoleRemoveSubmit": "역할 제거", "accessRoleRemoved": "역할이 제거되었습니다", "accessRoleRemovedDescription": "역할이 성공적으로 제거되었습니다.", "accessRoleRequiredRemove": "이 역할을 삭제하기 전에 기존 구성원을 전송할 새 역할을 선택하세요.", + "network": "네트워크", "manage": "관리", "sitesNotFound": "사이트를 찾을 수 없습니다.", "pangolinServerAdmin": "서버 관리자 - 판골린", @@ -750,6 +808,9 @@ "sitestCountIncrease": "사이트 수 증가", "idpManage": "아이덴티티 공급자 관리", "idpManageDescription": "시스템에서 ID 제공자를 보고 관리합니다", + "idpGlobalModeBanner": "조직별 신원 제공자(IdP)는 이 서버에서 비활성화되었습니다. 이 서버는 모든 조직에 걸쳐 공유된 글로벌 IdP를 사용 중입니다. 관리자 패널에서 글로벌 IdP를 관리하십시오. 조직별 IdP를 활성화하려면 서버 설정을 편집하고 IdP 모드를 조직으로 설정하십시오. 문서 보기. 글로벌 IdP 사용을 계속하고 조직 설정에서 이 항목을 제거하려면 설정에서 모드를 글로벌로 명시적으로 설정하십시오.", + "idpGlobalModeBannerUpgradeRequired": "조직별 신원 제공자(IdP)는 이 서버에서 비활성화되었습니다. 이 서버는 모든 조직에 걸쳐 공유된 글로벌 IdP를 사용 중입니다. 관리자 패널에서 글로벌 IdP를 관리하십시오. 조직별 신원 제공자를 사용하려면 Enterprise 에디션으로 업그레이드해야 합니다.", + "idpGlobalModeBannerLicenseRequired": "조직별 신원 제공자(IdP)는 이 서버에서 비활성화되었습니다. 이 서버는 모든 조직에 걸쳐 공유된 글로벌 IdP를 사용 중입니다. 관리자 패널에서 글로벌 IdP를 관리하십시오. 조직별 신원 제공자를 사용하려면 엔터프라이즈 라이선스가 필요합니다.", "idpDeletedDescription": "신원 공급자가 성공적으로 삭제되었습니다", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "아이덴티티 공급자를 영구적으로 삭제하시겠습니까?", @@ -840,6 +901,7 @@ "orgPolicyConfig": "조직에 대한 접근을 구성하십시오.", "idpUpdatedDescription": "아이덴티티 제공자가 성공적으로 업데이트되었습니다", "redirectUrl": "리디렉션 URL", + "orgIdpRedirectUrls": "리디렉션 URL", "redirectUrlAbout": "리디렉션 URL에 대한 정보", "redirectUrlAboutDescription": "사용자가 인증 후 리디렉션될 URL입니다. 이 URL을 신원 제공자 설정에서 구성해야 합니다.", "pangolinAuth": "인증 - 판골린", @@ -945,11 +1007,11 @@ "pincodeAuth": "인증 코드", "pincodeSubmit2": "코드 제출", "passwordResetSubmit": "재설정 요청", - "passwordResetAlreadyHaveCode": "비밀번호 초기화 코드를 입력하세요", + "passwordResetAlreadyHaveCode": "코드를 입력하십시오.", "passwordResetSmtpRequired": "관리자에게 문의하십시오", "passwordResetSmtpRequiredDescription": "비밀번호를 재설정하려면 비밀번호 초기화 코드가 필요합니다. 지원을 받으려면 관리자에게 문의하십시오.", "passwordBack": "비밀번호로 돌아가기", - "loginBack": "로그인으로 돌아가기", + "loginBack": "메인 로그인 페이지로 돌아갑니다.", "signup": "가입하기", "loginStart": "시작하려면 로그인하세요.", "idpOidcTokenValidating": "OIDC 토큰 검증 중", @@ -972,12 +1034,12 @@ "pangolinSetup": "설정 - 판골린", "orgNameRequired": "조직 이름은 필수입니다.", "orgIdRequired": "조직 ID가 필요합니다", + "orgIdMaxLength": "조직 ID는 최대 32자 이내여야 합니다", "orgErrorCreate": "조직 생성 중 오류가 발생했습니다.", "pageNotFound": "페이지를 찾을 수 없습니다", "pageNotFoundDescription": "앗! 찾고 있는 페이지가 존재하지 않습니다.", "overview": "개요", "home": "홈", - "accessControl": "액세스 제어", "settings": "설정", "usersAll": "모든 사용자", "license": "라이선스", @@ -1035,15 +1097,24 @@ "updateOrgUser": "조직 사용자 업데이트", "createOrgUser": "조직 사용자 생성", "actionUpdateOrg": "조직 업데이트", + "actionRemoveInvitation": "초대 제거", "actionUpdateUser": "사용자 업데이트", "actionGetUser": "사용자 조회", "actionGetOrgUser": "조직 사용자 가져오기", "actionListOrgDomains": "조직 도메인 목록", + "actionGetDomain": "도메인 가져오기", + "actionCreateOrgDomain": "도메인 생성", + "actionUpdateOrgDomain": "도메인 업데이트", + "actionDeleteOrgDomain": "도메인 삭제", + "actionGetDNSRecords": "DNS 레코드 가져오기", + "actionRestartOrgDomain": "도메인 재시작", "actionCreateSite": "사이트 생성", "actionDeleteSite": "사이트 삭제", "actionGetSite": "사이트 가져오기", "actionListSites": "사이트 목록", "actionApplyBlueprint": "청사진 적용", + "actionListBlueprints": "청사진 목록", + "actionGetBlueprint": "청사진 가져오기", "setupToken": "설정 토큰", "setupTokenDescription": "서버 콘솔에서 설정 토큰 입력.", "setupTokenRequired": "설정 토큰이 필요합니다", @@ -1077,6 +1148,7 @@ "actionRemoveUser": "사용자 제거", "actionListUsers": "사용자 목록", "actionAddUserRole": "사용자 역할 추가", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "액세스 토큰 생성", "actionDeleteAccessToken": "액세스 토큰 삭제", "actionListAccessTokens": "액세스 토큰 목록", @@ -1104,6 +1176,10 @@ "actionUpdateIdpOrg": "IDP 조직 업데이트", "actionCreateClient": "클라이언트 생성", "actionDeleteClient": "클라이언트 삭제", + "actionArchiveClient": "클라이언트 보관", + "actionUnarchiveClient": "클라이언트 보관 취소", + "actionBlockClient": "클라이언트 차단", + "actionUnblockClient": "클라이언트 차단 해제", "actionUpdateClient": "클라이언트 업데이트", "actionListClients": "클라이언트 목록", "actionGetClient": "클라이언트 가져오기", @@ -1117,17 +1193,18 @@ "actionViewLogs": "로그 보기", "noneSelected": "선택된 항목 없음", "orgNotFound2": "조직이 없습니다.", - "searchProgress": "검색...", + "searchPlaceholder": "검색...", + "emptySearchOptions": "옵션이 없습니다", "create": "생성", "orgs": "조직", - "loginError": "로그인 중 오류가 발생했습니다", - "loginRequiredForDevice": "장치를 인증하려면 로그인이 필요합니다.", + "loginError": "예기치 않은 오류가 발생했습니다. 다시 시도해주세요.", + "loginRequiredForDevice": "로그인이 필요합니다.", "passwordForgot": "비밀번호를 잊으셨나요?", "otpAuth": "이중 인증", "otpAuthDescription": "인증 앱에서 코드를 입력하거나 단일 사용 백업 코드 중 하나를 입력하세요.", "otpAuthSubmit": "코드 제출", "idpContinue": "또는 계속 진행하십시오.", - "otpAuthBack": "로그인으로 돌아가기", + "otpAuthBack": "비밀번호로 돌아가기", "navbar": "탐색 메뉴", "navbarDescription": "애플리케이션의 주요 탐색 메뉴", "navbarDocsLink": "문서", @@ -1175,11 +1252,13 @@ "sidebarOverview": "개요", "sidebarHome": "홈", "sidebarSites": "사이트", + "sidebarApprovals": "승인 요청", "sidebarResources": "리소스", "sidebarProxyResources": "공유", "sidebarClientResources": "비공개", "sidebarAccessControl": "액세스 제어", "sidebarLogsAndAnalytics": "로그 및 분석", + "sidebarTeam": "팀", "sidebarUsers": "사용자", "sidebarAdmin": "관리자", "sidebarInvitations": "초대", @@ -1191,13 +1270,15 @@ "sidebarIdentityProviders": "신원 공급자", "sidebarLicense": "라이선스", "sidebarClients": "클라이언트", - "sidebarUserDevices": "사용자", + "sidebarUserDevices": "사용자 장치", "sidebarMachineClients": "기계", "sidebarDomains": "도메인", - "sidebarGeneral": "일반", + "sidebarGeneral": "관리", "sidebarLogAndAnalytics": "로그 & 통계", "sidebarBluePrints": "청사진", "sidebarOrganization": "조직", + "sidebarManagement": "관리", + "sidebarBillingAndLicenses": "결제 및 라이선스", "sidebarLogsAnalytics": "분석", "blueprints": "청사진", "blueprintsDescription": "선언적 구성을 적용하고 이전 실행을 봅니다", @@ -1219,7 +1300,6 @@ "parsedContents": "구문 분석된 콘텐츠 (읽기 전용)", "enableDockerSocket": "Docker 청사진 활성화", "enableDockerSocketDescription": "블루프린트 레이블을 위한 Docker 소켓 레이블 수집을 활성화합니다. 소켓 경로는 Newt에 제공되어야 합니다.", - "enableDockerSocketLink": "자세히 알아보기", "viewDockerContainers": "도커 컨테이너 보기", "containersIn": "{siteName}의 컨테이너", "selectContainerDescription": "이 대상을 위한 호스트 이름으로 사용할 컨테이너를 선택하세요. 포트를 사용하려면 포트를 클릭하세요.", @@ -1263,6 +1343,7 @@ "setupErrorCreateAdmin": "서버 관리자 계정을 생성하는 동안 오류가 발생했습니다.", "certificateStatus": "인증서 상태", "loading": "로딩 중", + "loadingAnalytics": "분석 로딩 중", "restart": "재시작", "domains": "도메인", "domainsDescription": "조직에서 사용 가능한 도메인 생성 및 관리", @@ -1290,6 +1371,7 @@ "refreshError": "데이터 새로고침 실패", "verified": "검증됨", "pending": "대기 중", + "pendingApproval": "승인 대기 중", "sidebarBilling": "청구", "billing": "청구", "orgBillingDescription": "청구 정보 및 구독을 관리하세요", @@ -1308,8 +1390,11 @@ "accountSetupSuccess": "계정 설정이 완료되었습니다! 판골린에 오신 것을 환영합니다!", "documentation": "문서", "saveAllSettings": "모든 설정 저장", + "saveResourceTargets": "대상 저장", + "saveResourceHttp": "프록시 설정 저장", + "saveProxyProtocol": "프록시 프로토콜 설정 저장", "settingsUpdated": "설정이 업데이트되었습니다", - "settingsUpdatedDescription": "모든 설정이 성공적으로 업데이트되었습니다", + "settingsUpdatedDescription": "설정이 성공적으로 업데이트되었습니다.", "settingsErrorUpdate": "설정 업데이트 실패", "settingsErrorUpdateDescription": "설정을 업데이트하는 동안 오류가 발생했습니다", "sidebarCollapse": "줄이기", @@ -1342,6 +1427,7 @@ "domainPickerNamespace": "이름 공간: {namespace}", "domainPickerShowMore": "더보기", "regionSelectorTitle": "지역 선택", + "domainPickerRemoteExitNodeWarning": "제공된 도메인은 원격 종료 노드에 연결된 사이트에서 지원되지 않습니다. 원격 노드에서 리소스를 사용하려면 사용자 지정 도메인을 사용하십시오.", "regionSelectorInfo": "지역을 선택하면 위치에 따라 더 나은 성능이 제공됩니다. 서버와 같은 지역에 있을 필요는 없습니다.", "regionSelectorPlaceholder": "지역 선택", "regionSelectorComingSoon": "곧 출시 예정", @@ -1351,10 +1437,11 @@ "billingUsageLimitsOverview": "사용 한도 개요", "billingMonitorUsage": "설정된 한도에 대한 사용량을 모니터링합니다. 한도를 늘려야 하는 경우 support@pangolin.net로 연락하십시오.", "billingDataUsage": "데이터 사용량", - "billingOnlineTime": "사이트 온라인 시간", - "billingUsers": "활성 사용자", - "billingDomains": "활성 도메인", - "billingRemoteExitNodes": "활성 자체 호스팅 노드", + "billingSites": "사이트", + "billingUsers": "사용자", + "billingDomains": "도메인", + "billingOrganizations": "조직", + "billingRemoteExitNodes": "원격 노드", "billingNoLimitConfigured": "구성된 한도가 없습니다.", "billingEstimatedPeriod": "예상 청구 기간", "billingIncludedUsage": "포함 사용량", @@ -1379,15 +1466,24 @@ "billingFailedToGetPortalUrl": "포털 URL을 가져오는 데 실패했습니다.", "billingPortalError": "포털 오류", "billingDataUsageInfo": "클라우드에 연결할 때 보안 터널을 통해 전송된 모든 데이터에 대해 비용이 청구됩니다. 여기에는 모든 사이트의 들어오고 나가는 트래픽이 포함됩니다. 사용량 한도에 도달하면 플랜을 업그레이드하거나 사용량을 줄일 때까지 사이트가 연결 해제됩니다. 노드를 사용하는 경우 데이터는 요금이 청구되지 않습니다.", - "billingOnlineTimeInfo": "사이트가 클라우드에 연결된 시간에 따라 요금이 청구됩니다. 예를 들어, 44,640분은 사이트가 한 달 내내 24시간 작동하는 것과 같습니다. 사용량 한도에 도달하면 플랜을 업그레이드하거나 사용량을 줄일 때까지 사이트가 연결 해제됩니다. 노드를 사용할 때 시간은 요금이 청구되지 않습니다.", - "billingUsersInfo": "조직의 사용자마다 요금이 청구됩니다. 청구는 조직의 활성 사용자 계정 수에 따라 매일 계산됩니다.", - "billingDomainInfo": "조직의 도메인마다 요금이 청구됩니다. 청구는 조직의 활성 도메인 계정 수에 따라 매일 계산됩니다.", - "billingRemoteExitNodesInfo": "조직의 관리 노드마다 요금이 청구됩니다. 청구는 조직의 활성 관리 노드 수에 따라 매일 계산됩니다.", + "billingSInfo": "사용할 수 있는 사이트 수", + "billingUsersInfo": "사용할 수 있는 사용자 수", + "billingDomainInfo": "사용할 수 있는 도메인 수", + "billingRemoteExitNodesInfo": "사용할 수 있는 원격 노드 수", + "billingLicenseKeys": "라이센스 키", + "billingLicenseKeysDescription": "라이센스 키 구독을 관리하세요", + "billingLicenseSubscription": "라이센스 구독", + "billingInactive": "비활성화됨", + "billingLicenseItem": "라이센스 항목", + "billingQuantity": "수량", + "billingTotal": "총계", + "billingModifyLicenses": "라이센스 구독 수정", "domainNotFound": "도메인을 찾을 수 없습니다", "domainNotFoundDescription": "이 리소스는 도메인이 더 이상 시스템에 존재하지 않아 비활성화되었습니다. 이 리소스에 대한 새 도메인을 설정하세요.", "failed": "실패", "createNewOrgDescription": "새 조직 생성", "organization": "조직", + "primary": "기본", "port": "포트", "securityKeyManage": "보안 키 관리", "securityKeyDescription": "비밀번호 없는 인증을 위해 보안 키를 추가하거나 제거합니다.", @@ -1403,7 +1499,7 @@ "securityKeyRemoveSuccess": "보안 키가 성공적으로 제거되었습니다", "securityKeyRemoveError": "보안 키 제거 실패", "securityKeyLoadError": "보안 키를 불러오는 데 실패했습니다", - "securityKeyLogin": "보안 키로 계속하기", + "securityKeyLogin": "보안 키 사용", "securityKeyAuthError": "보안 키를 사용한 인증 실패", "securityKeyRecommendation": "항상 계정에 액세스할 수 있도록 다른 장치에 백업 보안 키를 등록하세요.", "registering": "등록 중...", @@ -1459,11 +1555,47 @@ "resourcePortRequired": "HTTP 리소스가 아닌 경우 포트 번호가 필요합니다", "resourcePortNotAllowed": "HTTP 리소스에 대해 포트 번호를 설정하지 마세요", "billingPricingCalculatorLink": "가격 계산기", + "billingYourPlan": "귀하의 계획", + "billingViewOrModifyPlan": "현재 계획 보기 또는 수정", + "billingViewPlanDetails": "계획 세부정보 보기", + "billingUsageAndLimits": "사용량 및 제한", + "billingViewUsageAndLimits": "계획의 제한 및 현재 사용량 보기", + "billingCurrentUsage": "현재 사용량", + "billingMaximumLimits": "최대 제한", + "billingRemoteNodes": "원격 노드", + "billingUnlimited": "무제한", + "billingPaidLicenseKeys": "유료 라이센스 키", + "billingManageLicenseSubscription": "유료 독립 호스트 라이센스 키를 위한 구독 관리", + "billingCurrentKeys": "현재 키", + "billingModifyCurrentPlan": "현재 계획 수정", + "billingConfirmUpgrade": "업그레이드 확인", + "billingConfirmDowngrade": "다운그레이드 확인", + "billingConfirmUpgradeDescription": "계획을 업그레이드하려고 합니다. 아래의 새로운 제한 및 가격을 검토하세요.", + "billingConfirmDowngradeDescription": "계획을 다운그레이드하려고 합니다. 아래의 새로운 제한 및 가격을 검토하세요.", + "billingPlanIncludes": "계획 포함", + "billingProcessing": "처리 중...", + "billingConfirmUpgradeButton": "업그레이드 확인", + "billingConfirmDowngradeButton": "다운그레이드 확인", + "billingLimitViolationWarning": "사용량이 새 계획의 제한을 초과합니다.", + "billingLimitViolationDescription": "현재 사용량이 이 계획의 제한을 초과합니다. 다운그레이드 후 모든 작업은 새로운 제한 내로 사용량을 줄일 때까지 비활성화됩니다. 현재 초과된 제한 특징들을 검토하세요. 위반된 제한:", + "billingFeatureLossWarning": "기능 가용성 알림", + "billingFeatureLossDescription": "다운그레이드함으로써 새 계획에서 사용할 수 없는 기능은 자동으로 비활성화됩니다. 일부 설정 및 구성은 손실될 수 있습니다. 어떤 기능들이 더 이상 사용 불가능한지 이해하기 위해 가격표를 검토하세요.", + "billingUsageExceedsLimit": "현재 사용량 ({current})이 제한 ({limit})을 초과합니다", + "billingPastDueTitle": "연체된 결제", + "billingPastDueDescription": "결제가 연체되었습니다. 현재 이용 중인 플랜 기능을 계속 사용하기 위해 결제 수단을 업데이트해 주세요. 해결되지 않으면 구독이 취소되고 무료 요금제로 전환됩니다.", + "billingUnpaidTitle": "결제되지 않은 구독", + "billingUnpaidDescription": "구독 결제가 완료되지 않아 무료 요금제로 전환되었습니다. 구독을 복원하려면 결제 수단을 업데이트해 주세요.", + "billingIncompleteTitle": "불완전한 결제", + "billingIncompleteDescription": "결제가 불완전합니다. 구독을 활성화하기 위해 결제 과정을 완료해 주세요.", + "billingIncompleteExpiredTitle": "만료된 결제", + "billingIncompleteExpiredDescription": "결제가 완료되지 않아 만료되었습니다. 무료 요금제로 전환되었습니다. 유료 기능에 대한 액세스를 복원하려면 다시 구독해 주세요.", + "billingManageSubscription": "구독을 관리하십시오", + "billingResolvePaymentIssue": "업그레이드 또는 다운그레이드하기 전에 결제 문제를 해결해 주세요.", "signUpTerms": { "IAgreeToThe": "동의합니다", "termsOfService": "서비스 약관", "and": "및", - "privacyPolicy": "개인 정보 보호 정책" + "privacyPolicy": "개인 정보 보호 정책." }, "signUpMarketing": { "keepMeInTheLoop": "이메일을 통해 소식, 업데이트 및 새로운 기능을 받아보세요." @@ -1508,6 +1640,7 @@ "addNewTarget": "새 대상 추가", "targetsList": "대상 목록", "advancedMode": "고급 모드", + "advancedSettings": "고급 설정", "targetErrorDuplicateTargetFound": "중복 대상 발견", "healthCheckHealthy": "정상", "healthCheckUnhealthy": "비정상", @@ -1529,6 +1662,26 @@ "IntervalSeconds": "정상 간격", "timeoutSeconds": "타임아웃(초)", "timeIsInSeconds": "시간은 초 단위입니다", + "requireDeviceApproval": "장치 승인 요구", + "requireDeviceApprovalDescription": "이 역할을 가진 사용자는 장치가 연결되기 전에 관리자의 승인이 필요합니다.", + "sshAccess": "SSH 접속", + "roleAllowSsh": "SSH 허용", + "roleAllowSshAllow": "허용", + "roleAllowSshDisallow": "허용 안 함", + "roleAllowSshDescription": "이 역할을 가진 사용자가 SSH를 통해 리소스에 연결할 수 있도록 허용합니다. 비활성화되면 역할은 SSH 접속을 사용할 수 없습니다.", + "sshSudoMode": "Sudo 접속", + "sshSudoModeNone": "없음", + "sshSudoModeNoneDescription": "사용자는 sudo로 명령을 실행할 수 없습니다.", + "sshSudoModeFull": "전체 Sudo", + "sshSudoModeFullDescription": "사용자는 모든 명령을 sudo로 실행할 수 있습니다.", + "sshSudoModeCommands": "명령", + "sshSudoModeCommandsDescription": "사용자는 sudo로 지정된 명령만 실행할 수 있습니다.", + "sshSudo": "Sudo 허용", + "sshSudoCommands": "Sudo 명령", + "sshSudoCommandsDescription": "사용자가 sudo로 실행할 수 있는 명령어의 쉼표로 구분된 목록입니다.", + "sshCreateHomeDir": "홈 디렉터리 생성", + "sshUnixGroups": "유닉스 그룹", + "sshUnixGroupsDescription": "대상 호스트에서 사용자에게 추가할 유닉스 그룹의 쉼표로 구분된 목록입니다.", "retryAttempts": "재시도 횟수", "expectedResponseCodes": "예상 응답 코드", "expectedResponseCodesDescription": "정상 상태를 나타내는 HTTP 상태 코드입니다. 비워 두면 200-300이 정상으로 간주됩니다.", @@ -1569,6 +1722,8 @@ "resourcesTableNoInternalResourcesFound": "내부 리소스를 찾을 수 없습니다.", "resourcesTableDestination": "대상지", "resourcesTableAlias": "별칭", + "resourcesTableAliasAddress": "별칭 주소", + "resourcesTableAliasAddressInfo": "이 주소는 조직의 유틸리티 서브넷의 일부로, 내부 DNS 해석을 사용하여 별칭 레코드를 해석하는 데 사용됩니다.", "resourcesTableClients": "클라이언트", "resourcesTableAndOnlyAccessibleInternally": "클라이언트와 연결되었을 때만 내부적으로 접근 가능합니다.", "resourcesTableNoTargets": "대상 없음", @@ -1616,9 +1771,8 @@ "createInternalResourceDialogResourceProperties": "리소스 속성", "createInternalResourceDialogName": "이름", "createInternalResourceDialogSite": "사이트", - "createInternalResourceDialogSelectSite": "사이트 선택...", - "createInternalResourceDialogSearchSites": "사이트 검색...", - "createInternalResourceDialogNoSitesFound": "사이트를 찾을 수 없습니다.", + "selectSite": "사이트 선택...", + "noSitesFound": "사이트를 찾을 수 없습니다.", "createInternalResourceDialogProtocol": "프로토콜", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", @@ -1658,7 +1812,7 @@ "siteAddressDescription": "사이트의 내부 주소. 조직의 서브넷 내에 있어야 합니다.", "siteNameDescription": "나중에 변경할 수 있는 사이트의 표시 이름입니다.", "autoLoginExternalIdp": "외부 IDP로 자동 로그인", - "autoLoginExternalIdpDescription": "인증을 위해 외부 IDP로 사용자를 즉시 리디렉션합니다.", + "autoLoginExternalIdpDescription": "인증을 위해 사용자를 외부 IDP로 즉시 리디렉션합니다.", "selectIdp": "IDP 선택", "selectIdpPlaceholder": "IDP 선택...", "selectIdpRequired": "자동 로그인이 활성화된 경우 IDP를 선택하십시오.", @@ -1670,7 +1824,7 @@ "autoLoginErrorNoRedirectUrl": "ID 공급자로부터 리디렉션 URL을 받지 못했습니다.", "autoLoginErrorGeneratingUrl": "인증 URL 생성 실패.", "remoteExitNodeManageRemoteExitNodes": "원격 노드", - "remoteExitNodeDescription": "하나 이상의 원격 노드를 자체 호스팅하여 네트워크 연결을 확장하고 클라우드에 대한 의존도를 줄입니다.", + "remoteExitNodeDescription": "자체 원격 중계 및 프록시 서버 노드를 호스팅하십시오.", "remoteExitNodes": "노드", "searchRemoteExitNodes": "노드 검색...", "remoteExitNodeAdd": "노드 추가", @@ -1680,20 +1834,22 @@ "remoteExitNodeConfirmDelete": "노드 삭제 확인", "remoteExitNodeDelete": "노드 삭제", "sidebarRemoteExitNodes": "원격 노드", + "remoteExitNodeId": "ID", + "remoteExitNodeSecretKey": "비밀", "remoteExitNodeCreate": { - "title": "노드 생성", - "description": "네트워크 연결성을 확장하기 위해 새 노드를 생성하세요", + "title": "원격 노드 생성", + "description": "새로운 자체 호스팅 원격 중계 및 프록시 서버 노드를 생성하십시오.", "viewAllButton": "모든 노드 보기", "strategy": { "title": "생성 전략", - "description": "노드를 직접 구성하거나 새 자격 증명을 생성하려면 이것을 선택하세요.", + "description": "원격 노드를 생성하는 방법을 선택합니다.", "adopt": { "title": "노드 채택", "description": "이미 노드의 자격 증명이 있는 경우 이것을 선택하세요." }, "generate": { "title": "키 생성", - "description": "노드에 대한 새 키를 생성하려면 이것을 선택하세요" + "description": "노드에 대한 새 키를 생성하려면 이것을 선택하세요." } }, "adopt": { @@ -1806,9 +1962,33 @@ "idpAzureDescription": "Microsoft Azure OAuth2/OIDC 공급자", "subnet": "서브넷", "subnetDescription": "이 조직의 네트워크 구성에 대한 서브넷입니다.", + "customDomain": "사용자 정의 도메인", "authPage": "인증 페이지", - "authPageDescription": "조직에 대한 인증 페이지를 구성합니다.", + "authPageDescription": "조직의 인증 페이지에 대한 사용자 정의 도메인을 설정하세요.", "authPageDomain": "인증 페이지 도메인", + "authPageBranding": "사용자 정의 브랜딩", + "authPageBrandingDescription": "이 조직의 인증 페이지에 표시될 브랜딩을 구성합니다.", + "authPageBrandingUpdated": "인증 페이지 브랜딩이 성공적으로 업데이트되었습니다.", + "authPageBrandingRemoved": "인증 페이지 브랜딩이 성공적으로 제거되었습니다.", + "authPageBrandingRemoveTitle": "인증 페이지 브랜딩 제거", + "authPageBrandingQuestionRemove": "인증 페이지의 브랜딩을 제거하시겠습니까?", + "authPageBrandingDeleteConfirm": "브랜딩 삭제 확인", + "brandingLogoURL": "로고 URL", + "brandingLogoURLOrPath": "로고 URL 또는 경로", + "brandingLogoPathDescription": "URL 또는 로컬 경로를 입력하세요.", + "brandingLogoURLDescription": "로고 이미지에 대한 공용 URL을 입력하십시오.", + "brandingPrimaryColor": "기본 색상", + "brandingLogoWidth": "너비(px)", + "brandingLogoHeight": "높이(px)", + "brandingOrgTitle": "조직 인증 페이지의 제목", + "brandingOrgDescription": "{orgName}은 조직의 이름으로 대체됩니다.", + "brandingOrgSubtitle": "조직 인증 페이지의 부제목", + "brandingResourceTitle": "리소스 인증 페이지의 제목", + "brandingResourceSubtitle": "리소스 인증 페이지의 부제목", + "brandingResourceDescription": "{resourceName} 은 조직의 이름으로 대체됩니다.", + "saveAuthPageDomain": "도메인 저장", + "saveAuthPageBranding": "브랜딩 저장", + "removeAuthPageBranding": "브랜딩 제거", "noDomainSet": "도메인 설정 없음", "changeDomain": "도메인 변경", "selectDomain": "도메인 선택", @@ -1817,7 +1997,7 @@ "setAuthPageDomain": "인증 페이지 도메인 설정", "failedToFetchCertificate": "인증서 가져오기 실패", "failedToRestartCertificate": "인증서 재시작 실패", - "addDomainToEnableCustomAuthPages": "조직의 맞춤 인증 페이지를 활성화하려면 도메인을 추가하세요.", + "addDomainToEnableCustomAuthPages": "사용자는 이 도메인을 사용하여 조직의 로그인 페이지에 액세스하고 리소스 인증을 완료할 수 있습니다.", "selectDomainForOrgAuthPage": "조직 인증 페이지에 대한 도메인을 선택하세요.", "domainPickerProvidedDomain": "제공된 도메인", "domainPickerFreeProvidedDomain": "무료 제공된 도메인", @@ -1832,11 +2012,27 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\"을(를) {domain}에 대해 유효하게 만들 수 없습니다.", "domainPickerSubdomainSanitized": "하위 도메인 정리됨", "domainPickerSubdomainCorrected": "\"{sub}\"이(가) \"{sanitized}\"로 수정되었습니다", - "orgAuthSignInTitle": "조직에 로그인", + "orgAuthSignInTitle": "조직 로그인", "orgAuthChooseIdpDescription": "계속하려면 신원 공급자를 선택하세요.", "orgAuthNoIdpConfigured": "이 조직은 구성된 신원 공급자가 없습니다. 대신 Pangolin 아이덴티티로 로그인할 수 있습니다.", "orgAuthSignInWithPangolin": "Pangolin으로 로그인", + "orgAuthSignInToOrg": "조직에 로그인", + "orgAuthSelectOrgTitle": "조직 로그인", + "orgAuthSelectOrgDescription": "계속하려면 조직 ID를 입력하십시오.", + "orgAuthOrgIdPlaceholder": "your-organization", + "orgAuthOrgIdHelp": "조직의 고유 식별자를 입력하십시오.", + "orgAuthSelectOrgHelp": "조직 ID를 입력하면, SSO 또는 조직 인증 정보를 사용할 수 있는 조직의 로그인 페이지로 이동합니다.", + "orgAuthRememberOrgId": "이 조직 ID 기억하기", + "orgAuthBackToSignIn": "표준 로그인을 통해 돌아가기", + "orgAuthNoAccount": "계정이 없으신가요?", "subscriptionRequiredToUse": "이 기능을 사용하려면 구독이 필요합니다.", + "mustUpgradeToUse": "이 기능을 사용하려면 구독을 업그레이드해야 합니다.", + "subscriptionRequiredTierToUse": "이 기능을 사용하려면 {tier} 이상의 등급이 필요합니다.", + "upgradeToTierToUse": "이 기능을 사용하려면 {tier} 이상으로 업그레이드하세요.", + "subscriptionTierTier1": "홈", + "subscriptionTierTier2": "팀", + "subscriptionTierTier3": "비즈니스", + "subscriptionTierEnterprise": "기업", "idpDisabled": "신원 공급자가 비활성화되었습니다.", "orgAuthPageDisabled": "조직 인증 페이지가 비활성화되었습니다.", "domainRestartedDescription": "도메인 인증이 성공적으로 재시작되었습니다.", @@ -1850,6 +2046,8 @@ "enableTwoFactorAuthentication": "이중 인증 활성화", "completeSecuritySteps": "보안 단계 완료", "securitySettings": "보안 설정", + "dangerSection": "위험 구역", + "dangerSectionDescription": "이 조직에 관련된 모든 데이터를 영구적으로 삭제합니다.", "securitySettingsDescription": "조직의 보안 정책을 구성하세요", "requireTwoFactorForAllUsers": "모든 사용자에 대해 이중 인증 요구", "requireTwoFactorDescription": "활성화되면, 이 조직의 모든 내부 사용자는 조직에 접근하기 위해 이중 인증을 활성화해야 합니다.", @@ -1887,7 +2085,7 @@ "securityPolicyChangeWarningText": "이 작업은 조직의 모든 사용자에게 영향을 미칩니다", "authPageErrorUpdateMessage": "인증 페이지 설정을 업데이트하는 동안 오류가 발생했습니다", "authPageErrorUpdate": "인증 페이지를 업데이트할 수 없습니다", - "authPageUpdated": "인증 페이지가 성공적으로 업데이트되었습니다", + "authPageDomainUpdated": "인증 페이지 도메인이 성공적으로 업데이트되었습니다.", "healthCheckNotAvailable": "로컬", "rewritePath": "경로 재작성", "rewritePathDescription": "대상으로 전달하기 전에 경로를 선택적으로 재작성합니다.", @@ -1915,8 +2113,15 @@ "beta": "베타", "manageUserDevices": "사용자 초대를 제어", "manageUserDevicesDescription": "리소스에 개인적으로 연결하기 위해 사용자가 사용하는 장치를 보고 관리하세요", + "downloadClientBannerTitle": "Pangolin 클라이언트 다운로드", + "downloadClientBannerDescription": "Pangolin 네트워크에 연결하고 리소스를 비공개로 접근하기위해 시스템에 맞는 Pangolin 클라이언트를 다운로드하십시오.", "manageMachineClients": "기계 클라이언트 관리", "manageMachineClientsDescription": "서버와 시스템이 리소스에 개인적으로 연결하는 데 사용하는 클라이언트를 생성하고 관리하십시오", + "machineClientsBannerTitle": "서버 및 자동 시스템", + "machineClientsBannerDescription": "머신 클라이언트는 특정 사용자와 연결되지 않은 서버 및 자동화된 시스템을 위한 것입니다. 이들은 ID와 비밀을 통해 인증하며, Pangolin CLI, Olm CLI, 또는 Olm 컨테이너로 실행될 수 있습니다.", + "machineClientsBannerPangolinCLI": "Pangolin CLI", + "machineClientsBannerOlmCLI": "Olm CLI", + "machineClientsBannerOlmContainer": "Olm 컨테이너", "clientsTableUserClients": "사용자", "clientsTableMachineClients": "기계", "licenseTableValidUntil": "유효 기한", @@ -2015,6 +2220,32 @@ } } }, + "newPricingLicenseForm": { + "title": "라이센스 가져오기", + "description": "계획을 선택하고 Pangolin을 어떻게 사용할지 알려주세요.", + "chooseTier": "계획 선택", + "viewPricingLink": "가격, 기능 및 제한 보기", + "tiers": { + "starter": { + "title": "스타터", + "description": "기업 기능, 25명의 사용자, 25개의 사이트, 커뮤니티 지원." + }, + "scale": { + "title": "스케일", + "description": "기업 기능, 50명의 사용자, 50개의 사이트, 우선 지원." + } + }, + "personalUseOnly": "개인 사용 전용 (무료 라이센스 — 체크아웃 없음)", + "buttons": { + "continueToCheckout": "결제로 진행" + }, + "toasts": { + "checkoutError": { + "title": "체크아웃 오류", + "description": "체크아웃을 시작할 수 없습니다. 다시 시도하세요." + } + } + }, "priority": "우선순위", "priorityDescription": "우선 순위가 높은 경로가 먼저 평가됩니다. 우선 순위 = 100은 자동 정렬(시스템 결정)이 의미합니다. 수동 우선 순위를 적용하려면 다른 숫자를 사용하세요.", "instanceName": "인스턴스 이름", @@ -2060,13 +2291,15 @@ "request": "요청", "requests": "요청", "logs": "로그", - "logsSettingsDescription": "이 조직에서 수집된 로그를 모니터링합니다", + "logsSettingsDescription": "이 조직에서 수집된 로그를 모니터링합니다.", "searchLogs": "로그 검색...", "action": "작업", "actor": "행위자", "timestamp": "타임스탬프", "accessLogs": "접근 로그", "exportCsv": "CSV 내보내기", + "exportError": "CSV로 내보내는 중 알 수 없는 오류가 발생했습니다.", + "exportCsvTooltip": "시간 범위 내", "actorId": "행위자 ID", "allowedByRule": "룰에 의해 허용됨", "allowedNoAuth": "인증 없음 허용됨", @@ -2111,7 +2344,8 @@ "logRetentionEndOfFollowingYear": "다음 연도 말", "actionLogsDescription": "이 조직에서 수행된 작업의 기록을 봅니다", "accessLogsDescription": "이 조직의 자원에 대한 접근 인증 요청을 확인합니다", - "licenseRequiredToUse": "이 기능을 사용하려면 Enterprise 라이선스가 필요합니다.", + "licenseRequiredToUse": "이 기능을 사용하려면 엔터프라이즈 에디션 라이선스가 필요합니다. 이 기능은 판골린 클라우드에서도 사용할 수 있습니다. 데모 또는 POC 체험을 예약하세요.", + "ossEnterpriseEditionRequired": "이 기능을 사용하려면 엔터프라이즈 에디션이(가) 필요합니다. 이 기능은 판골린 클라우드에서도 사용할 수 있습니다. 데모 또는 POC 체험을 예약하세요.", "certResolver": "인증서 해결사", "certResolverDescription": "이 리소스에 사용할 인증서 해결사를 선택하세요.", "selectCertResolver": "인증서 해결사 선택", @@ -2120,7 +2354,7 @@ "unverified": "검증되지 않음", "domainSetting": "도메인 설정", "domainSettingDescription": "도메인 설정 구성", - "preferWildcardCertDescription": "와일드카드 인증서를 생성하려고 시도합니다 (올바르게 구성된 인증서 해결사가 필요합니다).", + "preferWildcardCertDescription": "와일드카드 인증서를 생성하려고 시도합니다(적절한 인증서 해결 장치가 필요).", "recordName": "레코드 이름", "auto": "자동", "TTL": "TTL", @@ -2172,6 +2406,8 @@ "deviceCodeInvalidFormat": "코드는 9자리여야 합니다 (예: A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "무효하거나 만료된 코드", "deviceCodeVerifyFailed": "이메일 확인에 실패했습니다:", + "deviceCodeValidating": "장치 코드 검증 중...", + "deviceCodeVerifying": "장치 권한 검증 중...", "signedInAs": "로그인한 사용자", "deviceCodeEnterPrompt": "기기에 표시된 코드를 입력하세요", "continue": "계속 진행하기", @@ -2184,7 +2420,7 @@ "deviceOrganizationsAccess": "계정이 접근할 수 있는 모든 조직에 대한 접근", "deviceAuthorize": "{applicationName} 권한 부여", "deviceConnected": "장치가 연결되었습니다!", - "deviceAuthorizedMessage": "장치가 계정에 액세스할 수 있도록 승인되었습니다.", + "deviceAuthorizedMessage": "장치가 계정 접속을 승인받았습니다. 클라이언트 응용프로그램으로 돌아가세요.", "pangolinCloud": "판골린 클라우드", "viewDevices": "장치 보기", "viewDevicesDescription": "연결된 장치를 관리하십시오", @@ -2246,6 +2482,7 @@ "identifier": "식별자", "deviceLoginUseDifferentAccount": "본인이 아닙니까? 다른 계정을 사용하세요.", "deviceLoginDeviceRequestingAccessToAccount": "장치가 이 계정에 접근하려고 합니다.", + "loginSelectAuthenticationMethod": "계속하려면 인증 방법을 선택하세요.", "noData": "데이터 없음", "machineClients": "기계 클라이언트", "install": "설치", @@ -2255,6 +2492,8 @@ "setupFailedToFetchSubnet": "기본값 로드 실패", "setupSubnetAdvanced": "서브넷(고급)", "setupSubnetDescription": "이 조직의 네트워크 구성에 대한 서브넷입니다.", + "setupUtilitySubnet": "유틸리티 서브넷 (고급)", + "setupUtilitySubnetDescription": "이 조직의 별칭 주소 및 DNS 서버에 대한 서브넷입니다.", "siteRegenerateAndDisconnect": "재생성 및 연결 해제", "siteRegenerateAndDisconnectConfirmation": "자격 증명을 재생성하고 이 사이트와의 연결을 해제하시겠습니까?", "siteRegenerateAndDisconnectWarning": "이 과정은 자격 증명을 다시 생성하고 사이트와의 연결을 즉시 해제합니다. 사이트는 새 자격 증명으로 다시 시작되어야 합니다.", @@ -2270,5 +2509,179 @@ "remoteExitNodeRegenerateAndDisconnectWarning": "이 과정은 자격 증명을 다시 생성하고 원격 종료 노드와의 연결을 즉시 해제합니다. 원격 종료 노드는 새 자격 증명으로 다시 시작되어야 합니다.", "remoteExitNodeRegenerateCredentialsConfirmation": "이 원격 종료 노드에 대한 자격 증명을 다시 생성하시겠습니까?", "remoteExitNodeRegenerateCredentialsWarning": "이 과정은 자격 증명을 다시 생성합니다. 수동으로 다시 시작하고 새 자격 증명을 사용하기 전까지 원격 종료 노드는 연결된 상태로 유지됩니다.", - "agent": "에이전트" + "agent": "에이전트", + "personalUseOnly": "개인 용도로만 사용", + "loginPageLicenseWatermark": "이 인스턴스는 개인 용도로만 라이선스가 부여되었습니다.", + "instanceIsUnlicensed": "이 인스턴스에는 라이선스가 없습니다.", + "portRestrictions": "포트 제한", + "allPorts": "모두", + "custom": "사용자 정의", + "allPortsAllowed": "모든 포트 허용", + "allPortsBlocked": "모든 포트 차단", + "tcpPortsDescription": "이 리소스에 허용된 TCP 포트를 지정하세요. 모든 포트에 '*'를 사용하고, 모든 포트를 차단하려면 비워두거나 쉼표로 구분된 포트 및 범위 목록(예: 80,443,8000-9000)을 입력하십시오.", + "udpPortsDescription": "이 리소스에 허용된 UDP 포트를 지정하세요. 모든 포트에 '*'를 사용하고, 모든 포트를 차단하려면 비워두거나 쉼표로 구분된 포트 및 범위 목록(예: 53,123,500-600)을 입력하십시오.", + "organizationLoginPageTitle": "조직 로그인 페이지", + "organizationLoginPageDescription": "이 조직의 로그인 페이지를 사용자 정의합니다.", + "resourceLoginPageTitle": "리소스 로그인 페이지", + "resourceLoginPageDescription": "각 리소스의 로그인 페이지를 사용자 정의합니다.", + "enterConfirmation": "확인 입력", + "blueprintViewDetails": "세부 정보", + "defaultIdentityProvider": "기본 아이덴티티 공급자", + "defaultIdentityProviderDescription": "기본 ID 공급자가 선택되면, 사용자는 인증을 위해 자동으로 해당 공급자로 리디렉션됩니다.", + "editInternalResourceDialogNetworkSettings": "네트워크 설정", + "editInternalResourceDialogAccessPolicy": "액세스 정책", + "editInternalResourceDialogAddRoles": "역할 추가", + "editInternalResourceDialogAddUsers": "사용자 추가", + "editInternalResourceDialogAddClients": "클라이언트 추가", + "editInternalResourceDialogDestinationLabel": "대상지", + "editInternalResourceDialogDestinationDescription": "내부 리소스의 목적지 주소를 지정하세요. 선택한 모드에 따라 이 주소는 호스트명, IP 주소, 또는 CIDR 범위가 될 수 있습니다. 더욱 쉽게 식별할 수 있도록 내부 DNS 별칭을 설정할 수 있습니다.", + "editInternalResourceDialogPortRestrictionsDescription": "특정 TCP/UDP 포트에 대한 접근을 제한하거나 모든 포트를 허용/차단하십시오.", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "액세스 제어", + "editInternalResourceDialogAccessControlDescription": "연결 시 이 리소스에 대한 액세스 권한을 가지는 역할, 사용자, 그리고 머신 클라이언트를 제어합니다. 관리자는 항상 접근할 수 있습니다.", + "editInternalResourceDialogPortRangeValidationError": "모든 포트에 대해서는 \"*\"로, 아니면 쉼표로 구분된 포트 및 범위 목록(예: \"80,443,8000-9000\")을 설정해야 합니다. 포트는 1에서 65535 사이여야 합니다.", + "internalResourceAuthDaemonStrategy": "SSH 인증 데몬 위치", + "internalResourceAuthDaemonStrategyDescription": "SSH 인증 데몬이 작동하는 위치를 선택하세요: 사이트(Newt)에서 또는 원격 호스트에서.", + "internalResourceAuthDaemonDescription": "SSH 인증 데몬은 이 리소스를 위한 SSH 키 서명과 PAM 인증을 처리합니다. 사이트(Newt)에서 나 별도의 원격 호스트에서 실행할 것인지를 선택하세요. 자세한 내용은 문서를 참조하세요.", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "전략 선택", + "internalResourceAuthDaemonStrategyLabel": "위치", + "internalResourceAuthDaemonSite": "사이트에서 인증 데몬이 실행됩니다(Newt).", + "internalResourceAuthDaemonSiteDescription": "인증 데몬이 사이트(Newt)에서 실행됩니다.", + "internalResourceAuthDaemonRemote": "원격 호스트", + "internalResourceAuthDaemonRemoteDescription": "인증 데몬이 사이트가 아닌 다른 호스트에서 실행됩니다.", + "internalResourceAuthDaemonPort": "데몬 포트 (선택 사항)", + "orgAuthWhatsThis": "조직 ID를 어디에서 찾을 수 있습니까?", + "learnMore": "자세히 알아보기", + "backToHome": "홈으로 돌아가기", + "needToSignInToOrg": "조직의 아이덴티티 공급자를 사용해야 합니까?", + "maintenanceMode": "유지보수 모드", + "maintenanceModeDescription": "방문자에게 유지보수 페이지 표시", + "maintenanceModeType": "유지보수 모드 유형", + "showMaintenancePage": "방문자에게 유지보수 페이지 표시", + "enableMaintenanceMode": "유지보수 모드 활성화", + "automatic": "자동", + "automaticModeDescription": "백엔드 타깃이 모두 다운되거나 건강하지 않을 때만 유지보수 페이지를 표시합니다. 적어도 하나의 타깃이 건강한 한 리소스는 정상 작동합니다.", + "forced": "강제", + "forcedModeDescription": "백엔드 상태와 무관하게 항상 유지보수 페이지를 표시하십시오. 모든 접근을 차단하려는 계획된 유지보수 시 사용하세요.", + "warning:": "경고:", + "forcedeModeWarning": "모든 트래픽이 유지보수 페이지로 전달됩니다. 백엔드 리소스는 어떠한 요청도 받지 않습니다.", + "pageTitle": "페이지 제목", + "pageTitleDescription": "유지보수 페이지에 표시될 주요 제목", + "maintenancePageMessage": "유지보수 메시지", + "maintenancePageMessagePlaceholder": "곧 돌아오겠습니다! 사이트는 현재 예정된 유지보수를 진행 중입니다.", + "maintenancePageMessageDescription": "유지보수를 설명하는 상세 메시지", + "maintenancePageTimeTitle": "예상 완료 시간(선택 사항)", + "maintenanceTime": "예: 2시간, 11월 1일 오후 5시", + "maintenanceEstimatedTimeDescription": "유지보수가 완료될 것으로 예상되는 시간", + "editDomain": "도메인 수정", + "editDomainDescription": "리소스를 위한 도메인을 선택하십시오.", + "maintenanceModeDisabledTooltip": "이 기능을 사용하려면 유효한 라이선스가 필요합니다.", + "maintenanceScreenTitle": "서비스 일시 중단", + "maintenanceScreenMessage": "현재 기술적 문제를 겪고 있습니다. 곧 다시 확인하십시오.", + "maintenanceScreenEstimatedCompletion": "예상 완료:", + "createInternalResourceDialogDestinationRequired": "목적지가 필요합니다.", + "available": "사용 가능", + "archived": "보관된", + "noArchivedDevices": "보관된 장치가 없습니다.", + "deviceArchived": "장치가 보관되었습니다.", + "deviceArchivedDescription": "장치가 성공적으로 보관되었습니다.", + "errorArchivingDevice": "장치를 보관하는 동안 오류가 발생했습니다.", + "failedToArchiveDevice": "장치를 보관하는 데 실패했습니다.", + "deviceQuestionArchive": "이 장치를 보관하시겠습니까?", + "deviceMessageArchive": "장치가 보관되며 당신의 활성 장치 목록에서 제거됩니다.", + "deviceArchiveConfirm": "장치 보관", + "archiveDevice": "장치 보관", + "archive": "보관", + "deviceUnarchived": "장치의 보관이 취소되었습니다.", + "deviceUnarchivedDescription": "장치의 보관이 성공적으로 취소되었습니다.", + "errorUnarchivingDevice": "장치 보관 해제 중 오류가 발생했습니다.", + "failedToUnarchiveDevice": "장치 보관 해제 실패", + "unarchive": "보관 해제", + "archiveClient": "클라이언트 보관", + "archiveClientQuestion": "이 클라이언트를 보관하시겠습니까?", + "archiveClientMessage": "클라이언트가 보관되며 당신의 활성 클라이언트 목록에서 제거됩니다.", + "archiveClientConfirm": "클라이언트 보관 확인", + "blockClient": "클라이언트 차단", + "blockClientQuestion": "이 클라이언트를 차단하시겠습니까?", + "blockClientMessage": "장치가 현재 연결되어 있는 경우 강제로 연결이 해제됩니다. 이후에도 차단 해제가 가능합니다.", + "blockClientConfirm": "클라이언트 차단 확인", + "active": "활성", + "usernameOrEmail": "사용자 이름 또는 이메일", + "selectYourOrganization": "조직 선택", + "signInTo": "로그인 중", + "signInWithPassword": "비밀번호로 계속", + "noAuthMethodsAvailable": "이 조직에는 사용할 수 있는 인증 방법이 없습니다.", + "enterPassword": "비밀번호를 입력하세요.", + "enterMfaCode": "인증 앱에서 제공한 코드를 입력하세요.", + "securityKeyRequired": "보안 키를 사용해 로그인하세요.", + "needToUseAnotherAccount": "다른 계정을 사용해야 합니까?", + "loginLegalDisclaimer": "아래 버튼을 클릭하여 서비스 약관개인 정보 보호 정책을 읽고 이해했으며 동의함을 인정합니다.", + "termsOfService": "서비스 약관", + "privacyPolicy": "개인 정보 보호 정책", + "userNotFoundWithUsername": "해당 사용자 이름으로 사용자를 찾지 못했습니다.", + "verify": "확인", + "signIn": "로그인", + "forgotPassword": "비밀번호를 잊으셨나요?", + "orgSignInTip": "이전에 로그인한 적이 있다면, 위의 사용자 이름 또는 이메일을 입력하여 조직의 ID 공급자로 인증할 수 있습니다. 더 쉬워요!", + "continueAnyway": "계속하기", + "dontShowAgain": "다시 보기 않습니다.", + "orgSignInNotice": "아셨나요?", + "signupOrgNotice": "로그인 중이신가요?", + "signupOrgTip": "조직의 ID 공급자를 통해 로그인하려고 하십니까?", + "signupOrgLink": "대신 조직을 사용하여 로그인 또는 가입", + "verifyEmailLogInWithDifferentAccount": "다른 계정 사용", + "logIn": "로그인", + "deviceInformation": "장치 정보", + "deviceInformationDescription": "장치와 에이전트 정보", + "deviceSecurity": "디바이스 보안", + "deviceSecurityDescription": "디바이스 보안 상태 정보", + "platform": "플랫폼", + "macosVersion": "macOS 버전", + "windowsVersion": "Windows 버전", + "iosVersion": "iOS 버전", + "androidVersion": "Android 버전", + "osVersion": "OS 버전", + "kernelVersion": "커널 버전", + "deviceModel": "장치 모델", + "serialNumber": "일련 번호", + "hostname": "호스트 이름", + "firstSeen": "처음 발견됨", + "lastSeen": "마지막으로 발견됨", + "biometricsEnabled": "생체 인식 활성화", + "diskEncrypted": "디스크 암호화됨", + "firewallEnabled": "방화벽 활성화", + "autoUpdatesEnabled": "자동 업데이트 활성화", + "tpmAvailable": "TPM 사용 가능", + "windowsAntivirusEnabled": "안티바이러스 활성화됨", + "macosSipEnabled": "시스템 무결성 보호 (SIP)", + "macosGatekeeperEnabled": "Gatekeeper", + "macosFirewallStealthMode": "방화벽 스텔스 모드", + "linuxAppArmorEnabled": "AppArmor", + "linuxSELinuxEnabled": "SELinux", + "deviceSettingsDescription": "장치 정보 및 설정 보기", + "devicePendingApprovalDescription": "이 장치는 승인을 기다리고 있습니다.", + "deviceBlockedDescription": "이 장치는 현재 차단되었습니다. 차단이 해제되지 않으면 리소스에 연결할 수 없습니다.", + "unblockClient": "클라이언트 차단 해제", + "unblockClientDescription": "장치가 차단 해제되었습니다.", + "unarchiveClient": "클라이언트 보관 취소", + "unarchiveClientDescription": "장치가 보관 해제되었습니다.", + "block": "차단", + "unblock": "차단 해제", + "deviceActions": "장치 작업", + "deviceActionsDescription": "장치 상태 및 접근 관리", + "devicePendingApprovalBannerDescription": "이 장치는 승인 대기 중입니다. 승인될 때까지 리소스에 연결할 수 없습니다.", + "connected": "연결됨", + "disconnected": "연결 해제됨", + "approvalsEmptyStateTitle": "장치 승인 비활성화됨", + "approvalsEmptyStateDescription": "사용자가 새 장치를 연결하기 전에 관리자의 승인을 필요로 하도록 역할에 대해 장치 승인을 활성화하세요.", + "approvalsEmptyStateStep1Title": "역할로 이동", + "approvalsEmptyStateStep1Description": "조직의 역할 설정으로 이동하여 장치 승인을 구성하십시오.", + "approvalsEmptyStateStep2Title": "장치 승인 활성화", + "approvalsEmptyStateStep2Description": "역할을 편집하고 '장치 승인 요구' 옵션을 활성화하세요. 이 역할을 가진 사용자는 새 장치에 대해 관리자의 승인이 필요합니다.", + "approvalsEmptyStatePreviewDescription": "미리 보기: 활성화된 경우, 승인 대기 중인 장치 요청이 검토용으로 여기에 표시됩니다.", + "approvalsEmptyStateButtonText": "역할 관리", + "domainErrorTitle": "도메인 확인에 문제가 발생했습니다." } diff --git a/messages/nb-NO.json b/messages/nb-NO.json index ec7553b67..50ec9a717 100644 --- a/messages/nb-NO.json +++ b/messages/nb-NO.json @@ -1,5 +1,7 @@ { "setupCreate": "Opprett organisasjonen, nettstedet og ressursene", + "headerAuthCompatibilityInfo": "Aktiver dette for å tvinge frem en 401 Uautorisert-respons når en autentiseringstoken mangler. Dette kreves for nettlesere eller spesifikke HTTP-biblioteker som ikke sender legitimasjon uten en serverutfordring.", + "headerAuthCompatibility": "Utvidet kompatibilitet", "setupNewOrg": "Ny Organisasjon", "setupCreateOrg": "Opprett organisasjon", "setupCreateResources": "Opprett ressurser", @@ -16,6 +18,8 @@ "componentsMember": "Du er {count, plural, =0 {ikke medlem av noen organisasjoner} one {medlem av en organisasjon} other {medlem av # organisasjoner}}.", "componentsInvalidKey": "Ugyldig eller utgått lisensnøkkel oppdaget. Følg lisensvilkårene for å fortsette å kunne bruke alle funksjonene.", "dismiss": "Avvis", + "subscriptionViolationMessage": "Du er utenfor grensen for gjeldende plan. Rett problemet ved å fjerne nettsteder, brukere eller andre ressurser for å bli innenfor planen din.", + "subscriptionViolationViewBilling": "Vis fakturering", "componentsLicenseViolation": "Lisens Brudd: Denne serveren bruker {usedSites} områder som overskrider den lisensierte grenser av {maxSites} områder. Følg lisensvilkårene for å fortsette å kunne bruke alle funksjonene.", "componentsSupporterMessage": "Takk for at du støtter Pangolin som en {tier}!", "inviteErrorNotValid": "Beklager, men det ser ut som invitasjonen du prøver å bruke ikke har blitt akseptert eller ikke er gyldig lenger.", @@ -33,7 +37,7 @@ "password": "Passord", "confirmPassword": "Bekreft Passord", "createAccount": "Opprett Konto", - "viewSettings": "Vis Innstillinger", + "viewSettings": "Vis innstillinger", "delete": "Slett", "name": "Navn", "online": "Online", @@ -51,6 +55,12 @@ "siteQuestionRemove": "Er du sikker på at du vil fjerne nettstedet fra organisasjonen?", "siteManageSites": "Administrer Områder", "siteDescription": "Opprette og administrere nettsteder for å aktivere tilkobling til private nettverk", + "sitesBannerTitle": "Koble til alle nettverk", + "sitesBannerDescription": "Et nettverk er en tilkobling til et eksternt nettverk som tillater Pangolin å gi tilgang til ressurser, enten offentlige eller private, til brukere hvor som helst. Installer nettverkskontaktet (Newt) hvor som helst du kan kjøre en binærfil eller container for å opprette forbindelsen.", + "sitesBannerButtonText": "Installer nettsted", + "approvalsBannerTitle": "Godkjenn eller avslå tilgang til enhet", + "approvalsBannerDescription": "Gjennomgå og godkjenne eller avslå forespørsler om tilgang fra brukere. Når enhetsgodkjenninger er nødvendig, må brukere få admingodkjenning før enhetene kan koble seg til organisasjonens ressurser.", + "approvalsBannerButtonText": "Lær mer", "siteCreate": "Opprett område", "siteCreateDescription2": "Følg trinnene nedenfor for å opprette og koble til et nytt område", "siteCreateDescription": "Opprett et nytt nettsted for å koble til ressurser", @@ -100,6 +110,7 @@ "siteTunnelDescription": "Avgjør hvordan du vil koble deg til nettstedet", "siteNewtCredentials": "Legitimasjon", "siteNewtCredentialsDescription": "Dette er hvordan nettstedet vil godkjenne med serveren", + "remoteNodeCredentialsDescription": "Slik vil den eksterne noden autentisere seg med serveren", "siteCredentialsSave": "Lagre brukeropplysninger", "siteCredentialsSaveDescription": "Du vil kun kunne se dette én gang. Sørg for å kopiere det til et sikkert sted.", "siteInfo": "Områdeinformasjon", @@ -146,8 +157,12 @@ "shareErrorSelectResource": "Vennligst velg en ressurs", "proxyResourceTitle": "Administrere offentlige ressurser", "proxyResourceDescription": "Opprett og administrer ressurser som er offentlig tilgjengelige via en nettleser", + "proxyResourcesBannerTitle": "Nettbasert offentlig tilgang", + "proxyResourcesBannerDescription": "Offentlige ressurser er HTTPS- eller TCP/UDP-proxyer tilgjengelige for alle på internett via en nettleser. I motsetning til private ressurser, krever de ikke klient-basert programvare og kan inkludere identitets- og kontekstbevisste tilgangspolicyer.", "clientResourceTitle": "Administrer private ressurser", "clientResourceDescription": "Opprette og administrere ressurser som bare er tilgjengelige via en tilkoblet klient", + "privateResourcesBannerTitle": "Zero-Trust privat tilgang", + "privateResourcesBannerDescription": "Private ressurser bruker Zero-Trust-sikkerhet, og sikrer at brukere og maskiner kun kan få tilgang til ressurser du eksplisitt gir tillatelse til. Koble bruker-enheter eller maskinklienter for å få tilgang til disse ressursene via et sikkert virtuelt privat nettverk.", "resourcesSearch": "Søk i ressurser...", "resourceAdd": "Legg til ressurs", "resourceErrorDelte": "Feil ved sletting av ressurs", @@ -157,9 +172,10 @@ "resourceMessageRemove": "Når den er fjernet, vil ressursen ikke lenger være tilgjengelig. Alle mål knyttet til ressursen vil også bli fjernet.", "resourceQuestionRemove": "Er du sikker på at du vil fjerne ressursen fra organisasjonen?", "resourceHTTP": "HTTPS-ressurs", - "resourceHTTPDescription": "Proxy-forespørsler til appen over HTTPS ved hjelp av et underdomene eller basisdomene.", + "resourceHTTPDescription": "Proxy forespørsler over HTTPS ved å bruke et fullstendig kvalifisert domenenavn.", "resourceRaw": "Rå TCP/UDP-ressurs", - "resourceRawDescription": "Proxy ber om til appen over TCP/UDP med et portnummer. Dette fungerer bare når nettsteder er koblet til noder.", + "resourceRawDescription": "Proxy forespørsler over rå TCP/UDP ved å bruke et portnummer.", + "resourceRawDescriptionCloud": "Proxy forespørsler om rå TCP/UDP ved hjelp av et portnummer. Krever sider for å koble til en ekstern node.", "resourceCreate": "Opprett ressurs", "resourceCreateDescription": "Følg trinnene nedenfor for å opprette en ny ressurs", "resourceSeeAll": "Se alle ressurser", @@ -186,6 +202,7 @@ "protocolSelect": "Velg en protokoll", "resourcePortNumber": "Portnummer", "resourcePortNumberDescription": "Det eksterne portnummeret for proxy forespørsler.", + "back": "Tilbake", "cancel": "Avbryt", "resourceConfig": "Konfigurasjonsutdrag", "resourceConfigDescription": "Kopier og lim inn disse konfigurasjons-øyeblikkene for å sette opp TCP/UDP ressursen", @@ -231,6 +248,17 @@ "orgErrorDeleteMessage": "Det oppsto en feil under sletting av organisasjonen.", "orgDeleted": "Organisasjon slettet", "orgDeletedMessage": "Organisasjonen og tilhørende data er slettet.", + "deleteAccount": "Slett konto", + "deleteAccountDescription": "Slett kontoen din permanent, alle organisasjoner du eier, og alle data i disse organisasjonene. Dette kan ikke angres.", + "deleteAccountButton": "Slett konto", + "deleteAccountConfirmTitle": "Slett konto", + "deleteAccountConfirmMessage": "Dette vil slette kontoen din, alle organisasjoner du eier og alle data i disse organisasjonene. Dette kan ikke gjøres om.", + "deleteAccountConfirmString": "Slett konto", + "deleteAccountSuccess": "Kontoen er slettet", + "deleteAccountSuccessMessage": "Kontoen din er slettet.", + "deleteAccountError": "Kunne ikke slette konto", + "deleteAccountPreviewAccount": "Din konto", + "deleteAccountPreviewOrgs": "Organisasjoner du eier (og alle deres data)", "orgMissing": "Organisasjons-ID Mangler", "orgMissingMessage": "Kan ikke regenerere invitasjon uten en organisasjons-ID.", "accessUsersManage": "Administrer brukere", @@ -247,6 +275,8 @@ "accessRolesSearch": "Søk etter roller...", "accessRolesAdd": "Legg til rolle", "accessRoleDelete": "Slett rolle", + "accessApprovalsManage": "Behandle godkjenninger", + "accessApprovalsDescription": "Se og administrer ventende godkjenninger for tilgang til denne organisasjonen", "description": "Beskrivelse", "inviteTitle": "Åpne invitasjoner", "inviteDescription": "Administrer invitasjoner til andre brukere for å bli med i organisasjonen", @@ -440,6 +470,20 @@ "selectDuration": "Velg varighet", "selectResource": "Velg ressurs", "filterByResource": "Filtrer etter ressurser", + "selectApprovalState": "Velg godkjenningsstatus", + "filterByApprovalState": "Filtrer etter godkjenningsstatus", + "approvalListEmpty": "Ingen godkjenninger", + "approvalState": "Godkjennings tilstand", + "approvalLoadMore": "Last mer", + "loadingApprovals": "Laster inn godkjenninger", + "approve": "Godkjenn", + "approved": "Godkjent", + "denied": "Avvist", + "deniedApproval": "Avslått godkjenning", + "all": "Alle", + "deny": "Avslå", + "viewDetails": "Vis detaljer", + "requestingNewDeviceApproval": "forespurt en ny enhet", "resetFilters": "Tilbakestill filtre", "totalBlocked": "Forespørsler blokkert av Pangolin", "totalRequests": "Totalt antall forespørsler", @@ -607,6 +651,7 @@ "resourcesErrorUpdate": "Feilet å slå av/på ressurs", "resourcesErrorUpdateDescription": "En feil oppstod under oppdatering av ressursen", "access": "Tilgang", + "accessControl": "Tilgangskontroll", "shareLink": "{resource} Del Lenke", "resourceSelect": "Velg ressurs", "shareLinks": "Del lenker", @@ -687,7 +732,7 @@ "resourceRoleDescription": "Administratorer har alltid tilgang til denne ressursen.", "resourceUsersRoles": "Tilgangskontroller", "resourceUsersRolesDescription": "Konfigurer hvilke brukere og roller som har tilgang til denne ressursen", - "resourceUsersRolesSubmit": "Lagre brukere og roller", + "resourceUsersRolesSubmit": "Lagre tilgangskontroller", "resourceWhitelistSave": "Lagring vellykket", "resourceWhitelistSaveDescription": "Hvitlisteinnstillinger er lagret", "ssoUse": "Bruk plattform SSO", @@ -719,22 +764,35 @@ "countries": "Land", "accessRoleCreate": "Opprett rolle", "accessRoleCreateDescription": "Opprett en ny rolle for å gruppere brukere og administrere deres tillatelser.", + "accessRoleEdit": "Rediger rolle", + "accessRoleEditDescription": "Rediger rolleinformasjon.", "accessRoleCreateSubmit": "Opprett rolle", "accessRoleCreated": "Rolle opprettet", "accessRoleCreatedDescription": "Rollen er vellykket opprettet.", "accessRoleErrorCreate": "Klarte ikke å opprette rolle", "accessRoleErrorCreateDescription": "Det oppstod en feil under opprettelse av rollen.", + "accessRoleUpdateSubmit": "Oppdater rolle", + "accessRoleUpdated": "Rollen oppdatert", + "accessRoleUpdatedDescription": "Rollen har blitt oppdatert.", + "accessApprovalUpdated": "Godkjenning behandlet", + "accessApprovalApprovedDescription": "Sett godkjenningsforespørsel om å godta.", + "accessApprovalDeniedDescription": "Sett godkjenningsforespørsel om å nekte.", + "accessRoleErrorUpdate": "Kunne ikke oppdatere rolle", + "accessRoleErrorUpdateDescription": "Det oppstod en feil under oppdatering av rollen.", + "accessApprovalErrorUpdate": "Kunne ikke behandle godkjenning", + "accessApprovalErrorUpdateDescription": "Det oppstod en feil under behandling av godkjenningen.", "accessRoleErrorNewRequired": "Ny rolle kreves", "accessRoleErrorRemove": "Kunne ikke fjerne rolle", "accessRoleErrorRemoveDescription": "Det oppstod en feil under fjerning av rollen.", "accessRoleName": "Rollenavn", - "accessRoleQuestionRemove": "Du er i ferd med å slette rollen {name}. Du kan ikke angre denne handlingen.", + "accessRoleQuestionRemove": "Du er ferd med å slette rollen `{name}. Du kan ikke angre denne handlingen.", "accessRoleRemove": "Fjern Rolle", "accessRoleRemoveDescription": "Fjern en rolle fra organisasjonen", "accessRoleRemoveSubmit": "Fjern Rolle", "accessRoleRemoved": "Rolle fjernet", "accessRoleRemovedDescription": "Rollen er vellykket fjernet.", "accessRoleRequiredRemove": "Før du sletter denne rollen, vennligst velg en ny rolle å overføre eksisterende medlemmer til.", + "network": "Nettverk", "manage": "Administrer", "sitesNotFound": "Ingen områder funnet.", "pangolinServerAdmin": "Server Admin - Pangolin", @@ -750,6 +808,9 @@ "sitestCountIncrease": "Øk antall områder", "idpManage": "Administrer Identitetsleverandører", "idpManageDescription": "Vis og administrer identitetsleverandører i systemet", + "idpGlobalModeBanner": "Identitetsleverandører (IdPs) per organisasjon er deaktivert på denne serveren. Den bruker globale IdP (delt over alle organisasjoner). Administrer globale IdP'er i admin-panelet. For å aktivere IdP per organisasjon, rediger serverkonfigurasjonen og sett IdP-modus til org. Se dokumentasjonen. Hvis du vil fortsette å bruke globale IdPs og få denne til å forsvinne fra organisasjonens innstillinger, satt eksplisitt modusen til global i konfigurasjonen.", + "idpGlobalModeBannerUpgradeRequired": "Identitetsleverandører (IdPs) per organisasjon er deaktivert på denne serveren. Den bruker globale IdPs (delt på tvers av alle organisasjoner). Administrer globale IdPs i administrasjons-panelet. For å bruke identitetsleverandører per organisasjon, må du oppgradere til Enterprise-utgaven.", + "idpGlobalModeBannerLicenseRequired": "Identitetsleverandører (IdPs) per organisasjon er deaktivert på denne serveren. Den bruker globale IdPs (delt på tvers av alle organisasjoner). Administrer globale IdPs i administrasjons-panelet. For å bruke identitetsleverandører per organisasjon, kreves en Enterprise-lisens.", "idpDeletedDescription": "Identitetsleverandør slettet vellykket", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Er du sikker på at du vil slette identitetsleverandøren permanent?", @@ -840,6 +901,7 @@ "orgPolicyConfig": "Konfigurer tilgang for en organisasjon", "idpUpdatedDescription": "Identitetsleverandør vellykket oppdatert", "redirectUrl": "Omdirigerings-URL", + "orgIdpRedirectUrls": "Omadressere URL'er", "redirectUrlAbout": "Om omdirigerings-URL", "redirectUrlAboutDescription": "Dette er URLen som brukere vil bli omdirigert etter autentisering. Du må konfigurere denne URLen i identitetsleverandørens innstillinger.", "pangolinAuth": "Autentisering - Pangolin", @@ -943,13 +1005,13 @@ "passwordExpiryDescription": "Denne organisasjonen krever at du bytter passord hver {maxDays} dag.", "changePasswordNow": "Bytt passord nå", "pincodeAuth": "Autentiseringskode", - "pincodeSubmit2": "Send inn kode", + "pincodeSubmit2": "Send kode", "passwordResetSubmit": "Be om tilbakestilling", - "passwordResetAlreadyHaveCode": "Skriv inn tilbakestillingskode for passord", + "passwordResetAlreadyHaveCode": "Skriv inn koden", "passwordResetSmtpRequired": "Kontakt din administrator", "passwordResetSmtpRequiredDescription": "En passord tilbakestillingskode kreves for å tilbakestille passordet. Kontakt systemansvarlig for assistanse.", "passwordBack": "Tilbake til passord", - "loginBack": "Gå tilbake til innlogging", + "loginBack": "Gå tilbake til innloggingssiden for hovedkontoen", "signup": "Registrer deg", "loginStart": "Logg inn for å komme i gang", "idpOidcTokenValidating": "Validerer OIDC-token", @@ -972,12 +1034,12 @@ "pangolinSetup": "Oppsett - Pangolin", "orgNameRequired": "Organisasjonsnavn er påkrevd", "orgIdRequired": "Organisasjons-ID er påkrevd", + "orgIdMaxLength": "Organisasjons-ID må maksimalt være 32 tegn", "orgErrorCreate": "En feil oppstod under oppretting av organisasjon", "pageNotFound": "Siden ble ikke funnet", "pageNotFoundDescription": "Oops! Siden du leter etter finnes ikke.", "overview": "Oversikt", "home": "Hjem", - "accessControl": "Tilgangskontroll", "settings": "Innstillinger", "usersAll": "Alle brukere", "license": "Lisens", @@ -1035,15 +1097,24 @@ "updateOrgUser": "Oppdater org.bruker", "createOrgUser": "Opprett Org bruker", "actionUpdateOrg": "Oppdater organisasjon", + "actionRemoveInvitation": "Fjern invitasjon", "actionUpdateUser": "Oppdater bruker", "actionGetUser": "Hent bruker", "actionGetOrgUser": "Hent organisasjonsbruker", "actionListOrgDomains": "List opp organisasjonsdomener", + "actionGetDomain": "Få Domene", + "actionCreateOrgDomain": "Opprett domene", + "actionUpdateOrgDomain": "Oppdater domene", + "actionDeleteOrgDomain": "Slett domene", + "actionGetDNSRecords": "Hent DNS-oppføringer", + "actionRestartOrgDomain": "Omstart Domene", "actionCreateSite": "Opprett område", "actionDeleteSite": "Slett område", "actionGetSite": "Hent område", "actionListSites": "List opp områder", "actionApplyBlueprint": "Bruk blåkopi", + "actionListBlueprints": "List opp blåkopier", + "actionGetBlueprint": "Hent blåkopi", "setupToken": "Oppsetttoken", "setupTokenDescription": "Skriv inn oppsetttoken fra serverkonsollen.", "setupTokenRequired": "Oppsetttoken er nødvendig", @@ -1077,6 +1148,7 @@ "actionRemoveUser": "Fjern bruker", "actionListUsers": "List opp brukere", "actionAddUserRole": "Legg til brukerrolle", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Generer tilgangstoken", "actionDeleteAccessToken": "Slett tilgangstoken", "actionListAccessTokens": "List opp tilgangstokener", @@ -1104,6 +1176,10 @@ "actionUpdateIdpOrg": "Oppdater IDP-organisasjon", "actionCreateClient": "Opprett Klient", "actionDeleteClient": "Slett klient", + "actionArchiveClient": "Arkiver klient", + "actionUnarchiveClient": "Fjern arkivering klient", + "actionBlockClient": "Blokker kunde", + "actionUnblockClient": "Avblokker klient", "actionUpdateClient": "Oppdater klient", "actionListClients": "List klienter", "actionGetClient": "Hent klient", @@ -1117,17 +1193,18 @@ "actionViewLogs": "Vis logger", "noneSelected": "Ingen valgt", "orgNotFound2": "Ingen organisasjoner funnet.", - "searchProgress": "Søker...", + "searchPlaceholder": "Søk...", + "emptySearchOptions": "Ingen valg funnet", "create": "Opprett", "orgs": "Organisasjoner", - "loginError": "En feil oppstod under innlogging", - "loginRequiredForDevice": "Innlogging kreves for å godkjenne enheten.", + "loginError": "En uventet feil oppstod. Vennligst prøv igjen.", + "loginRequiredForDevice": "Innlogging er nødvendig for enheten din.", "passwordForgot": "Glemt passordet ditt?", "otpAuth": "Tofaktorautentisering", "otpAuthDescription": "Skriv inn koden fra autentiseringsappen din eller en av dine engangs reservekoder.", "otpAuthSubmit": "Send inn kode", "idpContinue": "Eller fortsett med", - "otpAuthBack": "Tilbake til innlogging", + "otpAuthBack": "Tilbake til passord", "navbar": "Navigasjonsmeny", "navbarDescription": "Hovednavigasjonsmeny for applikasjonen", "navbarDocsLink": "Dokumentasjon", @@ -1175,11 +1252,13 @@ "sidebarOverview": "Oversikt", "sidebarHome": "Hjem", "sidebarSites": "Områder", + "sidebarApprovals": "Godkjenningsforespørsler", "sidebarResources": "Ressurser", "sidebarProxyResources": "Offentlig", "sidebarClientResources": "Privat", "sidebarAccessControl": "Tilgangskontroll", "sidebarLogsAndAnalytics": "Logger og analyser", + "sidebarTeam": "Lag", "sidebarUsers": "Brukere", "sidebarAdmin": "Administrator", "sidebarInvitations": "Invitasjoner", @@ -1191,13 +1270,15 @@ "sidebarIdentityProviders": "Identitetsleverandører", "sidebarLicense": "Lisens", "sidebarClients": "Klienter", - "sidebarUserDevices": "Brukere", + "sidebarUserDevices": "Bruker Enheter", "sidebarMachineClients": "Maskiner", "sidebarDomains": "Domener", - "sidebarGeneral": "Generelt", + "sidebarGeneral": "Administrer", "sidebarLogAndAnalytics": "Logg og analyser", "sidebarBluePrints": "Tegninger", "sidebarOrganization": "Organisasjon", + "sidebarManagement": "Administrasjon", + "sidebarBillingAndLicenses": "Fakturering & lisenser", "sidebarLogsAnalytics": "Analyser", "blueprints": "Tegninger", "blueprintsDescription": "Bruk deklarative konfigurasjoner og vis tidligere kjøringer", @@ -1219,7 +1300,6 @@ "parsedContents": "Parastinnhold (kun lese)", "enableDockerSocket": "Aktiver Docker blåkopi", "enableDockerSocketDescription": "Aktiver skraping av Docker Socket for blueprint Etiketter. Socket bane må brukes for nye.", - "enableDockerSocketLink": "Lær mer", "viewDockerContainers": "Vis Docker-containere", "containersIn": "Containere i {siteName}", "selectContainerDescription": "Velg en hvilken som helst container for å bruke som vertsnavn for dette målet. Klikk på en port for å bruke en port.", @@ -1263,6 +1343,7 @@ "setupErrorCreateAdmin": "En feil oppstod under opprettelsen av serveradministratorkontoen.", "certificateStatus": "Sertifikatstatus", "loading": "Laster inn", + "loadingAnalytics": "Laster inn analyser", "restart": "Start på nytt", "domains": "Domener", "domainsDescription": "Opprett og behandle domener som er tilgjengelige i organisasjonen", @@ -1290,6 +1371,7 @@ "refreshError": "Klarte ikke å oppdatere data", "verified": "Verifisert", "pending": "Venter", + "pendingApproval": "Venter på godkjenning", "sidebarBilling": "Fakturering", "billing": "Fakturering", "orgBillingDescription": "Administrer faktureringsinformasjon og abonnementer", @@ -1308,8 +1390,11 @@ "accountSetupSuccess": "Kontooppsett fullført! Velkommen til Pangolin!", "documentation": "Dokumentasjon", "saveAllSettings": "Lagre alle innstillinger", + "saveResourceTargets": "Lagre mål", + "saveResourceHttp": "Lagre proxy-innstillinger", + "saveProxyProtocol": "Lagre proxy-protokollinnstillinger", "settingsUpdated": "Innstillinger oppdatert", - "settingsUpdatedDescription": "Alle innstillinger er oppdatert", + "settingsUpdatedDescription": "Innstillinger oppdatert vellykket", "settingsErrorUpdate": "Klarte ikke å oppdatere innstillinger", "settingsErrorUpdateDescription": "En feil oppstod under oppdatering av innstillinger", "sidebarCollapse": "Skjul", @@ -1342,6 +1427,7 @@ "domainPickerNamespace": "Navnerom: {namespace}", "domainPickerShowMore": "Vis mer", "regionSelectorTitle": "Velg Region", + "domainPickerRemoteExitNodeWarning": "Tilbudte domener støttes ikke når sider kobles til eksterne avkjøringsnoder. For ressurser som skal være tilgjengelige på eksterne noder, brukes et egendefinert domene i stedet.", "regionSelectorInfo": "Å velge en region hjelper oss med å gi bedre ytelse for din lokasjon. Du trenger ikke være i samme region som serveren.", "regionSelectorPlaceholder": "Velg en region", "regionSelectorComingSoon": "Kommer snart", @@ -1351,10 +1437,11 @@ "billingUsageLimitsOverview": "Oversikt over bruksgrenser", "billingMonitorUsage": "Overvåk bruken din i forhold til konfigurerte grenser. Hvis du trenger økte grenser, vennligst kontakt support@pangolin.net.", "billingDataUsage": "Databruk", - "billingOnlineTime": "Online tid for nettsteder", - "billingUsers": "Aktive brukere", - "billingDomains": "Aktive domener", - "billingRemoteExitNodes": "Aktive selvstyrte noder", + "billingSites": "Områder", + "billingUsers": "Brukere", + "billingDomains": "Domener", + "billingOrganizations": "Orger", + "billingRemoteExitNodes": "Eksterne Noder", "billingNoLimitConfigured": "Ingen grense konfigurert", "billingEstimatedPeriod": "Estimert faktureringsperiode", "billingIncludedUsage": "Inkludert Bruk", @@ -1379,15 +1466,24 @@ "billingFailedToGetPortalUrl": "Mislyktes å hente portal URL", "billingPortalError": "Portalfeil", "billingDataUsageInfo": "Du er ladet for all data som overføres gjennom dine sikre tunneler når du er koblet til skyen. Dette inkluderer både innkommende og utgående trafikk på alle dine nettsteder. Når du når grensen din, vil sidene koble fra til du oppgraderer planen eller reduserer bruken. Data belastes ikke ved bruk av EK-grupper.", - "billingOnlineTimeInfo": "Du er ladet på hvor lenge sidene dine forblir koblet til skyen. For eksempel tilsvarer 44,640 minutter ett nettsted som går 24/7 i en hel måned. Når du når grensen din, vil sidene koble fra til du oppgraderer planen eller reduserer bruken. Tid belastes ikke når du bruker noder.", - "billingUsersInfo": "Du lades for hver bruker i organisasjonen. Fakturering beregnes daglig basert på antall aktive brukerkontoer i dine org.", - "billingDomainInfo": "Du lades for hvert domene i organisasjonen. Fakturering beregnes daglig basert på antallet aktive domenekontoer i din org.", - "billingRemoteExitNodesInfo": "Du lades for hver håndterte node i organisasjonen. Fakturering beregnes daglig basert på antallet aktive håndterte noder i dine org.", + "billingSInfo": "Hvor mange nettsteder du kan bruke", + "billingUsersInfo": "Hvor mange brukere du kan bruke", + "billingDomainInfo": "Hvor mange domener du kan bruke", + "billingRemoteExitNodesInfo": "Hvor mange fjernnoder du kan bruke", + "billingLicenseKeys": "Lisensnøkler", + "billingLicenseKeysDescription": "Administrer dine lisensnøkkelabonnementer", + "billingLicenseSubscription": "Lisens abonnement", + "billingInactive": "Inaktiv", + "billingLicenseItem": "Lisens artikkel", + "billingQuantity": "Antall", + "billingTotal": "totalt", + "billingModifyLicenses": "Endre lisensabonnement", "domainNotFound": "Domene ikke funnet", "domainNotFoundDescription": "Denne ressursen er deaktivert fordi domenet ikke lenger eksisterer i systemet vårt. Vennligst angi et nytt domene for denne ressursen.", "failed": "Mislyktes", "createNewOrgDescription": "Opprett en ny organisasjon", "organization": "Organisasjon", + "primary": "Primær", "port": "Port", "securityKeyManage": "Administrer sikkerhetsnøkler", "securityKeyDescription": "Legg til eller fjern sikkerhetsnøkler for passordløs autentisering", @@ -1403,7 +1499,7 @@ "securityKeyRemoveSuccess": "Sikkerhetsnøkkel fjernet", "securityKeyRemoveError": "Klarte ikke å fjerne sikkerhetsnøkkel", "securityKeyLoadError": "Klarte ikke å laste inn sikkerhetsnøkler", - "securityKeyLogin": "Fortsett med sikkerhetsnøkkel", + "securityKeyLogin": "Bruk sikkerhetsnøkkel", "securityKeyAuthError": "Klarte ikke å autentisere med sikkerhetsnøkkel", "securityKeyRecommendation": "Registrer en reservesikkerhetsnøkkel på en annen enhet for å sikre at du alltid har tilgang til kontoen din.", "registering": "Registrerer...", @@ -1459,11 +1555,47 @@ "resourcePortRequired": "Portnummer er påkrevd for ikke-HTTP-ressurser", "resourcePortNotAllowed": "Portnummer skal ikke angis for HTTP-ressurser", "billingPricingCalculatorLink": "Pris Kalkulator", + "billingYourPlan": "Din funksjonsplan", + "billingViewOrModifyPlan": "Vis eller endre gjeldende abonnement", + "billingViewPlanDetails": "Se Planleggings detaljer", + "billingUsageAndLimits": "Bruk og grenser", + "billingViewUsageAndLimits": "Se planets grenser og gjeldende bruk", + "billingCurrentUsage": "Gjeldende bruk", + "billingMaximumLimits": "Maks antall grenser", + "billingRemoteNodes": "Eksterne Noder", + "billingUnlimited": "Ubegrenset", + "billingPaidLicenseKeys": "Betalt lisensnøkler", + "billingManageLicenseSubscription": "Administrer abonnementet for betalte lisensnøkler selv hostet", + "billingCurrentKeys": "Nåværende nøkler", + "billingModifyCurrentPlan": "Endre gjeldende plan", + "billingConfirmUpgrade": "Bekreft oppgradering", + "billingConfirmDowngrade": "Bekreft nedgradering", + "billingConfirmUpgradeDescription": "Du er i ferd med å oppgradere abonnementet ditt. Gå gjennom de nye grensene og pris nedenfor.", + "billingConfirmDowngradeDescription": "Du er i ferd med å nedgradere planen din. Gå gjennom de nye grensene og pris nedenfor.", + "billingPlanIncludes": "Plan Inkluderer", + "billingProcessing": "Behandler...", + "billingConfirmUpgradeButton": "Bekreft oppgradering", + "billingConfirmDowngradeButton": "Bekreft nedgradering", + "billingLimitViolationWarning": "Bruk overbelastede grenser for ny plan", + "billingLimitViolationDescription": "Gjeldende bruk overskrider grensene for denne planen. Etter nedgradering vil alle handlinger deaktiveres inntil du reduserer bruken innenfor de nye grensene. Vennligst se igjennom funksjonene under som er i øyeblikket over grensene. Begrensninger i vold:", + "billingFeatureLossWarning": "Fremhev tilgjengelig varsel", + "billingFeatureLossDescription": "Ved å nedgradere vil funksjoner som ikke er tilgjengelige i den nye planen automatisk bli deaktivert. Noen innstillinger og konfigurasjoner kan gå tapt. Vennligst gjennomgå prismatrisen for å forstå hvilke funksjoner som ikke lenger vil være tilgjengelige.", + "billingUsageExceedsLimit": "Gjeldende bruk ({current}) overskrider grensen ({limit})", + "billingPastDueTitle": "Betalingen har forfalt", + "billingPastDueDescription": "Betalingen er forfalt. Vennligst oppdater betalingsmetoden din for å fortsette å bruke den gjeldende funksjonsplanen din. Hvis du ikke har løst deg, vil abonnementet ditt avbrytes, og du vil bli tilbakestilt til gratistiden.", + "billingUnpaidTitle": "Abonnement ubetalt", + "billingUnpaidDescription": "Ditt abonnement er ubetalt og du har blitt tilbakestilt til gratis kasse. Vennligst oppdater din betalingsmetode for å gjenopprette abonnementet.", + "billingIncompleteTitle": "Betaling ufullstendig", + "billingIncompleteDescription": "Betalingen er ufullstendig. Vennligst fullfør betalingsprosessen for å aktivere abonnementet.", + "billingIncompleteExpiredTitle": "Betaling utløpt", + "billingIncompleteExpiredDescription": "Din betaling ble aldri fullført, og har utløpt. Du har blitt tilbakestilt til gratis dekk. Vennligst abonner på nytt for å gjenopprette tilgangen til betalte funksjoner.", + "billingManageSubscription": "Administrere ditt abonnement", + "billingResolvePaymentIssue": "Vennligst løs ditt betalingsproblem før du oppgraderer eller nedgraderer betalingen", "signUpTerms": { "IAgreeToThe": "Jeg godtar", "termsOfService": "brukervilkårene", "and": "og", - "privacyPolicy": "personvernerklæringen" + "privacyPolicy": "retningslinjer for personvern" }, "signUpMarketing": { "keepMeInTheLoop": "Hold meg i løken med nyheter, oppdateringer og nye funksjoner via e-post." @@ -1508,6 +1640,7 @@ "addNewTarget": "Legg til nytt mål", "targetsList": "Liste over mål", "advancedMode": "Avansert modus", + "advancedSettings": "Avanserte innstillinger", "targetErrorDuplicateTargetFound": "Duplikat av mål funnet", "healthCheckHealthy": "Sunn", "healthCheckUnhealthy": "Usunn", @@ -1529,6 +1662,26 @@ "IntervalSeconds": "Sunt intervall", "timeoutSeconds": "Tidsavbrudd (sek)", "timeIsInSeconds": "Tid er i sekunder", + "requireDeviceApproval": "Krev enhetsgodkjenning", + "requireDeviceApprovalDescription": "Brukere med denne rollen trenger nye enheter godkjent av en admin før de kan koble seg og få tilgang til ressurser.", + "sshAccess": "SSH tilgang", + "roleAllowSsh": "Tillat SSH", + "roleAllowSshAllow": "Tillat", + "roleAllowSshDisallow": "Forby", + "roleAllowSshDescription": "Tillat brukere med denne rollen å koble til ressurser via SSH. Når deaktivert får rollen ikke tilgang til SSH.", + "sshSudoMode": "Sudo tilgang", + "sshSudoModeNone": "Ingen", + "sshSudoModeNoneDescription": "Brukeren kan ikke kjøre kommandoer med sudo.", + "sshSudoModeFull": "Full Sudo", + "sshSudoModeFullDescription": "Brukeren kan kjøre hvilken som helst kommando med sudo.", + "sshSudoModeCommands": "Kommandoer", + "sshSudoModeCommandsDescription": "Brukeren kan bare kjøre de angitte kommandoene med sudo.", + "sshSudo": "Tillat sudo", + "sshSudoCommands": "Sudo kommandoer", + "sshSudoCommandsDescription": "Kommaseparert liste med kommandoer brukeren kan kjøre med sudo.", + "sshCreateHomeDir": "Opprett hjemmappe", + "sshUnixGroups": "Unix grupper", + "sshUnixGroupsDescription": "Kommaseparerte Unix grupper for å legge brukeren til på mål-verten.", "retryAttempts": "Forsøk på nytt", "expectedResponseCodes": "Forventede svarkoder", "expectedResponseCodesDescription": "HTTP-statuskode som indikerer sunn status. Hvis den blir stående tom, regnes 200-300 som sunn.", @@ -1569,6 +1722,8 @@ "resourcesTableNoInternalResourcesFound": "Ingen interne ressurser funnet.", "resourcesTableDestination": "Destinasjon", "resourcesTableAlias": "Alias", + "resourcesTableAliasAddress": "Alias adresse", + "resourcesTableAliasAddressInfo": "Denne adressen er en del av organisasjonens undernettverk. Den brukes til å løse aliasposter ved hjelp av intern DNS-oppløsning.", "resourcesTableClients": "Klienter", "resourcesTableAndOnlyAccessibleInternally": "og er kun tilgjengelig internt når de er koblet til med en klient.", "resourcesTableNoTargets": "Ingen mål", @@ -1616,9 +1771,8 @@ "createInternalResourceDialogResourceProperties": "Ressursegenskaper", "createInternalResourceDialogName": "Navn", "createInternalResourceDialogSite": "Område", - "createInternalResourceDialogSelectSite": "Velg område...", - "createInternalResourceDialogSearchSites": "Søk i områder...", - "createInternalResourceDialogNoSitesFound": "Ingen områder funnet.", + "selectSite": "Velg område...", + "noSitesFound": "Ingen områder funnet.", "createInternalResourceDialogProtocol": "Protokoll", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", @@ -1658,7 +1812,7 @@ "siteAddressDescription": "Den interne adressen til nettstedet. Må falle innenfor organisasjonens undernett.", "siteNameDescription": "Visningsnavnet på nettstedet som kan endres senere.", "autoLoginExternalIdp": "Automatisk innlogging med ekstern IDP", - "autoLoginExternalIdpDescription": "Omdiriger brukeren umiddelbart til den eksterne IDP-en for autentisering.", + "autoLoginExternalIdpDescription": "Omdiriger brukeren umiddelbart til den eksterne identitetsleverandøren for autentisering.", "selectIdp": "Velg IDP", "selectIdpPlaceholder": "Velg en IDP...", "selectIdpRequired": "Vennligst velg en IDP når automatisk innlogging er aktivert.", @@ -1670,7 +1824,7 @@ "autoLoginErrorNoRedirectUrl": "Ingen omdirigerings-URL mottatt fra identitetsleverandøren.", "autoLoginErrorGeneratingUrl": "Kunne ikke generere autentiserings-URL.", "remoteExitNodeManageRemoteExitNodes": "Eksterne Noder", - "remoteExitNodeDescription": "Selvbetjent én eller flere eksterne noder for å utvide nettverkstilkobling og redusere avhengighet på skyen", + "remoteExitNodeDescription": "Egendrift din egen eksterne relé- og proxyservernode", "remoteExitNodes": "Noder", "searchRemoteExitNodes": "Søk noder...", "remoteExitNodeAdd": "Legg til Node", @@ -1680,20 +1834,22 @@ "remoteExitNodeConfirmDelete": "Bekreft sletting av Node", "remoteExitNodeDelete": "Slett Node", "sidebarRemoteExitNodes": "Eksterne Noder", + "remoteExitNodeId": "ID", + "remoteExitNodeSecretKey": "Sikkerhetsnøkkel", "remoteExitNodeCreate": { - "title": "Opprett node", - "description": "Opprett en ny node for å utvide nettverkstilkoblingen", + "title": "Opprett ekstern node", + "description": "Opprett en ny egendrift ekstern relé- og proxyservernode", "viewAllButton": "Vis alle koder", "strategy": { "title": "Opprettelsesstrategi", - "description": "Velg denne for manuelt å konfigurere noden eller generere nye legitimasjoner.", + "description": "Velg hvordan du vil opprette den eksterne noden", "adopt": { "title": "Adopter Node", "description": "Velg dette hvis du allerede har legitimasjon til noden." }, "generate": { "title": "Generer Nøkler", - "description": "Velg denne hvis du vil generere nye nøkler for noden" + "description": "Velg denne hvis du vil generere nye nøkler for noden." } }, "adopt": { @@ -1806,9 +1962,33 @@ "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subnett", "subnetDescription": "Undernettverket for denne organisasjonens nettverkskonfigurasjon.", - "authPage": "Autentiseringsside", - "authPageDescription": "Konfigurer autoriseringssiden for organisasjonen", + "customDomain": "Egendefinert domene", + "authPage": "Autentiseringssider", + "authPageDescription": "Sett et egendefinert domene for organisasjonens autentiseringssider", "authPageDomain": "Autentiseringsside domene", + "authPageBranding": "Egendefinert merkevarebygging", + "authPageBrandingDescription": "Konfigurer merkevarebyggingen som vises på autentiseringssidene for denne organisasjonen", + "authPageBrandingUpdated": "Autentiseringsside-markedsføring oppdatert vellykket", + "authPageBrandingRemoved": "Autentiseringsside-markedsføring fjernet vellykket", + "authPageBrandingRemoveTitle": "Fjern markedsføring for autentiseringsside", + "authPageBrandingQuestionRemove": "Er du sikker på at du vil fjerne merkevarebyggingen for autentiseringssider?", + "authPageBrandingDeleteConfirm": "Bekreft sletting av merkevarebygging", + "brandingLogoURL": "Logo URL", + "brandingLogoURLOrPath": "Logoen URL eller sti", + "brandingLogoPathDescription": "Skriv inn en URL eller en lokal bane.", + "brandingLogoURLDescription": "Skriv inn en offentlig tilgjengelig nettadresse til din logobilde.", + "brandingPrimaryColor": "Primærfarge", + "brandingLogoWidth": "Bredde (px)", + "brandingLogoHeight": "Høyde (px)", + "brandingOrgTitle": "Tittel for organisasjonens autentiseringsside", + "brandingOrgDescription": "{orgName} vil bli erstattet med organisasjonens navn", + "brandingOrgSubtitle": "Undertittel for organisasjonens autentiseringsside", + "brandingResourceTitle": "Tittel for ressursens autentiseringsside", + "brandingResourceSubtitle": "Undertittel for ressursens autentiseringsside", + "brandingResourceDescription": "{resourceName} vil bli erstattet med organisasjonens navn", + "saveAuthPageDomain": "Lagre domene", + "saveAuthPageBranding": "Lagre merkevarebygging", + "removeAuthPageBranding": "Fjern merkevarebygging", "noDomainSet": "Ingen domene valgt", "changeDomain": "Endre domene", "selectDomain": "Velg domene", @@ -1817,7 +1997,7 @@ "setAuthPageDomain": "Angi autoriseringsside domene", "failedToFetchCertificate": "Kunne ikke hente sertifikat", "failedToRestartCertificate": "Kan ikke starte sertifikat", - "addDomainToEnableCustomAuthPages": "Legg til et domene for å aktivere egendefinerte autentiseringssider for organisasjonen", + "addDomainToEnableCustomAuthPages": "Brukere vil kunne få tilgang til organisasjonens innloggingsside og fullføre ressursautentisering ved å bruke dette domenet.", "selectDomainForOrgAuthPage": "Velg et domene for organisasjonens autentiseringsside", "domainPickerProvidedDomain": "Gitt domene", "domainPickerFreeProvidedDomain": "Gratis oppgitt domene", @@ -1832,11 +2012,27 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" kunne ikke gjøres gyldig for {domain}.", "domainPickerSubdomainSanitized": "Underdomenet som ble sanivert", "domainPickerSubdomainCorrected": "\"{sub}\" var korrigert til \"{sanitized}\"", - "orgAuthSignInTitle": "Logg inn på organisasjonen", + "orgAuthSignInTitle": "Organisasjonsinnlogging", "orgAuthChooseIdpDescription": "Velg din identitet leverandør for å fortsette", "orgAuthNoIdpConfigured": "Denne organisasjonen har ikke noen identitetstjeneste konfigurert. Du kan i stedet logge inn med Pangolin identiteten din.", "orgAuthSignInWithPangolin": "Logg inn med Pangolin", + "orgAuthSignInToOrg": "Logg inn på en organisasjon", + "orgAuthSelectOrgTitle": "Organisasjonsinnlogging", + "orgAuthSelectOrgDescription": "Skriv inn organisasjons-ID-en din for å fortsette", + "orgAuthOrgIdPlaceholder": "din-organisasjon", + "orgAuthOrgIdHelp": "Skriv inn organisasjonens unike identifikator", + "orgAuthSelectOrgHelp": "Etter å ha skrevet inn din organisasjons-ID, blir du videresendt til din organisasjons innloggingsside hvor du kan bruke SSO eller organisasjonens legitimasjon.", + "orgAuthRememberOrgId": "Husk denne organisasjons-ID-en", + "orgAuthBackToSignIn": "Tilbake til standard innlogging", + "orgAuthNoAccount": "Har du ikke konto?", "subscriptionRequiredToUse": "Et abonnement er påkrevd for å bruke denne funksjonen.", + "mustUpgradeToUse": "Du må oppgradere ditt abonnement for å bruke denne funksjonen.", + "subscriptionRequiredTierToUse": "Denne funksjonen krever {tier} eller høyere.", + "upgradeToTierToUse": "Oppgrader til {tier} eller høyere for å bruke denne funksjonen.", + "subscriptionTierTier1": "Hjem", + "subscriptionTierTier2": "Lag", + "subscriptionTierTier3": "Forretninger", + "subscriptionTierEnterprise": "Bedrift", "idpDisabled": "Identitetsleverandører er deaktivert.", "orgAuthPageDisabled": "Informasjons-siden for organisasjon er deaktivert.", "domainRestartedDescription": "Domene-verifiseringen ble startet på nytt", @@ -1850,6 +2046,8 @@ "enableTwoFactorAuthentication": "Aktiver to-faktor autentisering", "completeSecuritySteps": "Fullfør sikkerhetstrinnene", "securitySettings": "Sikkerhet innstillinger", + "dangerSection": "Faresone", + "dangerSectionDescription": "Slett permanent alle data tilknyttet denne organisasjonen", "securitySettingsDescription": "Konfigurer sikkerhetspolicyer for organisasjonen", "requireTwoFactorForAllUsers": "Krev to-faktor autentisering for alle brukere", "requireTwoFactorDescription": "Når aktivert må alle interne brukere i denne organisasjonen ha to-faktorautentisering aktivert for å få tilgang til organisasjonen.", @@ -1887,7 +2085,7 @@ "securityPolicyChangeWarningText": "Dette vil påvirke alle brukere i organisasjonen", "authPageErrorUpdateMessage": "Det oppstod en feil under oppdatering av innstillingene for godkjenningssiden", "authPageErrorUpdate": "Kunne ikke oppdatere autoriseringssiden", - "authPageUpdated": "Godkjenningsside oppdatert", + "authPageDomainUpdated": "Autentiseringsside-domenet ble oppdatert vellykket", "healthCheckNotAvailable": "Lokal", "rewritePath": "Omskriv sti", "rewritePathDescription": "Valgfritt omskrive stien før videresending til målet.", @@ -1915,8 +2113,15 @@ "beta": "beta", "manageUserDevices": "Bruker Enheter", "manageUserDevicesDescription": "Se og administrer enheter som brukere bruker for privat tilkobling til ressurser", + "downloadClientBannerTitle": "Last ned Pangolin-klienten", + "downloadClientBannerDescription": "Last ned Pangolin-klienten for systemet ditt for å koble til Pangolin-nettverket og få tilgang til ressurser privat.", "manageMachineClients": "Administrer maskinneklienter", "manageMachineClientsDescription": "Opprett og behandle klienter som servere og systemer bruker for privat tilkobling til ressurser", + "machineClientsBannerTitle": "Servere og automatiserte systemer", + "machineClientsBannerDescription": "Maskinklienter er for servere og automatiserte systemer som ikke er tilknyttet en spesifikk bruker. De autentiserer med en ID og et hemmelighetsnummer, og kan kjøre med Pangolin CLI, Olm CLI eller Olm som en container.", + "machineClientsBannerPangolinCLI": "Pangolin CLI", + "machineClientsBannerOlmCLI": "Olm CLI", + "machineClientsBannerOlmContainer": "Olm Container", "clientsTableUserClients": "Bruker", "clientsTableMachineClients": "Maskin", "licenseTableValidUntil": "Gyldig til", @@ -2015,6 +2220,32 @@ } } }, + "newPricingLicenseForm": { + "title": "Få en lisens", + "description": "Velg en plan og fortell oss hvordan du planlegger å bruke Pangolin.", + "chooseTier": "Velg din funksjonsplan", + "viewPricingLink": "Se prising, egenskaper og grenser", + "tiers": { + "starter": { + "title": "Begynner", + "description": "Enterprise features, 25 brukere, 25 sitater og støtte fra fellesskapet." + }, + "scale": { + "title": "Skala", + "description": "Enterprise features, 50 brukere, 50 nettsteder og prioritetsstøtte." + } + }, + "personalUseOnly": "Kun personlig bruk (gratis lisens - ingen utsjekking)", + "buttons": { + "continueToCheckout": "Fortsett til kassen" + }, + "toasts": { + "checkoutError": { + "title": "Feil ved utsjekk", + "description": "Kan ikke starte kassen. Prøv på nytt." + } + } + }, "priority": "Prioritet", "priorityDescription": "Høyere prioriterte ruter evalueres først. Prioritet = 100 betyr automatisk bestilling (systembeslutninger). Bruk et annet nummer til å håndheve manuell prioritet.", "instanceName": "Forekomst navn", @@ -2060,13 +2291,15 @@ "request": "Forespørsel", "requests": "Forespørsler", "logs": "Logger", - "logsSettingsDescription": "Overvåk logger samlet fra denne orginiasjonen", + "logsSettingsDescription": "Overvåk logger samlet inn fra denne organisasjonen", "searchLogs": "Søk i logger...", "action": "Handling", "actor": "Aktør", "timestamp": "Tidsstempel", "accessLogs": "Tilgangslogger (Automatic Translation)", "exportCsv": "Eksportere CSV", + "exportError": "Ukjent feil ved eksport av CSV", + "exportCsvTooltip": "Innenfor tidsramme", "actorId": "Skuespiller ID", "allowedByRule": "Tillatt etter regel", "allowedNoAuth": "Tillatt Ingen Auth", @@ -2111,7 +2344,8 @@ "logRetentionEndOfFollowingYear": "Slutt på neste år", "actionLogsDescription": "Vis historikk for handlinger som er utført i denne organisasjonen", "accessLogsDescription": "Vis autoriseringsforespørsler for ressurser i denne organisasjonen", - "licenseRequiredToUse": "En Enterprise lisens er påkrevd for å bruke denne funksjonen.", + "licenseRequiredToUse": "En Enterprise Edition lisens eller Pangolin Cloud er påkrevd for å bruke denne funksjonen. Bestill en demo eller POC prøveversjon.", + "ossEnterpriseEditionRequired": "Enterprise Edition er nødvendig for å bruke denne funksjonen. Denne funksjonen er også tilgjengelig i Pangolin Cloud. Bestill en demo eller POC studie.", "certResolver": "Sertifikat løser", "certResolverDescription": "Velg sertifikatløser som skal brukes for denne ressursen.", "selectCertResolver": "Velg sertifikatløser", @@ -2120,7 +2354,7 @@ "unverified": "Uverifisert", "domainSetting": "Domene innstillinger", "domainSettingDescription": "Konfigurer innstillinger for domenet", - "preferWildcardCertDescription": "Forsøk på å generere et jokertegn (krever en riktig konfigurert sertifikatløsning).", + "preferWildcardCertDescription": "Forsøk å generere et jokertegn-sertifikat (krever en riktig konfigurert sertifikatløser).", "recordName": "Lagre navn", "auto": "Automatisk", "TTL": "TTL", @@ -2172,6 +2406,8 @@ "deviceCodeInvalidFormat": "Kode må inneholde 9 tegn (f.eks A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Ugyldig eller utløpt kode", "deviceCodeVerifyFailed": "Klarte ikke å bekrefte enhetskoden", + "deviceCodeValidating": "Validerer enhetskode...", + "deviceCodeVerifying": "Bekrefter enhetens godkjennelse...", "signedInAs": "Logget inn som", "deviceCodeEnterPrompt": "Skriv inn koden som vises på enheten", "continue": "Fortsett", @@ -2184,7 +2420,7 @@ "deviceOrganizationsAccess": "Tilgang til alle organisasjoner din konto har tilgang til", "deviceAuthorize": "Autoriser {applicationName}", "deviceConnected": "Enhet tilkoblet!", - "deviceAuthorizedMessage": "Enhet er autorisert for tilgang til kontoen din.", + "deviceAuthorizedMessage": "Enheten er autorisert for tilgang til kontoen. Vennligst gå tilbake til klientapplikasjonen.", "pangolinCloud": "Pangolin Sky", "viewDevices": "Vis enheter", "viewDevicesDescription": "Administrer tilkoblede enheter", @@ -2246,6 +2482,7 @@ "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Ikke du? Bruk en annen konto.", "deviceLoginDeviceRequestingAccessToAccount": "En enhet ber om tilgang til denne kontoen.", + "loginSelectAuthenticationMethod": "Velg en autentiseringsmetode for å fortsette.", "noData": "Ingen data", "machineClients": "Maskinklienter", "install": "Installer", @@ -2255,6 +2492,8 @@ "setupFailedToFetchSubnet": "Kunne ikke hente standard undernett", "setupSubnetAdvanced": "Subnet (avansert)", "setupSubnetDescription": "Subnet for denne organisasjonens interne nettverk.", + "setupUtilitySubnet": "Utility Subnet (Avansert)", + "setupUtilitySubnetDescription": "Subnettet for denne organisasjonens aliasadresser og DNS-server.", "siteRegenerateAndDisconnect": "Regenerer og koble fra", "siteRegenerateAndDisconnectConfirmation": "Er du sikker på at du vil regenerere legitimasjon og koble fra dette nettstedet?", "siteRegenerateAndDisconnectWarning": "Dette vil regenerere legitimasjon og umiddelbart koble fra siden. Siden må startes på nytt med de nye legitimasjonene.", @@ -2270,5 +2509,179 @@ "remoteExitNodeRegenerateAndDisconnectWarning": "Dette vil regenerere innloggingsdetaljene og umiddelbart koble fra den eksterne avkjøringnoden. Ekstern avkjøringsnode må startes på nytt med de nye opplysningene", "remoteExitNodeRegenerateCredentialsConfirmation": "Er du sikker på at du vil regenerere innloggingsene for denne eksterne avslutningen?", "remoteExitNodeRegenerateCredentialsWarning": "Dette vil regenerere legitimasjon. Ekstern avgang noden vil forbli tilkoblet inntil du manuelt gjenoppstarter den og bruker de nye legitimasjonene.", - "agent": "Agent" + "agent": "Agent", + "personalUseOnly": "Kun til personlig bruk", + "loginPageLicenseWatermark": "Denne instansen er lisensiert kun for personlig bruk.", + "instanceIsUnlicensed": "Denne instansen er ulisensiert.", + "portRestrictions": "Portbegrensninger", + "allPorts": "Alle", + "custom": "Egendefinert", + "allPortsAllowed": "Alle porter tillatt", + "allPortsBlocked": "Alle porter blokkert", + "tcpPortsDescription": "Spesifiser hvilke TCP-porter som er tillatt for denne ressursen. Bruk '*' for alle porter, la stå tomt for å blokkere alle, eller skriv inn en kommaseparert liste over porter og sjikt (f.eks. 80,443,8000-9000).", + "udpPortsDescription": "Spesifiser hvilke UDP-porter som er tillatt for denne ressursen. Bruk '*' for alle porter, la stå tomt for å blokkere alle, eller skriv inn en kommaseparert liste over porter og sjikt (f.eks. 53,123,500-600).", + "organizationLoginPageTitle": "Organisasjonens innloggingsside", + "organizationLoginPageDescription": "Tilpass innloggingssiden for denne organisasjonen", + "resourceLoginPageTitle": "Ressursens innloggingsside", + "resourceLoginPageDescription": "Tilpass innloggingssiden for individuelle ressurser", + "enterConfirmation": "Skriv inn bekreftelse", + "blueprintViewDetails": "Detaljer", + "defaultIdentityProvider": "Standard identitetsleverandør", + "defaultIdentityProviderDescription": "Når en standard identitetsleverandør er valgt, vil brukeren automatisk bli omdirigert til leverandøren for autentisering.", + "editInternalResourceDialogNetworkSettings": "Nettverksinnstillinger", + "editInternalResourceDialogAccessPolicy": "Tilgangsregler for tilgang", + "editInternalResourceDialogAddRoles": "Legg til roller", + "editInternalResourceDialogAddUsers": "Legg til brukere", + "editInternalResourceDialogAddClients": "Legg til klienter", + "editInternalResourceDialogDestinationLabel": "Destinasjon", + "editInternalResourceDialogDestinationDescription": "Spesifiser destinasjonsadressen for den interne ressursen. Dette kan være et vertsnavn, IP-adresse eller CIDR-sjikt avhengig av valgt modus. Valgfrie oppsett av intern DNS-alias for enklere identifikasjon.", + "editInternalResourceDialogPortRestrictionsDescription": "Begrens tilgang til spesifikke TCP/UDP-porter eller tillate/blokkere alle porter.", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "Tilgangskontroll", + "editInternalResourceDialogAccessControlDescription": "Kontroller hvilke roller, brukere og maskinklienter som har tilgang til denne ressursen når den er koblet til. Administratorer har alltid tilgang.", + "editInternalResourceDialogPortRangeValidationError": "Portsjiktet må være \"*\" for alle porter, eller en kommaseparert liste med porter og sjikt (f.eks. \"80,443,8000-9000\"). Porter må være mellom 1 og 65535.", + "internalResourceAuthDaemonStrategy": "SSH Auth Daemon Sted", + "internalResourceAuthDaemonStrategyDescription": "Velg hvor SSH-autentisering daemon kjører: på nettstedet (Newt) eller på en ekstern vert.", + "internalResourceAuthDaemonDescription": "SSH-godkjenning daemon håndterer SSH-nøkkel signering og PAM autentisering for denne ressursen. Velg om den kjører på nettstedet (Newt) eller på en separat ekstern vert. Se dokumentasjonen for mer.", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "Velg strategi", + "internalResourceAuthDaemonStrategyLabel": "Sted", + "internalResourceAuthDaemonSite": "På nettsted", + "internalResourceAuthDaemonSiteDescription": "Autentiser daemon kjører på nettstedet (Newt).", + "internalResourceAuthDaemonRemote": "Ekstern vert", + "internalResourceAuthDaemonRemoteDescription": "Autentiser daemon kjører på en vert som ikke er nettstedet.", + "internalResourceAuthDaemonPort": "Daemon Port (valgfritt)", + "orgAuthWhatsThis": "Hvor kan jeg finne min organisasjons-ID?", + "learnMore": "Lær mer", + "backToHome": "Gå tilbake til start", + "needToSignInToOrg": "Trenger du å bruke organisasjonens identitetsleverandør?", + "maintenanceMode": "Vedlikeholdsmodus", + "maintenanceModeDescription": "Vis en vedlikeholdsside til besøkende", + "maintenanceModeType": "Vedlikeholdsmodus type", + "showMaintenancePage": "Vis en vedlikeholdsside til besøkende", + "enableMaintenanceMode": "Aktiver vedlikeholdsmodus", + "automatic": "Automatisk", + "automaticModeDescription": "Vis vedlikeholdsside kun når alle serverens mål er nede eller usunne. Ressursen din fortsetter å fungere normalt så lenge minst ett mål er sunt.", + "forced": "Tvunget", + "forcedModeDescription": "Vis alltid vedlikeholdssiden uavhengig av serverens helse. Bruk dette ved planlagt vedlikehold når du vil hindre all tilgang.", + "warning:": "Advarsel:", + "forcedeModeWarning": "All trafikk vil bli dirigeres til vedlikeholdssiden. Serverens ressurser vil ikke motta noen forespørsler.", + "pageTitle": "Sidetittel", + "pageTitleDescription": "Hovedoverskriften vist på vedlikeholdssiden", + "maintenancePageMessage": "Vedlikeholdsbeskjed", + "maintenancePageMessagePlaceholder": "Vi kommer snart tilbake! Vårt nettsted gjennomgår for øyeblikket planlagt vedlikehold.", + "maintenancePageMessageDescription": "Detaljert beskjed som forklarer vedlikeholdet", + "maintenancePageTimeTitle": "Estimert ferdigstillelsestid (Valgfritt)", + "maintenanceTime": "f.eks. 2 timer, 1. november kl. 17:00", + "maintenanceEstimatedTimeDescription": "Når du forventer at vedlikeholdet er ferdigstilt", + "editDomain": "Rediger domene", + "editDomainDescription": "Velg et domene for ressursen din", + "maintenanceModeDisabledTooltip": "Denne funksjonen krever en gyldig lisens for å aktiveres.", + "maintenanceScreenTitle": "Tjenesten er midlertidig utilgjengelig", + "maintenanceScreenMessage": "Vi opplever for øyeblikket tekniske problemer. Vennligst sjekk igjen snart.", + "maintenanceScreenEstimatedCompletion": "Estimert ferdigstillelse:", + "createInternalResourceDialogDestinationRequired": "Destinasjonen er nødvendig", + "available": "Tilgjengelig", + "archived": "Arkivert", + "noArchivedDevices": "Ingen arkiverte enheter funnet", + "deviceArchived": "Enhet arkivert", + "deviceArchivedDescription": "Enheten er blitt arkivert.", + "errorArchivingDevice": "Feil ved arkivering av enhet", + "failedToArchiveDevice": "Kunne ikke arkivere enhet", + "deviceQuestionArchive": "Er du sikker på at du vil arkivere denne enheten?", + "deviceMessageArchive": "Enheten blir arkivert og fjernet fra listen over aktive enheter.", + "deviceArchiveConfirm": "Arkiver enhet", + "archiveDevice": "Arkiver enhet", + "archive": "Arkiv", + "deviceUnarchived": "Enheten er uarkivert", + "deviceUnarchivedDescription": "Enheten er blitt avarkivert.", + "errorUnarchivingDevice": "Feil ved arkivering av enhet", + "failedToUnarchiveDevice": "Kunne ikke fjerne arkivere enheten", + "unarchive": "Avarkiver", + "archiveClient": "Arkiver klient", + "archiveClientQuestion": "Er du sikker på at du vil arkivere denne klienten?", + "archiveClientMessage": "Klienten arkiveres og fjernes fra listen over aktive klienter.", + "archiveClientConfirm": "Arkiver klient", + "blockClient": "Blokker kunde", + "blockClientQuestion": "Er du sikker på at du vil blokkere denne klienten?", + "blockClientMessage": "Enheten blir tvunget til å koble fra hvis den er koblet til. Du kan fjerne blokkeringen av enheten senere.", + "blockClientConfirm": "Blokker kunde", + "active": "Aktiv", + "usernameOrEmail": "Brukernavn eller e-post", + "selectYourOrganization": "Velg din organisasjon", + "signInTo": "Logg inn på", + "signInWithPassword": "Fortsett med passord", + "noAuthMethodsAvailable": "Ingen autentiseringsmetoder er tilgjengelige for denne organisasjonen.", + "enterPassword": "Angi ditt passord", + "enterMfaCode": "Angi koden fra din autentiseringsapp", + "securityKeyRequired": "Vennligst bruk sikkerhetsnøkkelen til å logge på.", + "needToUseAnotherAccount": "Trenger du å bruke en annen konto?", + "loginLegalDisclaimer": "Ved å klikke på knappene nedenfor, erkjenner du at du har lest, forstår, og godtar Vilkår for bruk og for Personvernerklæring.", + "termsOfService": "Vilkår for bruk", + "privacyPolicy": "Retningslinjer for personvern", + "userNotFoundWithUsername": "Ingen bruker med det brukernavnet funnet.", + "verify": "Verifiser", + "signIn": "Logg inn", + "forgotPassword": "Glemt passord?", + "orgSignInTip": "Hvis du har logget inn før, kan du skrive inn brukernavnet eller e-postadressen ovenfor for å autentisere med organisasjonens identitetstjeneste i stedet. Det er enklere!", + "continueAnyway": "Fortsett likevel", + "dontShowAgain": "Ikke vis igjen", + "orgSignInNotice": "Visste du?", + "signupOrgNotice": "Prøver å logge inn?", + "signupOrgTip": "Prøver du å logge inn gjennom din organisasjons identitetsleverandør?", + "signupOrgLink": "Logg inn eller registrer deg med organisasjonen din i stedet", + "verifyEmailLogInWithDifferentAccount": "Bruk en annen konto", + "logIn": "Logg inn", + "deviceInformation": "Enhetens informasjon", + "deviceInformationDescription": "Informasjon om enheten og agenten", + "deviceSecurity": "Enhetens sikkerhet", + "deviceSecurityDescription": "Sikkerhetsstillings informasjon om utstyr", + "platform": "Plattform", + "macosVersion": "macOS versjon", + "windowsVersion": "Windows versjon", + "iosVersion": "iOS Versjon", + "androidVersion": "Android versjon", + "osVersion": "OS versjon", + "kernelVersion": "Kjerne versjon", + "deviceModel": "Enhets modell", + "serialNumber": "Serienummer", + "hostname": "Hostname", + "firstSeen": "Først sett", + "lastSeen": "Sist sett", + "biometricsEnabled": "Biometri aktivert", + "diskEncrypted": "Disk kryptert", + "firewallEnabled": "Brannmur aktivert", + "autoUpdatesEnabled": "Automatiske oppdateringer aktivert", + "tpmAvailable": "TPM tilgjengelig", + "windowsAntivirusEnabled": "Antivirus aktivert", + "macosSipEnabled": "System Integritetsbeskyttelse (SIP)", + "macosGatekeeperEnabled": "Gatekeeper", + "macosFirewallStealthMode": "Brannmur Usynlig Modus", + "linuxAppArmorEnabled": "Rustning", + "linuxSELinuxEnabled": "SELinux", + "deviceSettingsDescription": "Vis enhetsinformasjon og innstillinger", + "devicePendingApprovalDescription": "Denne enheten venter på godkjenning", + "deviceBlockedDescription": "Denne enheten er blokkert. Det kan ikke kobles til noen ressurser med mindre de ikke blir blokkert.", + "unblockClient": "Avblokker klient", + "unblockClientDescription": "Enheten har blitt blokkert", + "unarchiveClient": "Fjern arkivering klient", + "unarchiveClientDescription": "Enheten er arkivert", + "block": "Blokker", + "unblock": "Avblokker", + "deviceActions": "Enhetens handlinger", + "deviceActionsDescription": "Administrer enhetsstatus og tilgang", + "devicePendingApprovalBannerDescription": "Denne enheten venter på godkjenning. Den kan ikke koble til ressurser før den er godkjent.", + "connected": "Tilkoblet", + "disconnected": "Frakoblet", + "approvalsEmptyStateTitle": "Enhetsgodkjenninger er ikke aktivert", + "approvalsEmptyStateDescription": "Aktivere godkjenninger av enheter for at roller må godkjennes av admin før brukere kan koble til nye enheter.", + "approvalsEmptyStateStep1Title": "Gå til roller", + "approvalsEmptyStateStep1Description": "Naviger til organisasjonens roller innstillinger for å konfigurere enhetsgodkjenninger.", + "approvalsEmptyStateStep2Title": "Aktiver enhetsgodkjenninger", + "approvalsEmptyStateStep2Description": "Rediger en rolle og aktiver alternativet 'Kreve enhetsgodkjenninger'. Brukere med denne rollen vil trenge administratorgodkjenning for nye enheter.", + "approvalsEmptyStatePreviewDescription": "Forhåndsvisning: Når aktivert, ventende enhets forespørsler vil vises her for vurdering", + "approvalsEmptyStateButtonText": "Administrer Roller", + "domainErrorTitle": "Vi har problemer med å verifisere domenet ditt" } diff --git a/messages/nl-NL.json b/messages/nl-NL.json index c235676a0..f0eaff3fd 100644 --- a/messages/nl-NL.json +++ b/messages/nl-NL.json @@ -1,5 +1,7 @@ { "setupCreate": "Maak de organisatie, site en bronnen aan", + "headerAuthCompatibilityInfo": "Schakel dit in om een 401 Niet Geautoriseerd antwoord af te dwingen wanneer een authenticatietoken ontbreekt. Dit is vereist voor browsers of specifieke HTTP-bibliotheken die geen referenties verzenden zonder een serveruitdaging.", + "headerAuthCompatibility": "Uitgebreide compatibiliteit", "setupNewOrg": "Nieuwe organisatie", "setupCreateOrg": "Nieuwe organisatie aanmaken", "setupCreateResources": "Bronnen aanmaken", @@ -16,6 +18,8 @@ "componentsMember": "Je bent lid van {count, plural, =0 {geen organisatie} one {één organisatie} other {# organisaties}}.", "componentsInvalidKey": "Ongeldige of verlopen licentiesleutels gedetecteerd. Volg de licentievoorwaarden om alle functies te blijven gebruiken.", "dismiss": "Uitschakelen", + "subscriptionViolationMessage": "U overschrijdt uw huidige abonnement. Corrigeer het probleem door sites, gebruikers of andere bronnen te verwijderen om binnen uw plan te blijven.", + "subscriptionViolationViewBilling": "Facturering bekijken", "componentsLicenseViolation": "Licentie overtreding: Deze server gebruikt {usedSites} sites die de gelicentieerde limiet van {maxSites} sites overschrijden. Volg de licentievoorwaarden om door te gaan met het gebruik van alle functies.", "componentsSupporterMessage": "Bedankt voor het ondersteunen van Pangolin als {tier}!", "inviteErrorNotValid": "Het spijt ons, maar de uitnodiging die je probeert te bezoeken is niet geaccepteerd of is niet meer geldig.", @@ -51,6 +55,12 @@ "siteQuestionRemove": "Weet u zeker dat u de site wilt verwijderen uit de organisatie?", "siteManageSites": "Sites beheren", "siteDescription": "Maak en beheer sites om verbinding met privénetwerken in te schakelen", + "sitesBannerTitle": "Verbind elk netwerk", + "sitesBannerDescription": "Een site is een verbinding met een extern netwerk waarmee Pangolin toegang biedt tot bronnen, zowel openbaar als privé, aan gebruikers overal. Installeer de sitedatacenterconnector (Newt) overal waar je een binaire of container kunt uitvoeren om de verbinding tot stand te brengen.", + "sitesBannerButtonText": "Site installeren", + "approvalsBannerTitle": "Toegang tot het apparaat goedkeuren of weigeren", + "approvalsBannerDescription": "Bekijk en keur toestelverzoeken goed of weiger toegang van gebruikers. Wanneer apparaatgoedkeuringen vereist zijn, moeten gebruikers de goedkeuring van beheerders krijgen voordat hun apparaten verbinding kunnen maken met de bronnen van uw organisatie.", + "approvalsBannerButtonText": "Meer informatie", "siteCreate": "Site maken", "siteCreateDescription2": "Volg de onderstaande stappen om een nieuwe site aan te maken en te verbinden", "siteCreateDescription": "Maak een nieuwe site aan om bronnen te verbinden", @@ -100,6 +110,7 @@ "siteTunnelDescription": "Bepaal hoe u verbinding wilt maken met de site", "siteNewtCredentials": "Aanmeldgegevens", "siteNewtCredentialsDescription": "Dit is hoe de site zich zal verifiëren met de server", + "remoteNodeCredentialsDescription": "Dit is hoe de externe node zich bij de server zal authenticeren", "siteCredentialsSave": "Sla de aanmeldgegevens op", "siteCredentialsSaveDescription": "Je kunt dit slechts één keer zien. Kopieer het naar een beveiligde plek.", "siteInfo": "Site informatie", @@ -146,8 +157,12 @@ "shareErrorSelectResource": "Selecteer een bron", "proxyResourceTitle": "Openbare bronnen beheren", "proxyResourceDescription": "Creëer en beheer bronnen die openbaar toegankelijk zijn via een webbrowser", + "proxyResourcesBannerTitle": "Webgebaseerde openbare toegang", + "proxyResourcesBannerDescription": "Openbare bronnen zijn HTTPS of TCP/UDP-proxies die toegankelijk zijn voor iedereen op het internet via een webbrowser. In tegenstelling tot priv��bronnen vereisen ze geen client-side software maar kunnen ze identiteits- en context-bewuste toegangsrichtlijnen bevatten.", "clientResourceTitle": "Privébronnen beheren", "clientResourceDescription": "Creëer en beheer bronnen die alleen toegankelijk zijn via een verbonden client", + "privateResourcesBannerTitle": "Zero-Trust Private Access", + "privateResourcesBannerDescription": "Privé bronnen maken gebruik van zero-trust-beveiliging, wat ervoor zorgt dat gebruikers en machines alleen toegang kunnen krijgen tot middelen die jij specifiek toestaat. Verbind gebruikersapparaten of machineclients om deze middelen te benaderen via een veilig virtueel priv��netwerk.", "resourcesSearch": "Zoek bronnen...", "resourceAdd": "Bron toevoegen", "resourceErrorDelte": "Fout bij verwijderen document", @@ -157,9 +172,10 @@ "resourceMessageRemove": "Eenmaal verwijderd, zal het bestand niet langer toegankelijk zijn. Alle doelen die gekoppeld zijn aan het hulpbron, zullen ook verwijderd worden.", "resourceQuestionRemove": "Weet u zeker dat u het document van de organisatie wilt verwijderen?", "resourceHTTP": "HTTPS bron", - "resourceHTTPDescription": "Proxy verzoeken aan de app via HTTPS via een subdomein of basisdomein.", + "resourceHTTPDescription": "Proxyverzoeken via HTTPS met een volledig gekwalificeerde domeinnaam.", "resourceRaw": "TCP/UDP bron", - "resourceRawDescription": "Proxy verzoeken naar de app via TCP/UDP met behulp van een poortnummer. Dit werkt alleen als sites zijn verbonden met nodes.", + "resourceRawDescription": "Proxyverzoeken via ruwe TCP/UDP met een poortnummer.", + "resourceRawDescriptionCloud": "Proxy verzoeken over rauwe TCP/UDP met behulp van een poortnummer. Vereist sites om verbinding te maken met een remote node.", "resourceCreate": "Bron maken", "resourceCreateDescription": "Volg de onderstaande stappen om een nieuwe bron te maken", "resourceSeeAll": "Alle bronnen bekijken", @@ -186,6 +202,7 @@ "protocolSelect": "Selecteer een protocol", "resourcePortNumber": "Nummer van poort", "resourcePortNumberDescription": "Het externe poortnummer naar proxyverzoeken.", + "back": "Achterzijde", "cancel": "Annuleren", "resourceConfig": "Configuratie tekstbouwstenen", "resourceConfigDescription": "Kopieer en plak deze configuratie-snippets om de TCP/UDP-bron in te stellen", @@ -231,6 +248,17 @@ "orgErrorDeleteMessage": "Er is een fout opgetreden tijdens het verwijderen van de organisatie.", "orgDeleted": "Organisatie verwijderd", "orgDeletedMessage": "De organisatie en haar gegevens zijn verwijderd.", + "deleteAccount": "Verwijder account", + "deleteAccountDescription": "Verwijdert permanent uw account, alle organisaties die u bezit, en alle gegevens binnen deze organisaties. Dit kan niet ongedaan worden gemaakt.", + "deleteAccountButton": "Verwijder account", + "deleteAccountConfirmTitle": "Verwijder account", + "deleteAccountConfirmMessage": "Dit zal uw account permanent wissen, alle organisaties die u bezit, en alle gegevens binnen deze organisaties. Dit kan niet ongedaan worden gemaakt.", + "deleteAccountConfirmString": "verwijder account", + "deleteAccountSuccess": "Account verwijderd", + "deleteAccountSuccessMessage": "Uw account is verwijderd.", + "deleteAccountError": "Kan account niet verwijderen", + "deleteAccountPreviewAccount": "Uw account", + "deleteAccountPreviewOrgs": "Organisaties die je bezit (en al hun gegevens)", "orgMissing": "Organisatie-ID ontbreekt", "orgMissingMessage": "Niet in staat om de uitnodiging te regenereren zonder organisatie-ID.", "accessUsersManage": "Gebruikers beheren", @@ -247,6 +275,8 @@ "accessRolesSearch": "Rollen zoeken...", "accessRolesAdd": "Rol toevoegen", "accessRoleDelete": "Verwijder rol", + "accessApprovalsManage": "Goedkeuringen beheren", + "accessApprovalsDescription": "Bekijk en beheer openstaande goedkeuringen voor toegang tot deze organisatie", "description": "Beschrijving", "inviteTitle": "Open uitnodigingen", "inviteDescription": "Beheer uitnodigingen voor andere gebruikers om deel te nemen aan de organisatie", @@ -440,6 +470,20 @@ "selectDuration": "Selecteer duur", "selectResource": "Selecteer Document", "filterByResource": "Filter op pagina", + "selectApprovalState": "Selecteer goedkeuringsstatus", + "filterByApprovalState": "Filter op goedkeuringsstatus", + "approvalListEmpty": "Geen goedkeuringen", + "approvalState": "Goedkeuring status", + "approvalLoadMore": "Meer laden", + "loadingApprovals": "Goedkeuringen laden", + "approve": "Goedkeuren", + "approved": "Goedgekeurd", + "denied": "Geweigerd", + "deniedApproval": "Geweigerde goedkeuring", + "all": "Alles", + "deny": "Weigeren", + "viewDetails": "Details bekijken", + "requestingNewDeviceApproval": "heeft een nieuw apparaat aangevraagd", "resetFilters": "Filters resetten", "totalBlocked": "Verzoeken geblokkeerd door Pangolin", "totalRequests": "Totaal verzoeken", @@ -607,6 +651,7 @@ "resourcesErrorUpdate": "Bron wisselen mislukt", "resourcesErrorUpdateDescription": "Er is een fout opgetreden tijdens het bijwerken van het document", "access": "Toegangsrechten", + "accessControl": "Toegangs controle", "shareLink": "{resource} Share link", "resourceSelect": "Selecteer resource", "shareLinks": "Links delen", @@ -687,7 +732,7 @@ "resourceRoleDescription": "Beheerders hebben altijd toegang tot deze bron.", "resourceUsersRoles": "Toegang Bediening", "resourceUsersRolesDescription": "Configureer welke gebruikers en rollen deze pagina kunnen bezoeken", - "resourceUsersRolesSubmit": "Gebruikers opslaan & rollen", + "resourceUsersRolesSubmit": "Bewaar Toegangsbesturing", "resourceWhitelistSave": "Succesvol opgeslagen", "resourceWhitelistSaveDescription": "Whitelist instellingen zijn opgeslagen", "ssoUse": "Gebruik Platform SSO", @@ -719,22 +764,35 @@ "countries": "Landen", "accessRoleCreate": "Rol aanmaken", "accessRoleCreateDescription": "Maak een nieuwe rol aan om gebruikers te groeperen en hun rechten te beheren.", + "accessRoleEdit": "Rol bewerken", + "accessRoleEditDescription": "Bewerk rol informatie.", "accessRoleCreateSubmit": "Rol aanmaken", "accessRoleCreated": "Rol aangemaakt", "accessRoleCreatedDescription": "De rol is succesvol aangemaakt.", "accessRoleErrorCreate": "Rol aanmaken mislukt", "accessRoleErrorCreateDescription": "Fout opgetreden tijdens het aanmaken van de rol.", + "accessRoleUpdateSubmit": "Rol bijwerken", + "accessRoleUpdated": "Rol bijgewerkt", + "accessRoleUpdatedDescription": "De rol is succesvol bijgewerkt.", + "accessApprovalUpdated": "Afgewerkt met goedkeuring", + "accessApprovalApprovedDescription": "Stel het goedkeuringsverzoek in op goedkeuring.", + "accessApprovalDeniedDescription": "Stel de beslissing over het goedkeuringsverzoek in als geweigerd.", + "accessRoleErrorUpdate": "Bijwerken van rol mislukt", + "accessRoleErrorUpdateDescription": "Fout opgetreden tijdens het bijwerken van de rol.", + "accessApprovalErrorUpdate": "Kan goedkeuring niet verwerken", + "accessApprovalErrorUpdateDescription": "Er is een fout opgetreden bij het verwerken van de goedkeuring.", "accessRoleErrorNewRequired": "Nieuwe rol is vereist", "accessRoleErrorRemove": "Rol verwijderen mislukt", "accessRoleErrorRemoveDescription": "Er is een fout opgetreden tijdens het verwijderen van de rol.", "accessRoleName": "Rol naam", - "accessRoleQuestionRemove": "U staat op het punt de {name} rol te verwijderen. U kunt deze actie niet ongedaan maken.", + "accessRoleQuestionRemove": "Je staat op het punt de `{name}` rol te verwijderen. Je kunt deze actie niet ongedaan maken.", "accessRoleRemove": "Rol verwijderen", "accessRoleRemoveDescription": "Verwijder een rol van de organisatie", "accessRoleRemoveSubmit": "Rol verwijderen", "accessRoleRemoved": "Rol verwijderd", "accessRoleRemovedDescription": "De rol is succesvol verwijderd.", "accessRoleRequiredRemove": "Voordat u deze rol verwijdert, selecteer een nieuwe rol om bestaande leden aan te dragen.", + "network": "Netwerk", "manage": "Beheren", "sitesNotFound": "Geen sites gevonden.", "pangolinServerAdmin": "Serverbeheer - Pangolin", @@ -750,6 +808,9 @@ "sitestCountIncrease": "Toename van site vergroten", "idpManage": "Identiteitsaanbieders beheren", "idpManageDescription": "Identiteitsaanbieders in het systeem bekijken en beheren", + "idpGlobalModeBanner": "Identiteitsaanbieders (IdPs) per organisatie zijn uitgeschakeld op deze server. Het gebruikt globale IdPs (gedeeld tussen alle organisaties). Beheer globale IdPs in het beheerderspaneel. Om IdPs per organisatie in te schakelen, bewerk de server configuratie en zet IdP modus op org. Zie de documenten. Als je globale IdPs wilt blijven gebruiken en dit uit de organisatie-instellingen wilt laten verdwijnen, zet dan expliciet de modus naar globaal in de config.", + "idpGlobalModeBannerUpgradeRequired": "Identity providers (IdPs) per organisatie zijn uitgeschakeld op deze server. Het gebruikt globale IdPs (gedeeld in alle organisaties) Beheer globale IdPs in het beheerderspaneel. Om identiteitsproviders per organisatie te gebruiken, moet u upgraden naar de Enterprise editie.", + "idpGlobalModeBannerLicenseRequired": "Identity providers (IdPs) per organisatie zijn uitgeschakeld op deze server. Het gebruikt globale IdPs (gedeeld in alle organisaties) Beheer globale IdPs in het beheerderspaneel. Om identiteitsaanbieders per organisatie te gebruiken, is een Enterprise-licentie vereist.", "idpDeletedDescription": "Identity provider succesvol verwijderd", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Weet u zeker dat u de identiteitsprovider permanent wilt verwijderen?", @@ -840,6 +901,7 @@ "orgPolicyConfig": "Toegang voor een organisatie configureren", "idpUpdatedDescription": "Identity provider succesvol bijgewerkt", "redirectUrl": "Omleidings URL", + "orgIdpRedirectUrls": "URL's omleiden", "redirectUrlAbout": "Over omleidings-URL", "redirectUrlAboutDescription": "Dit is de URL waarnaar gebruikers worden doorverwezen na verificatie. U moet deze URL configureren in de instellingen van de identiteitsprovider.", "pangolinAuth": "Authenticatie - Pangolin", @@ -863,7 +925,7 @@ "inviteAlready": "Het lijkt erop dat je bent uitgenodigd!", "inviteAlreadyDescription": "Om de uitnodiging te accepteren, moet je inloggen of een account aanmaken.", "signupQuestion": "Heeft u al een account?", - "login": "Inloggen", + "login": "Log in", "resourceNotFound": "Bron niet gevonden", "resourceNotFoundDescription": "De bron die u probeert te benaderen bestaat niet.", "pincodeRequirementsLength": "Pincode moet precies 6 cijfers zijn", @@ -945,11 +1007,11 @@ "pincodeAuth": "Authenticatiecode", "pincodeSubmit2": "Code indienen", "passwordResetSubmit": "Opnieuw instellen aanvragen", - "passwordResetAlreadyHaveCode": "Herstelcode wachtwoord invoeren", + "passwordResetAlreadyHaveCode": "Code invoeren", "passwordResetSmtpRequired": "Neem contact op met uw beheerder", "passwordResetSmtpRequiredDescription": "Er is een wachtwoord reset code nodig om uw wachtwoord opnieuw in te stellen. Neem contact op met uw beheerder voor hulp.", "passwordBack": "Terug naar wachtwoord", - "loginBack": "Ga terug naar login", + "loginBack": "Ga terug naar de hoofdinlogpagina", "signup": "Registreer nu", "loginStart": "Log in om te beginnen", "idpOidcTokenValidating": "Valideer OIDC-token", @@ -972,12 +1034,12 @@ "pangolinSetup": "Instellen - Pangolin", "orgNameRequired": "Organisatienaam is vereist", "orgIdRequired": "Organisatie-ID is vereist", + "orgIdMaxLength": "Organisatie-ID mag maximaal 32 tekens lang zijn", "orgErrorCreate": "Fout opgetreden tijdens het aanmaken org", "pageNotFound": "Pagina niet gevonden", "pageNotFoundDescription": "Oeps! De pagina die je zoekt bestaat niet.", "overview": "Overzicht.", "home": "Startpagina", - "accessControl": "Toegangs controle", "settings": "Instellingen", "usersAll": "Alle gebruikers", "license": "Licentie", @@ -1035,15 +1097,24 @@ "updateOrgUser": "Org gebruiker bijwerken", "createOrgUser": "Org gebruiker aanmaken", "actionUpdateOrg": "Organisatie bijwerken", + "actionRemoveInvitation": "Verwijder uitnodiging", "actionUpdateUser": "Gebruiker bijwerken", "actionGetUser": "Gebruiker ophalen", "actionGetOrgUser": "Krijg organisatie-gebruiker", "actionListOrgDomains": "Lijst organisatie domeinen", + "actionGetDomain": "Domein verkrijgen", + "actionCreateOrgDomain": "Domein aanmaken", + "actionUpdateOrgDomain": "Domein bijwerken", + "actionDeleteOrgDomain": "Domein verwijderen", + "actionGetDNSRecords": "Krijg DNS Records", + "actionRestartOrgDomain": "Domein opnieuw starten", "actionCreateSite": "Site aanmaken", "actionDeleteSite": "Site verwijderen", "actionGetSite": "Site ophalen", "actionListSites": "Sites weergeven", "actionApplyBlueprint": "Blauwdruk toepassen", + "actionListBlueprints": "Lijst blauwdrukken", + "actionGetBlueprint": "Krijg Blauwdruk", "setupToken": "Instel Token", "setupTokenDescription": "Voer het setup-token in vanaf de serverconsole.", "setupTokenRequired": "Setup-token is vereist", @@ -1077,6 +1148,7 @@ "actionRemoveUser": "Gebruiker verwijderen", "actionListUsers": "Gebruikers weergeven", "actionAddUserRole": "Gebruikersrol toevoegen", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Genereer Toegangstoken", "actionDeleteAccessToken": "Verwijder toegangstoken", "actionListAccessTokens": "Lijst toegangstokens", @@ -1104,6 +1176,10 @@ "actionUpdateIdpOrg": "IDP-org bijwerken", "actionCreateClient": "Client aanmaken", "actionDeleteClient": "Verwijder klant", + "actionArchiveClient": "Archiveer client", + "actionUnarchiveClient": "Dearchiveer client", + "actionBlockClient": "Blokkeer klant", + "actionUnblockClient": "Deblokkeer client", "actionUpdateClient": "Klant bijwerken", "actionListClients": "Lijst klanten", "actionGetClient": "Client ophalen", @@ -1117,17 +1193,18 @@ "actionViewLogs": "Logboeken bekijken", "noneSelected": "Niet geselecteerd", "orgNotFound2": "Geen organisaties gevonden.", - "searchProgress": "Zoeken...", + "searchPlaceholder": "Zoeken...", + "emptySearchOptions": "Geen opties gevonden", "create": "Aanmaken", "orgs": "Organisaties", - "loginError": "Er is een fout opgetreden tijdens het inloggen", - "loginRequiredForDevice": "Inloggen is vereist om je apparaat te verifiëren.", + "loginError": "Er is een onverwachte fout opgetreden. Probeer het opnieuw.", + "loginRequiredForDevice": "Inloggen is vereist voor je apparaat.", "passwordForgot": "Wachtwoord vergeten?", "otpAuth": "Tweestapsverificatie verificatie", "otpAuthDescription": "Voer de code van je authenticator-app of een van je reservekopiecodes voor het eenmalig gebruik in.", "otpAuthSubmit": "Code indienen", "idpContinue": "Of ga verder met", - "otpAuthBack": "Terug naar inloggen", + "otpAuthBack": "Terug naar wachtwoord", "navbar": "Navigatiemenu", "navbarDescription": "Hoofd navigatie menu voor de applicatie", "navbarDocsLink": "Documentatie", @@ -1175,11 +1252,13 @@ "sidebarOverview": "Overzicht.", "sidebarHome": "Startpagina", "sidebarSites": "Werkruimtes", + "sidebarApprovals": "Goedkeuringsverzoeken", "sidebarResources": "Bronnen", "sidebarProxyResources": "Openbaar", "sidebarClientResources": "Privé", "sidebarAccessControl": "Toegangs controle", "sidebarLogsAndAnalytics": "Logs & Analytics", + "sidebarTeam": "Team", "sidebarUsers": "Gebruikers", "sidebarAdmin": "Beheerder", "sidebarInvitations": "Uitnodigingen", @@ -1191,13 +1270,15 @@ "sidebarIdentityProviders": "Identiteit aanbieders", "sidebarLicense": "Licentie", "sidebarClients": "Clienten", - "sidebarUserDevices": "Gebruikers", + "sidebarUserDevices": "Gebruiker Apparaten", "sidebarMachineClients": "Machines", "sidebarDomains": "Domeinen", - "sidebarGeneral": "Algemeen", + "sidebarGeneral": "Beheren", "sidebarLogAndAnalytics": "Log & Analytics", "sidebarBluePrints": "Blauwdrukken", "sidebarOrganization": "Organisatie", + "sidebarManagement": "Beheer", + "sidebarBillingAndLicenses": "Facturatie & Licenties", "sidebarLogsAnalytics": "Analyses", "blueprints": "Blauwdrukken", "blueprintsDescription": "Gebruik declaratieve configuraties en bekijk vorige uitvoeringen.", @@ -1219,7 +1300,6 @@ "parsedContents": "Geparseerde inhoud (alleen lezen)", "enableDockerSocket": "Schakel Docker Blauwdruk in", "enableDockerSocketDescription": "Schakel Docker Socket label in voor blauwdruk labels. Pad naar Nieuw.", - "enableDockerSocketLink": "Meer informatie", "viewDockerContainers": "Bekijk Docker containers", "containersIn": "Containers in {siteName}", "selectContainerDescription": "Selecteer een container om als hostnaam voor dit doel te gebruiken. Klik op een poort om een poort te gebruiken.", @@ -1263,6 +1343,7 @@ "setupErrorCreateAdmin": "Er is een fout opgetreden bij het maken van het serverbeheerdersaccount.", "certificateStatus": "Certificaatstatus", "loading": "Bezig met laden", + "loadingAnalytics": "Laden van Analytics", "restart": "Herstarten", "domains": "Domeinen", "domainsDescription": "Maak en beheer domeinen die beschikbaar zijn in de organisatie", @@ -1290,6 +1371,7 @@ "refreshError": "Het vernieuwen van gegevens is mislukt", "verified": "Gecontroleerd", "pending": "In afwachting", + "pendingApproval": "Wachten op goedkeuring", "sidebarBilling": "Facturering", "billing": "Facturering", "orgBillingDescription": "Beheer factureringsinformatie en abonnementen", @@ -1308,8 +1390,11 @@ "accountSetupSuccess": "Accountinstelling voltooid! Welkom bij Pangolin!", "documentation": "Documentatie", "saveAllSettings": "Alle instellingen opslaan", + "saveResourceTargets": "Doelstellingen opslaan", + "saveResourceHttp": "Proxyinstellingen opslaan", + "saveProxyProtocol": "Proxy-protocolinstellingen opslaan", "settingsUpdated": "Instellingen bijgewerkt", - "settingsUpdatedDescription": "Alle instellingen zijn succesvol bijgewerkt", + "settingsUpdatedDescription": "Instellingen succesvol bijgewerkt", "settingsErrorUpdate": "Bijwerken van instellingen mislukt", "settingsErrorUpdateDescription": "Er is een fout opgetreden bij het bijwerken van instellingen", "sidebarCollapse": "Inklappen", @@ -1342,6 +1427,7 @@ "domainPickerNamespace": "Naamruimte: {namespace}", "domainPickerShowMore": "Meer weergeven", "regionSelectorTitle": "Selecteer Regio", + "domainPickerRemoteExitNodeWarning": "Opgegeven domeinen worden niet ondersteund wanneer websites verbinding maken met externe sluitnodes. Gebruik in plaats daarvan een aangepast domein. Om bronnen beschikbaar te maken op externe nodes.", "regionSelectorInfo": "Het selecteren van een regio helpt ons om betere prestaties te leveren voor uw locatie. U hoeft niet in dezelfde regio als uw server te zijn.", "regionSelectorPlaceholder": "Kies een regio", "regionSelectorComingSoon": "Komt binnenkort", @@ -1351,10 +1437,11 @@ "billingUsageLimitsOverview": "Overzicht gebruikslimieten", "billingMonitorUsage": "Houd uw gebruik in de gaten ten opzichte van de ingestelde limieten. Als u verhoogde limieten nodig heeft, neem dan contact met ons op support@pangolin.net.", "billingDataUsage": "Gegevensgebruik", - "billingOnlineTime": "Site Online Tijd", - "billingUsers": "Actieve Gebruikers", - "billingDomains": "Actieve Domeinen", - "billingRemoteExitNodes": "Actieve Zelfgehoste Nodes", + "billingSites": "Sites", + "billingUsers": "Gebruikers", + "billingDomains": "Domeinen", + "billingOrganizations": "Ordenen", + "billingRemoteExitNodes": "Externe knooppunten", "billingNoLimitConfigured": "Geen limiet ingesteld", "billingEstimatedPeriod": "Geschatte Facturatie Periode", "billingIncludedUsage": "Opgenomen Gebruik", @@ -1379,15 +1466,24 @@ "billingFailedToGetPortalUrl": "Niet gelukt om portal URL te krijgen", "billingPortalError": "Portal Fout", "billingDataUsageInfo": "U bent in rekening gebracht voor alle gegevens die via uw beveiligde tunnels via de cloud worden verzonden. Dit omvat zowel inkomende als uitgaande verkeer over al uw sites. Wanneer u uw limiet bereikt zullen uw sites de verbinding verbreken totdat u uw abonnement upgradet of het gebruik vermindert. Gegevens worden niet in rekening gebracht bij het gebruik van knooppunten.", - "billingOnlineTimeInfo": "U wordt in rekening gebracht op basis van hoe lang uw sites verbonden blijven met de cloud. Bijvoorbeeld 44,640 minuten is gelijk aan één site met 24/7 voor een volledige maand. Wanneer u uw limiet bereikt, zal de verbinding tussen uw sites worden verbroken totdat u een upgrade van uw abonnement uitvoert of het gebruik vermindert. Tijd wordt niet belast bij het gebruik van knooppunten.", - "billingUsersInfo": "U bent in rekening gebracht voor elke gebruiker in de organisatie. Facturering wordt dagelijks berekend op basis van het aantal actieve gebruikersaccounts in uw org.", - "billingDomainInfo": "U wordt voor elk domein in de organisatie in rekening gebracht. Facturering wordt dagelijks berekend op basis van het aantal actieve domeinaccounts in uw org.", - "billingRemoteExitNodesInfo": "U bent belast voor elke beheerde node in de organisatie. Facturering wordt dagelijks berekend op basis van het aantal actieve beheerde knooppunten in uw org.", + "billingSInfo": "Hoeveel sites u kunt gebruiken", + "billingUsersInfo": "Hoeveel gebruikers je kan gebruiken", + "billingDomainInfo": "Hoeveel domeinen je kunt gebruiken", + "billingRemoteExitNodesInfo": "Hoeveel externe nodes je kunt gebruiken", + "billingLicenseKeys": "Licentie Sleutels", + "billingLicenseKeysDescription": "Beheer uw licentiesleutelabonnementen", + "billingLicenseSubscription": "Licentie abonnement", + "billingInactive": "Inactief", + "billingLicenseItem": "Licentie artikel", + "billingQuantity": "Aantal", + "billingTotal": "totaal", + "billingModifyLicenses": "Licentieabonnement wijzigen", "domainNotFound": "Domein niet gevonden", "domainNotFoundDescription": "Deze bron is uitgeschakeld omdat het domein niet langer in ons systeem bestaat. Stel een nieuw domein in voor deze bron.", "failed": "Mislukt", "createNewOrgDescription": "Maak een nieuwe organisatie", "organization": "Organisatie", + "primary": "Primair", "port": "Poort", "securityKeyManage": "Beveiligingssleutels beheren", "securityKeyDescription": "Voeg beveiligingssleutels toe of verwijder ze voor wachtwoordloze authenticatie", @@ -1403,7 +1499,7 @@ "securityKeyRemoveSuccess": "Beveiligingssleutel succesvol verwijderd", "securityKeyRemoveError": "Fout bij verwijderen van beveiligingssleutel", "securityKeyLoadError": "Fout bij laden van beveiligingssleutels", - "securityKeyLogin": "Doorgaan met beveiligingssleutel", + "securityKeyLogin": "Gebruik beveiligingssleutel", "securityKeyAuthError": "Fout bij authenticatie met beveiligingssleutel", "securityKeyRecommendation": "Overweeg om een andere beveiligingssleutel te registreren op een ander apparaat om ervoor te zorgen dat u niet buitengesloten raakt van uw account.", "registering": "Registreren...", @@ -1459,11 +1555,47 @@ "resourcePortRequired": "Poortnummer is vereist voor niet-HTTP-bronnen", "resourcePortNotAllowed": "Poortnummer mag niet worden ingesteld voor HTTP-bronnen", "billingPricingCalculatorLink": "Prijs Calculator", + "billingYourPlan": "Uw abonnement", + "billingViewOrModifyPlan": "Bekijk of wijzig uw huidige abonnement", + "billingViewPlanDetails": "Abonnementsdetails bekijken", + "billingUsageAndLimits": "Gebruik en limieten", + "billingViewUsageAndLimits": "Limiet van je abonnement en huidig gebruik bekijken", + "billingCurrentUsage": "Huidig gebruik", + "billingMaximumLimits": "Maximaal aantal limieten", + "billingRemoteNodes": "Externe knooppunten", + "billingUnlimited": "Onbeperkt", + "billingPaidLicenseKeys": "Betaalde licentiesleutels", + "billingManageLicenseSubscription": "Beheer je abonnement voor betaalde zelf gehoste licentiesleutels", + "billingCurrentKeys": "Huidige toetsen", + "billingModifyCurrentPlan": "Huidig plan wijzigen", + "billingConfirmUpgrade": "Bevestig Upgrade", + "billingConfirmDowngrade": "Downgraden bevestigen", + "billingConfirmUpgradeDescription": "U staat op het punt uw abonnement te upgraden. Controleer de nieuwe limieten en prijzen hieronder.", + "billingConfirmDowngradeDescription": "U staat op het punt om uw abonnement te downgraden. Controleer de nieuwe limieten en prijzen hieronder.", + "billingPlanIncludes": "Abonnement bevat", + "billingProcessing": "Verwerken...", + "billingConfirmUpgradeButton": "Bevestig Upgrade", + "billingConfirmDowngradeButton": "Downgraden bevestigen", + "billingLimitViolationWarning": "Gebruik Overschrijdt nieuwe Plan Limieten", + "billingLimitViolationDescription": "Uw huidige verbruik overschrijdt de limieten van dit plan. Na het downgraden worden alle acties uitgeschakeld totdat u het verbruik vermindert binnen de nieuwe grenzen. Controleer de onderstaande functies die de limieten overschrijden. Beperkingen in overtreding:", + "billingFeatureLossWarning": "Kennisgeving beschikbaarheid", + "billingFeatureLossDescription": "Door downgraden worden functies die niet beschikbaar zijn in het nieuwe abonnement automatisch uitgeschakeld. Sommige instellingen en configuraties kunnen verloren gaan. Raadpleeg de prijsmatrix om te begrijpen welke functies niet langer beschikbaar zijn.", + "billingUsageExceedsLimit": "Huidig gebruik ({current}) overschrijdt limiet ({limit})", + "billingPastDueTitle": "Vervaldatum betaling", + "billingPastDueDescription": "Uw betaling is verlopen. Werk uw betaalmethode bij om uw huidige abonnementsfuncties te blijven gebruiken. Als dit niet is opgelost, zal je abonnement worden geannuleerd en zal je worden teruggezet naar de vrije rang.", + "billingUnpaidTitle": "Abonnement Onbetaald", + "billingUnpaidDescription": "Uw abonnement is niet betaald en u bent teruggekeerd naar het gratis niveau. Update uw betalingsmethode om uw abonnement te herstellen.", + "billingIncompleteTitle": "Betaling onvolledig", + "billingIncompleteDescription": "Uw betaling is onvolledig. Voltooi alstublieft het betalingsproces om uw abonnement te activeren.", + "billingIncompleteExpiredTitle": "Betaling verlopen", + "billingIncompleteExpiredDescription": "Uw betaling is nooit voltooid en verlopen. U bent teruggekeerd naar de gratis niveaus. Abonneer u opnieuw om de toegang tot betaalde functies te herstellen.", + "billingManageSubscription": "Beheer uw abonnement", + "billingResolvePaymentIssue": "Gelieve uw betalingsprobleem op te lossen voor het upgraden of downgraden", "signUpTerms": { "IAgreeToThe": "Ik ga akkoord met de", "termsOfService": "servicevoorwaarden", "and": "en", - "privacyPolicy": "privacybeleid" + "privacyPolicy": "privacy beleid" }, "signUpMarketing": { "keepMeInTheLoop": "Houd me op de hoogte met nieuws, updates en nieuwe functies per e-mail." @@ -1508,6 +1640,7 @@ "addNewTarget": "Voeg nieuw doelwit toe", "targetsList": "Lijst met doelen", "advancedMode": "Geavanceerde modus", + "advancedSettings": "Geavanceerde instellingen", "targetErrorDuplicateTargetFound": "Dubbel doelwit gevonden", "healthCheckHealthy": "Gezond", "healthCheckUnhealthy": "Ongezond", @@ -1529,6 +1662,26 @@ "IntervalSeconds": "Gezonde Interval", "timeoutSeconds": "Timeout (sec)", "timeIsInSeconds": "Tijd is in seconden", + "requireDeviceApproval": "Vereist goedkeuring van apparaat", + "requireDeviceApprovalDescription": "Gebruikers met deze rol hebben nieuwe apparaten nodig die door een beheerder zijn goedgekeurd voordat ze verbinding kunnen maken met bronnen en deze kunnen gebruiken.", + "sshAccess": "SSH toegang", + "roleAllowSsh": "SSH toestaan", + "roleAllowSshAllow": "Toestaan", + "roleAllowSshDisallow": "Weigeren", + "roleAllowSshDescription": "Sta gebruikers met deze rol toe om verbinding te maken met bronnen via SSH. Indien uitgeschakeld kan de rol geen gebruik maken van SSH toegang.", + "sshSudoMode": "Sudo toegang", + "sshSudoModeNone": "geen", + "sshSudoModeNoneDescription": "Gebruiker kan geen commando's uitvoeren met sudo.", + "sshSudoModeFull": "Volledige Sudo", + "sshSudoModeFullDescription": "Gebruiker kan elk commando uitvoeren met een sudo.", + "sshSudoModeCommands": "Opdrachten", + "sshSudoModeCommandsDescription": "Gebruiker kan alleen de opgegeven commando's uitvoeren met de sudo.", + "sshSudo": "sudo toestaan", + "sshSudoCommands": "Sudo Commando's", + "sshSudoCommandsDescription": "Komma's gescheiden lijst van commando's waar de gebruiker een sudo mee mag uitvoeren.", + "sshCreateHomeDir": "Maak Home Directory", + "sshUnixGroups": "Unix groepen", + "sshUnixGroupsDescription": "Door komma's gescheiden Unix-groepen om de gebruiker toe te voegen aan de doelhost.", "retryAttempts": "Herhaal Pogingen", "expectedResponseCodes": "Verwachte Reactiecodes", "expectedResponseCodesDescription": "HTTP-statuscode die gezonde status aangeeft. Indien leeg wordt 200-300 als gezond beschouwd.", @@ -1569,6 +1722,8 @@ "resourcesTableNoInternalResourcesFound": "Geen interne bronnen gevonden.", "resourcesTableDestination": "Bestemming", "resourcesTableAlias": "Alias", + "resourcesTableAliasAddress": "Alias adres", + "resourcesTableAliasAddressInfo": "Dit adres is onderdeel van het hulpprogramma subnet van de organisatie. Het wordt gebruikt om aliasrecords op te lossen met behulp van interne DNS-resolutie.", "resourcesTableClients": "Clienten", "resourcesTableAndOnlyAccessibleInternally": "en zijn alleen intern toegankelijk wanneer verbonden met een client.", "resourcesTableNoTargets": "Geen doelen", @@ -1616,9 +1771,8 @@ "createInternalResourceDialogResourceProperties": "Bron-eigenschappen", "createInternalResourceDialogName": "Naam", "createInternalResourceDialogSite": "Site", - "createInternalResourceDialogSelectSite": "Selecteer site...", - "createInternalResourceDialogSearchSites": "Zoek sites...", - "createInternalResourceDialogNoSitesFound": "Geen sites gevonden.", + "selectSite": "Selecteer site...", + "noSitesFound": "Geen sites gevonden.", "createInternalResourceDialogProtocol": "Protocol", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", @@ -1658,7 +1812,7 @@ "siteAddressDescription": "Het interne adres van de site. Moet binnen het subnetwerk van de organisatie vallen.", "siteNameDescription": "De weergavenaam van de site die later gewijzigd kan worden.", "autoLoginExternalIdp": "Auto Login met Externe IDP", - "autoLoginExternalIdpDescription": "De gebruiker onmiddellijk doorsturen naar de externe IDP voor authenticatie.", + "autoLoginExternalIdpDescription": "Leidt de gebruiker onmiddellijk door naar de externe identiteitsprovider voor authenticatie.", "selectIdp": "Selecteer IDP", "selectIdpPlaceholder": "Kies een IDP...", "selectIdpRequired": "Selecteer alstublieft een IDP wanneer automatisch inloggen is ingeschakeld.", @@ -1670,7 +1824,7 @@ "autoLoginErrorNoRedirectUrl": "Geen redirect URL ontvangen van de identity provider.", "autoLoginErrorGeneratingUrl": "Genereren van authenticatie-URL mislukt.", "remoteExitNodeManageRemoteExitNodes": "Externe knooppunten", - "remoteExitNodeDescription": "Zelf host één of meer externe knooppunten om de netwerkverbinding uit te breiden en het vertrouwen in de cloud te verminderen", + "remoteExitNodeDescription": "Host je eigen externe relais- en proxyserverknooppunten zelf", "remoteExitNodes": "Nodes", "searchRemoteExitNodes": "Knooppunten zoeken...", "remoteExitNodeAdd": "Voeg node toe", @@ -1680,20 +1834,22 @@ "remoteExitNodeConfirmDelete": "Bevestig verwijderen node", "remoteExitNodeDelete": "Knoop verwijderen", "sidebarRemoteExitNodes": "Externe knooppunten", + "remoteExitNodeId": "ID", + "remoteExitNodeSecretKey": "Geheim", "remoteExitNodeCreate": { - "title": "Maak node", - "description": "Maak een nieuwe node aan om de netwerkverbinding uit te breiden", + "title": "Externe knoop aanmaken", + "description": "Maak een nieuwe zelf-gehoste externe relais- en proxyservermodule", "viewAllButton": "Alle nodes weergeven", "strategy": { "title": "Creatie Strategie", - "description": "Kies dit om handmatig het knooppunt te configureren of nieuwe referenties te genereren.", + "description": "Selecteer hoe u de externe knoop wilt aanmaken", "adopt": { "title": "Adopteer Node", "description": "Kies dit als u al de referenties voor deze node heeft" }, "generate": { "title": "Genereer Sleutels", - "description": "Kies dit als u nieuwe sleutels voor het knooppunt wilt genereren" + "description": "Kies dit als u nieuwe sleutels voor het knooppunt wilt genereren." } }, "adopt": { @@ -1806,9 +1962,33 @@ "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Subnet", "subnetDescription": "Het subnet van de netwerkconfiguratie van deze organisatie.", - "authPage": "Authenticatie pagina", - "authPageDescription": "De autorisatiepagina voor de organisatie configureren", + "customDomain": "Aangepast domein", + "authPage": "Authenticatiepagina's", + "authPageDescription": "Stel een aangepast domein in voor de authenticatiepagina's van de organisatie", "authPageDomain": "Authenticatie pagina domein", + "authPageBranding": "Aangepaste branding", + "authPageBrandingDescription": "Configureer de branding die op de authenticatiepagina's voor deze organisatie verschijnt", + "authPageBrandingUpdated": "Auth-paginamerken succesvol bijgewerkt", + "authPageBrandingRemoved": "Configuratie hiervan is succesvol verwijderd.", + "authPageBrandingRemoveTitle": "Verwijder Auth-pagina Branding", + "authPageBrandingQuestionRemove": "Weet u zeker dat u de branding voor Auth-pagina's wilt verwijderen?", + "authPageBrandingDeleteConfirm": "Bevestig verwijder Branding", + "brandingLogoURL": "Het logo-URL", + "brandingLogoURLOrPath": "Logo URL of pad", + "brandingLogoPathDescription": "Voer een URL of een lokaal pad in.", + "brandingLogoURLDescription": "Voer een openbaar toegankelijke URL in voor uw logo afbeelding.", + "brandingPrimaryColor": "Primaire kleur", + "brandingLogoWidth": "Breedte (px)", + "brandingLogoHeight": "Hoogte (px)", + "brandingOrgTitle": "Titel voor organisatie-authenticatiepagina", + "brandingOrgDescription": "{orgName} wordt vervangen door de naam van de organisatie", + "brandingOrgSubtitle": "Ondertitel voor organisatie-authenticatiepagina", + "brandingResourceTitle": "Titel voor bron-authenticatiepagina", + "brandingResourceSubtitle": "Ondertitel voor bron-authenticatiepagina", + "brandingResourceDescription": "{resourceName} wordt vervangen door de naam van de organisatie", + "saveAuthPageDomain": "Domein opslaan", + "saveAuthPageBranding": "Branding opslaan", + "removeAuthPageBranding": "Branding verwijderen", "noDomainSet": "Geen domein ingesteld", "changeDomain": "Domein wijzigen", "selectDomain": "Domein selecteren", @@ -1817,7 +1997,7 @@ "setAuthPageDomain": "Authenticatiepagina domein instellen", "failedToFetchCertificate": "Certificaat ophalen mislukt", "failedToRestartCertificate": "Kon certificaat niet opnieuw opstarten", - "addDomainToEnableCustomAuthPages": "Een domein toevoegen om aangepaste authenticatiepagina's voor de organisatie in te schakelen", + "addDomainToEnableCustomAuthPages": "Gebruikers kunnen toegang krijgen tot de inlogpagina van de organisatie en de bronauthenticatie voltooien met dit domein.", "selectDomainForOrgAuthPage": "Selecteer een domein voor de authenticatiepagina van de organisatie", "domainPickerProvidedDomain": "Opgegeven domein", "domainPickerFreeProvidedDomain": "Gratis verstrekt domein", @@ -1832,11 +2012,27 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" kon niet geldig worden gemaakt voor {domain}.", "domainPickerSubdomainSanitized": "Subdomein gesaniseerd", "domainPickerSubdomainCorrected": "\"{sub}\" was gecorrigeerd op \"{sanitized}\"", - "orgAuthSignInTitle": "Log in op de organisatie", + "orgAuthSignInTitle": "Organisatie Inloggen", "orgAuthChooseIdpDescription": "Kies uw identiteitsprovider om door te gaan", "orgAuthNoIdpConfigured": "Deze organisatie heeft geen identiteitsproviders geconfigureerd. Je kunt in plaats daarvan inloggen met je Pangolin-identiteit.", "orgAuthSignInWithPangolin": "Log in met Pangolin", + "orgAuthSignInToOrg": "Log in bij een organisatie", + "orgAuthSelectOrgTitle": "Organisatie Inloggen", + "orgAuthSelectOrgDescription": "Voer je organisatie-ID in om verder te gaan", + "orgAuthOrgIdPlaceholder": "jouw-organisatie", + "orgAuthOrgIdHelp": "Voer de unieke ID van jouw organisatie in", + "orgAuthSelectOrgHelp": "Na het invoeren van je organisatie-ID, word je doorgestuurd naar de inlogpagina van je organisatie waar je SSO kunt gebruiken of de gegevens van je organisatie.", + "orgAuthRememberOrgId": "Vergeet deze organisatie-ID niet", + "orgAuthBackToSignIn": "Terug naar standaard aanmelden", + "orgAuthNoAccount": "Nog geen account?", "subscriptionRequiredToUse": "Een abonnement is vereist om deze functie te gebruiken.", + "mustUpgradeToUse": "U moet uw abonnement upgraden om deze functie te gebruiken.", + "subscriptionRequiredTierToUse": "Deze functie vereist {tier} of hoger.", + "upgradeToTierToUse": "Upgrade naar {tier} of hoger om deze functie te gebruiken.", + "subscriptionTierTier1": "Startpagina", + "subscriptionTierTier2": "Team", + "subscriptionTierTier3": "Bedrijfsleven", + "subscriptionTierEnterprise": "Onderneming", "idpDisabled": "Identiteitsaanbieders zijn uitgeschakeld.", "orgAuthPageDisabled": "Pagina voor organisatie-authenticatie is uitgeschakeld.", "domainRestartedDescription": "Domeinverificatie met succes opnieuw gestart", @@ -1850,6 +2046,8 @@ "enableTwoFactorAuthentication": "Tweestapsverificatie inschakelen", "completeSecuritySteps": "Voltooi beveiligingsstappen", "securitySettings": "Beveiliging instellingen", + "dangerSection": "Gevaarlijke zone", + "dangerSectionDescription": "Verwijder permanent alle gegevens die aan deze organisatie zijn gekoppeld", "securitySettingsDescription": "Beveiligingsbeleid voor de organisatie configureren", "requireTwoFactorForAllUsers": "Authenticatie in twee stappen vereist voor alle gebruikers", "requireTwoFactorDescription": "Wanneer ingeschakeld, moeten alle interne gebruikers in deze organisatie tweestapsverificatie ingeschakeld hebben om toegang te krijgen tot de organisatie.", @@ -1887,7 +2085,7 @@ "securityPolicyChangeWarningText": "Dit heeft invloed op alle gebruikers in de organisatie", "authPageErrorUpdateMessage": "Er is een fout opgetreden bij het bijwerken van de instellingen van de auth-pagina", "authPageErrorUpdate": "Kan de autorisatiepagina niet bijwerken", - "authPageUpdated": "Auth-pagina succesvol bijgewerkt", + "authPageDomainUpdated": "Auth-pagina domein succesvol bijgewerkt", "healthCheckNotAvailable": "Lokaal", "rewritePath": "Herschrijf Pad", "rewritePathDescription": "Optioneel het pad herschrijven voordat je het naar het doel doorstuurt.", @@ -1915,8 +2113,15 @@ "beta": "Bèta", "manageUserDevices": "Gebruiker Apparaten", "manageUserDevicesDescription": "Bekijk en beheer apparaten die gebruikers gebruiken om privé verbinding te maken met bronnen", + "downloadClientBannerTitle": "Download Pangolin Client", + "downloadClientBannerDescription": "Download de Pangolin-client voor je systeem om verbinding te maken met het Pangolin-netwerk en resources privé te benaderen.", "manageMachineClients": "Beheer Machine Clients", "manageMachineClientsDescription": "Creëer en beheer clients die servers en systemen gebruiken om privé verbinding te maken met bronnen", + "machineClientsBannerTitle": "Servers & Geautomatiseerde Systemen", + "machineClientsBannerDescription": "Machineclients zijn bedoeld voor servers en geautomatiseerde systemen die niet aan een specifieke gebruiker zijn gekoppeld. Ze verifiëren met een ID en geheim, en kunnen draaien met Pangolin CLI, Olm CLI, of Olm als een container.", + "machineClientsBannerPangolinCLI": "Pangolin CLI", + "machineClientsBannerOlmCLI": "Olm CLI", + "machineClientsBannerOlmContainer": "Olm-container", "clientsTableUserClients": "Gebruiker", "clientsTableMachineClients": "Machine", "licenseTableValidUntil": "Geldig tot", @@ -2015,6 +2220,32 @@ } } }, + "newPricingLicenseForm": { + "title": "Krijg een licentie", + "description": "Kies een plan en vertel ons hoe u Pangolin wilt gebruiken.", + "chooseTier": "Kies uw abonnement", + "viewPricingLink": "Zie prijzen, functies en limieten", + "tiers": { + "starter": { + "title": "Beginner", + "description": "Enterprise functies, 25 gebruikers, 25 sites en community ondersteuning." + }, + "scale": { + "title": "Schaal", + "description": "Enterprise functies, 50 gebruikers, 50 sites en prioriteit ondersteuning." + } + }, + "personalUseOnly": "Alleen persoonlijk gebruik (gratis licentie - geen afrekenen)", + "buttons": { + "continueToCheckout": "Doorgaan naar afrekenen" + }, + "toasts": { + "checkoutError": { + "title": "Fout bij afrekenen", + "description": "Kan de afhandeling niet starten. Probeer het opnieuw." + } + } + }, "priority": "Prioriteit", "priorityDescription": "routes met hogere prioriteit worden eerst geëvalueerd. Prioriteit = 100 betekent automatisch bestellen (systeem beslist de). Gebruik een ander nummer om handmatige prioriteit af te dwingen.", "instanceName": "Naam instantie", @@ -2060,13 +2291,15 @@ "request": "Aanvragen", "requests": "Verzoeken", "logs": "Logboeken", - "logsSettingsDescription": "Monitor logs verzameld van deze orginiatie", + "logsSettingsDescription": "Controleer logs verzameld van deze organisatie", "searchLogs": "Logboeken zoeken...", "action": "actie", "actor": "Acteur", "timestamp": "Artikeldatering", "accessLogs": "Toegang tot logboek", "exportCsv": "Exporteren als CSV", + "exportError": "Onbekende fout bij exporteren naar CSV", + "exportCsvTooltip": "Binnen tijdsbereik", "actorId": "Acteur ID", "allowedByRule": "Toegestaan door regel", "allowedNoAuth": "Toegestaan geen authenticatie", @@ -2111,7 +2344,8 @@ "logRetentionEndOfFollowingYear": "Einde van volgend jaar", "actionLogsDescription": "Bekijk een geschiedenis van acties die worden uitgevoerd in deze organisatie", "accessLogsDescription": "Toegangsverificatieverzoeken voor resources in deze organisatie bekijken", - "licenseRequiredToUse": "Een Enterprise-licentie is vereist om deze functie te gebruiken.", + "licenseRequiredToUse": "Een Enterprise Edition licentie of Pangolin Cloud is vereist om deze functie te gebruiken. Boek een demo of POC trial.", + "ossEnterpriseEditionRequired": "De Enterprise Edition is vereist om deze functie te gebruiken. Deze functie is ook beschikbaar in Pangolin Cloud. Boek een demo of POC trial.", "certResolver": "Certificaat Resolver", "certResolverDescription": "Selecteer de certificaat resolver die moet worden gebruikt voor deze resource.", "selectCertResolver": "Certificaat Resolver selecteren", @@ -2120,7 +2354,7 @@ "unverified": "Ongeverifieerd", "domainSetting": "Domein instellingen", "domainSettingDescription": "Configureer instellingen voor het domein", - "preferWildcardCertDescription": "Poging om een certificaat met een wildcard te genereren (vereist een correct geconfigureerde certificaatresolver).", + "preferWildcardCertDescription": "Probeer een wildcardcertificaat te genereren (vereist een correct geconfigureerde certificaatoplosser).", "recordName": "Record Naam", "auto": "Automatisch", "TTL": "TTL", @@ -2172,6 +2406,8 @@ "deviceCodeInvalidFormat": "Code moet 9 tekens bevatten (bijv. A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Ongeldige of verlopen code", "deviceCodeVerifyFailed": "Apparaatcode verifiëren mislukt", + "deviceCodeValidating": "Apparaatcode valideren...", + "deviceCodeVerifying": "Apparaatmachtiging verifiëren...", "signedInAs": "Ingelogd als", "deviceCodeEnterPrompt": "Voer de op het apparaat weergegeven code in", "continue": "Doorgaan", @@ -2184,7 +2420,7 @@ "deviceOrganizationsAccess": "Toegang tot alle organisaties waar uw account toegang tot heeft", "deviceAuthorize": "Autoriseer {applicationName}", "deviceConnected": "Apparaat verbonden!", - "deviceAuthorizedMessage": "Apparaat is gemachtigd om toegang te krijgen tot je account.", + "deviceAuthorizedMessage": "Apparaat is gemachtigd om toegang te krijgen tot je account. Ga terug naar de client applicatie.", "pangolinCloud": "Pangoline Cloud", "viewDevices": "Bekijk apparaten", "viewDevicesDescription": "Beheer uw aangesloten apparaten", @@ -2246,6 +2482,7 @@ "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Niet u? Gebruik een ander account.", "deviceLoginDeviceRequestingAccessToAccount": "Een apparaat vraagt om toegang tot dit account.", + "loginSelectAuthenticationMethod": "Selecteer een verificatiemethode om door te gaan.", "noData": "Geen gegevens", "machineClients": "Machine Clienten", "install": "Installeren", @@ -2255,6 +2492,8 @@ "setupFailedToFetchSubnet": "Kan standaard subnet niet ophalen", "setupSubnetAdvanced": "Subnet (Geavanceerd)", "setupSubnetDescription": "Het subnet van het interne netwerk van deze organisatie.", + "setupUtilitySubnet": "Hulpprogrammasubnet (Geavanceerd)", + "setupUtilitySubnetDescription": "Het subnet voor de aliasadressen en DNS-server van deze organisatie.", "siteRegenerateAndDisconnect": "Hergenereer en verbreek verbinding", "siteRegenerateAndDisconnectConfirmation": "Weet u zeker dat u de inloggegevens opnieuw wilt genereren en de verbinding met deze website wilt verbreken?", "siteRegenerateAndDisconnectWarning": "Dit zal de inloggegevens opnieuw genereren en onmiddellijk de site ontkoppelen. De site zal opnieuw moeten worden gestart met de nieuwe inloggegevens.", @@ -2270,5 +2509,179 @@ "remoteExitNodeRegenerateAndDisconnectWarning": "Dit zal de referenties regenereren en onmiddellijk de externe exit node ontkoppelen. Het externe exit node zal opnieuw moeten worden gestart met de nieuwe referenties.", "remoteExitNodeRegenerateCredentialsConfirmation": "Weet u zeker dat u de referenties voor deze externe exit node opnieuw wilt genereren?", "remoteExitNodeRegenerateCredentialsWarning": "Dit zal de referenties opnieuw genereren. De remote exit node zal verbonden blijven totdat u deze handmatig herstart en de nieuwe referenties gebruikt.", - "agent": "Agent" + "agent": "Agent", + "personalUseOnly": "Alleen voor persoonlijk gebruik", + "loginPageLicenseWatermark": "Deze instantie is alleen gelicentieerd voor persoonlijk gebruik.", + "instanceIsUnlicensed": "Deze instantie is niet gelicentieerd.", + "portRestrictions": "Poortbeperkingen", + "allPorts": "Alles", + "custom": "Aangepast", + "allPortsAllowed": "Alle poorten toegestaan", + "allPortsBlocked": "Alle poorten geblokkeerd", + "tcpPortsDescription": "Geef op welke TCP-poorten zijn toegestaan voor deze bron. Gebruik '*' voor alle poorten, laat leeg om alles te blokkeren of voer een komma-gescheiden lijst van poorten en reeksen in (bijv. 80,443,8000-9000).", + "udpPortsDescription": "Geef op welke UDP-poorten zijn toegestaan voor deze bron. Gebruik '*' voor alle poorten, laat leeg om alles te blokkeren of voer een komma-gescheiden lijst van poorten en reeksen in (bijv. 53,123,500-600).", + "organizationLoginPageTitle": "Organisatie-inlogpagina", + "organizationLoginPageDescription": "Pas de inlogpagina voor deze organisatie aan", + "resourceLoginPageTitle": "Inlogpagina voor bronnen", + "resourceLoginPageDescription": "Pas de inlogpagina aan voor individuele bronnen", + "enterConfirmation": "Bevestiging invoeren", + "blueprintViewDetails": "Details", + "defaultIdentityProvider": "Standaard Identiteitsprovider", + "defaultIdentityProviderDescription": "Wanneer een standaard identity provider is geselecteerd, zal de gebruiker automatisch worden doorgestuurd naar de provider voor authenticatie.", + "editInternalResourceDialogNetworkSettings": "Netwerkinstellingen", + "editInternalResourceDialogAccessPolicy": "Toegangsbeleid", + "editInternalResourceDialogAddRoles": "Rollen toevoegen", + "editInternalResourceDialogAddUsers": "Gebruikers toevoegen", + "editInternalResourceDialogAddClients": "Clienten toevoegen", + "editInternalResourceDialogDestinationLabel": "Bestemming", + "editInternalResourceDialogDestinationDescription": "Specificeer het bestemmingsadres voor de interne bron. Dit kan een hostnaam, IP-adres of CIDR-bereik zijn, afhankelijk van de geselecteerde modus. Stel optioneel een interne DNS-alias in voor eenvoudigere identificatie.", + "editInternalResourceDialogPortRestrictionsDescription": "Beperk toegang tot specifieke TCP/UDP-poorten of sta alle poorten toe/blokkeer.", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "Toegangs controle", + "editInternalResourceDialogAccessControlDescription": "Beheer welke rollen, gebruikers en machineclients toegang hebben tot deze bron wanneer ze zijn verbonden. Beheerders hebben altijd toegang.", + "editInternalResourceDialogPortRangeValidationError": "Poortbereik moet \"*\" zijn voor alle poorten, of een komma-gescheiden lijst van poorten en bereiken (bijv. \"80,443,8000-9000\"). Poorten moeten tussen 1 en 65535 zijn.", + "internalResourceAuthDaemonStrategy": "SSH Auth Daemon locatie", + "internalResourceAuthDaemonStrategyDescription": "Kies waar de SSH authenticatie daemon wordt uitgevoerd: op de website (Newt) of op een externe host.", + "internalResourceAuthDaemonDescription": "De SSH authenticatie daemon zorgt voor SSH sleutelondertekening en PAM authenticatie voor deze resource. Kies of het wordt uitgevoerd op de website (Nieuw) of op een afzonderlijke externe host. Zie de documentatie voor meer.", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "Selecteer strategie", + "internalResourceAuthDaemonStrategyLabel": "Locatie", + "internalResourceAuthDaemonSite": "In de site", + "internalResourceAuthDaemonSiteDescription": "Auth daemon draait op de site (Newt).", + "internalResourceAuthDaemonRemote": "Externe host", + "internalResourceAuthDaemonRemoteDescription": "Authenticatiedaemon draait op een host die niet de site is.", + "internalResourceAuthDaemonPort": "Daemon poort (optioneel)", + "orgAuthWhatsThis": "Waar kan ik mijn organisatie-ID vinden?", + "learnMore": "Meer informatie", + "backToHome": "Ga terug naar startpagina", + "needToSignInToOrg": "Moet u de identiteit provider van uw organisatie gebruiken?", + "maintenanceMode": "Onderhoudsmodus", + "maintenanceModeDescription": "Toon een onderhoudspagina aan bezoekers", + "maintenanceModeType": "Type onderhoudsmodus", + "showMaintenancePage": "Toon een onderhoudspagina aan bezoekers", + "enableMaintenanceMode": "Onderhoudsmodus inschakelen", + "automatic": "Automatisch", + "automaticModeDescription": " Toon onderhoudspagina alleen wanneer alle back-enddoelen niet beschikbaar zijn of ongezond zijn. Jouw bron blijft normaal functioneren zolang er tenminste één doel gezond is.", + "forced": "Geforceerd", + "forcedModeDescription": "Toon altijd de onderhoudspagina ongeacht de gezondheid van de backend. Gebruik dit voor gepland onderhoud wanneer je alle toegang wilt voorkomen.", + "warning:": "Waarschuwing:", + "forcedeModeWarning": "Al het verkeer wordt naar de onderhoudspagina geleid. Jouw back-endbronnen ontvangen geen verzoeken.", + "pageTitle": "Paginatitel", + "pageTitleDescription": "De hoofdkop die op de onderhoudspagina wordt weergegeven", + "maintenancePageMessage": "Onderhoudsbericht", + "maintenancePageMessagePlaceholder": "We keren snel terug! Onze site ondergaat momenteel gepland onderhoud.", + "maintenancePageMessageDescription": "Gedetailleerd bericht dat het onderhoud uitlegt", + "maintenancePageTimeTitle": "Geschatte voltooiingstijd (optioneel)", + "maintenanceTime": "bijv. 2 uur, 1 nov om 17:00", + "maintenanceEstimatedTimeDescription": "Wanneer u verwacht dat het onderhoud voltooid is", + "editDomain": "Domein bewerken", + "editDomainDescription": "Selecteer een domein voor jouw hulpbron", + "maintenanceModeDisabledTooltip": "Deze functie vereist een geldige licentie om in te schakelen.", + "maintenanceScreenTitle": "Dienst tijdelijk niet beschikbaar", + "maintenanceScreenMessage": "We hebben momenteel technische problemen. Probeer het later opnieuw.", + "maintenanceScreenEstimatedCompletion": "Geschatte voltooiing:", + "createInternalResourceDialogDestinationRequired": "Bestemming is vereist", + "available": "Beschikbaar", + "archived": "Gearchiveerd", + "noArchivedDevices": "Geen gearchiveerde apparaten gevonden", + "deviceArchived": "Apparaat gearchiveerd", + "deviceArchivedDescription": "Het apparaat is met succes gearchiveerd.", + "errorArchivingDevice": "Fout bij archiveren apparaat", + "failedToArchiveDevice": "Kan apparaat niet archiveren", + "deviceQuestionArchive": "Weet u zeker dat u dit apparaat wilt archiveren?", + "deviceMessageArchive": "Het apparaat wordt gearchiveerd en verwijderd uit de lijst met actieve apparaten.", + "deviceArchiveConfirm": "Archiveer apparaat", + "archiveDevice": "Archiveer apparaat", + "archive": "Archief", + "deviceUnarchived": "Apparaat niet gearchiveerd", + "deviceUnarchivedDescription": "Het apparaat is met succes gedearchiveerd.", + "errorUnarchivingDevice": "Fout bij dearchiveren van apparaat", + "failedToUnarchiveDevice": "Apparaat dearchiveren mislukt", + "unarchive": "Dearchiveren", + "archiveClient": "Archiveer client", + "archiveClientQuestion": "Weet u zeker dat u deze client wilt archiveren?", + "archiveClientMessage": "De klant zal worden gearchiveerd en verwijderd uit de lijst met actieve cliënten.", + "archiveClientConfirm": "Archiveer client", + "blockClient": "Blokkeer klant", + "blockClientQuestion": "Weet u zeker dat u deze cliënt wilt blokkeren?", + "blockClientMessage": "Het apparaat zal worden gedwongen de verbinding te verbreken als het momenteel is verbonden. U kunt het apparaat later deblokkeren.", + "blockClientConfirm": "Blokkeer klant", + "active": "actief", + "usernameOrEmail": "Gebruikersnaam of e-mailadres", + "selectYourOrganization": "Selecteer uw organisatie", + "signInTo": "Log in op", + "signInWithPassword": "Ga verder met wachtwoord", + "noAuthMethodsAvailable": "Geen verificatiemethoden beschikbaar voor deze organisatie.", + "enterPassword": "Voer je wachtwoord in", + "enterMfaCode": "Voer de code van je authenticator-app in", + "securityKeyRequired": "Gebruik uw beveiligingssleutel om in te loggen.", + "needToUseAnotherAccount": "Wilt u een ander account gebruiken?", + "loginLegalDisclaimer": "Door op de knoppen hieronder te klikken, erken je dat je gelezen en begrepen hebt en ga akkoord met de Gebruiksvoorwaarden en Privacybeleid.", + "termsOfService": "Algemene gebruiksvoorwaarden", + "privacyPolicy": "Privacy Beleid", + "userNotFoundWithUsername": "Geen gebruiker gevonden met die gebruikersnaam.", + "verify": "Verifiëren", + "signIn": "Log in", + "forgotPassword": "Wachtwoord vergeten?", + "orgSignInTip": "Als u eerder bent ingelogd, kunt u uw gebruikersnaam of e-mail hierboven invoeren om in plaats daarvan te verifiëren met de identiteitsprovider van uw organisatie! Het is makkelijk!", + "continueAnyway": "Toch doorgaan", + "dontShowAgain": "Niet meer weergeven", + "orgSignInNotice": "Wist u dat?", + "signupOrgNotice": "Proberen je aan te melden?", + "signupOrgTip": "Probeert u zich aan te melden via de identiteitsprovider van uw organisatie?", + "signupOrgLink": "Log in of meld je aan bij je organisatie", + "verifyEmailLogInWithDifferentAccount": "Gebruik een ander account", + "logIn": "Log in", + "deviceInformation": "Apparaat informatie", + "deviceInformationDescription": "Informatie over het apparaat en de agent", + "deviceSecurity": "Apparaat beveiliging", + "deviceSecurityDescription": "Apparaat beveiligingsinformatie", + "platform": "Platform", + "macosVersion": "macOS versie", + "windowsVersion": "Windows versie", + "iosVersion": "iOS versie", + "androidVersion": "Android versie", + "osVersion": "OS versie", + "kernelVersion": "Kernel versie", + "deviceModel": "Apparaat model", + "serialNumber": "Serienummer", + "hostname": "Hostname", + "firstSeen": "Eerst gezien", + "lastSeen": "Laatst gezien op", + "biometricsEnabled": "Biometrie ingeschakeld", + "diskEncrypted": "Schijf versleuteld", + "firewallEnabled": "Firewall ingeschakeld", + "autoUpdatesEnabled": "Auto Updates Ingeschakeld", + "tpmAvailable": "TPM beschikbaar", + "windowsAntivirusEnabled": "Antivirus ingeschakeld", + "macosSipEnabled": "Systeemintegriteitsbescherming (SIP)", + "macosGatekeeperEnabled": "Gatekeeper", + "macosFirewallStealthMode": "Firewall Verberg Modus", + "linuxAppArmorEnabled": "Appharnas", + "linuxSELinuxEnabled": "SELinux", + "deviceSettingsDescription": "Apparaatinformatie en -instellingen bekijken", + "devicePendingApprovalDescription": "Dit apparaat wacht op goedkeuring", + "deviceBlockedDescription": "Dit apparaat is momenteel geblokkeerd. Het kan geen verbinding maken met bronnen tenzij het wordt gedeblokkeerd.", + "unblockClient": "Deblokkeer client", + "unblockClientDescription": "Het apparaat is gedeblokkeerd", + "unarchiveClient": "Dearchiveer client", + "unarchiveClientDescription": "Het apparaat is gedearchiveerd", + "block": "Blokkeren", + "unblock": "Deblokkeer", + "deviceActions": "Apparaat Acties", + "deviceActionsDescription": "Apparaatstatus en toegang beheren", + "devicePendingApprovalBannerDescription": "Dit apparaat wacht op goedkeuring. Het zal niet in staat zijn verbinding te maken met bronnen totdat het is goedgekeurd.", + "connected": "Verbonden", + "disconnected": "Losgekoppeld", + "approvalsEmptyStateTitle": "Apparaat goedkeuringen niet ingeschakeld", + "approvalsEmptyStateDescription": "Apparaatgoedkeuringen voor rollen inschakelen om goedkeuring van de beheerder te vereisen voordat gebruikers nieuwe apparaten kunnen koppelen.", + "approvalsEmptyStateStep1Title": "Ga naar rollen", + "approvalsEmptyStateStep1Description": "Navigeer naar de rolinstellingen van uw organisatie om apparaatgoedkeuringen te configureren.", + "approvalsEmptyStateStep2Title": "Toestel goedkeuringen inschakelen", + "approvalsEmptyStateStep2Description": "Bewerk een rol en schakel de optie 'Vereist Apparaat Goedkeuringen' in. Gebruikers met deze rol hebben admin goedkeuring nodig voor nieuwe apparaten.", + "approvalsEmptyStatePreviewDescription": "Voorbeeld: Indien ingeschakeld, zullen in afwachting van apparaatverzoeken hier verschijnen om te beoordelen", + "approvalsEmptyStateButtonText": "Rollen beheren", + "domainErrorTitle": "We ondervinden problemen bij het controleren van uw domein" } diff --git a/messages/pl-PL.json b/messages/pl-PL.json index 99817d147..998fcc880 100644 --- a/messages/pl-PL.json +++ b/messages/pl-PL.json @@ -1,5 +1,7 @@ { "setupCreate": "Utwórz organizację, witrynę i zasoby", + "headerAuthCompatibilityInfo": "Włącz to, aby wymusić odpowiedź Unauthorized 401, gdy brakuje tokena uwierzytelniania. Jest to wymagane dla przeglądarek lub określonych bibliotek HTTP, które nie wysyłają poświadczeń bez wyzwania serwera.", + "headerAuthCompatibility": "Rozszerzona kompatybilność", "setupNewOrg": "Nowa organizacja", "setupCreateOrg": "Utwórz organizację", "setupCreateResources": "Utwórz Zasoby", @@ -16,6 +18,8 @@ "componentsMember": "Jesteś członkiem {count, plural, =0 {żadna organizacja} one {jedna organizacja} few {# organizacje} many {# organizacji} other {# organizacji}}.", "componentsInvalidKey": "Wykryto nieprawidłowe lub wygasłe klucze licencyjne. Postępuj zgodnie z warunkami licencji, aby kontynuować korzystanie ze wszystkich funkcji.", "dismiss": "Odrzuć", + "subscriptionViolationMessage": "Nie masz ograniczeń dla aktualnego planu. Popraw problem poprzez usunięcie stron, użytkowników lub innych zasobów, aby pozostać w swoim planie.", + "subscriptionViolationViewBilling": "Zobacz rozliczenie", "componentsLicenseViolation": "Naruszenie licencji: Ten serwer używa stron {usedSites} , które przekraczają limit licencyjny stron {maxSites} . Postępuj zgodnie z warunkami licencji, aby kontynuować korzystanie ze wszystkich funkcji.", "componentsSupporterMessage": "Dziękujemy za wsparcie Pangolina jako {tier}!", "inviteErrorNotValid": "Przykro nam, ale wygląda na to, że zaproszenie, do którego próbujesz uzyskać dostęp, nie zostało zaakceptowane lub jest już nieważne.", @@ -39,8 +43,8 @@ "online": "Dostępny", "offline": "Offline", "site": "Witryna", - "dataIn": "Dane w", - "dataOut": "Dane niedostępne", + "dataIn": "Dane Przychodzące", + "dataOut": "Dane Wychodzące", "connectionType": "Typ połączenia", "tunnelType": "Typ tunelu", "local": "Lokalny", @@ -51,6 +55,12 @@ "siteQuestionRemove": "Czy na pewno chcesz usunąć witrynę z organizacji?", "siteManageSites": "Zarządzaj stronami", "siteDescription": "Tworzenie stron i zarządzanie nimi, aby włączyć połączenia z prywatnymi sieciami", + "sitesBannerTitle": "Połącz dowolną sieć", + "sitesBannerDescription": "Witryna to połączenie z siecią zdalną, które umożliwia Pangolinowi zapewnienie dostępu do zasobów, publicznych lub prywatnych, użytkownikom w dowolnym miejscu. Zainstaluj łącznik sieci w witrynie (Newt) w dowolnym miejscu, w którym możesz uruchomić binarkę lub kontener, aby ustanowić połączenie.", + "sitesBannerButtonText": "Zainstaluj witrynę", + "approvalsBannerTitle": "Zatwierdź lub odmów dostępu do urządzenia", + "approvalsBannerDescription": "Przejrzyj i zatwierdzaj lub odmawiaj użytkownikom dostępu do urządzenia. Gdy wymagane jest zatwierdzenie urządzenia, użytkownicy muszą uzyskać zatwierdzenie administratora, zanim ich urządzenia będą mogły połączyć się z zasobami Twojej organizacji.", + "approvalsBannerButtonText": "Dowiedz się więcej", "siteCreate": "Utwórz witrynę", "siteCreateDescription2": "Wykonaj poniższe kroki, aby utworzyć i połączyć nową witrynę", "siteCreateDescription": "Utwórz nową witrynę, aby rozpocząć łączenie zasobów", @@ -63,11 +73,11 @@ "siteLearnNewt": "Dowiedz się, jak zainstalować Newt w systemie", "siteSeeConfigOnce": "Możesz zobaczyć konfigurację tylko raz.", "siteLoadWGConfig": "Ładowanie konfiguracji WireGuard...", - "siteDocker": "Rozwiń o szczegóły wdrożenia dokera", + "siteDocker": "Rozwiń o szczegóły wdrożenia Dockera", "toggle": "Przełącz", "dockerCompose": "Kompozytor dokujący", "dockerRun": "Uruchom Docker", - "siteLearnLocal": "Lokalne witryny nie tunelowają, dowiedz się więcej", + "siteLearnLocal": "Lokalne witryny nie tunelują, dowiedz się więcej", "siteConfirmCopy": "Skopiowałem konfigurację", "searchSitesProgress": "Szukaj witryn...", "siteAdd": "Dodaj witrynę", @@ -78,9 +88,9 @@ "operatingSystem": "System operacyjny", "commands": "Polecenia", "recommended": "Rekomendowane", - "siteNewtDescription": "Aby uzyskać najlepsze doświadczenia użytkownika, użyj Newt. Używa WireGuard pod zapleczem i pozwala na przekierowanie twoich prywatnych zasobów przez ich adres LAN w sieci prywatnej z panelu Pangolin.", - "siteRunsInDocker": "Uruchamia w Docke'u", - "siteRunsInShell": "Uruchamia w skorupce na macOS, Linux i Windows", + "siteNewtDescription": "Aby uzyskać najlepsze doświadczenia użytkownika, użyj Newt. Używa wewnętrznie WireGuard i pozwala na przekierowanie twoich prywatnych zasobów przez ich adres LAN w sieci prywatnej z panelu Pangolin.", + "siteRunsInDocker": "Uruchamia w Dockerze", + "siteRunsInShell": "Uruchamia w powłoce na macOS, Linux i Windows", "siteErrorDelete": "Błąd podczas usuwania witryny", "siteErrorUpdate": "Nie udało się zaktualizować witryny", "siteErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji witryny.", @@ -90,7 +100,7 @@ "siteSettingDescription": "Skonfiguruj ustawienia na stronie", "siteSetting": "Ustawienia {siteName}", "siteNewtTunnel": "Newt Site (Rekomendowane)", - "siteNewtTunnelDescription": "Najprostszy sposób na stworzenie punktu wejścia w żadnej sieci. Nie ma dodatkowej konfiguracji.", + "siteNewtTunnelDescription": "Najprostszy sposób na stworzenie punktu wejścia w sieci. Nie ma dodatkowej konfiguracji.", "siteWg": "Podstawowy WireGuard", "siteWgDescription": "Użyj dowolnego klienta WireGuard do utworzenia tunelu. Wymagana jest ręczna konfiguracja NAT.", "siteWgDescriptionSaas": "Użyj dowolnego klienta WireGuard do utworzenia tunelu. Wymagana ręczna konfiguracja NAT. DZIAŁA TYLKO NA SAMODZIELNIE HOSTOWANYCH WĘZŁACH", @@ -100,6 +110,7 @@ "siteTunnelDescription": "Określ jak chcesz połączyć się z witryną", "siteNewtCredentials": "Dane logowania", "siteNewtCredentialsDescription": "Oto jak witryna będzie uwierzytelniać się z serwerem", + "remoteNodeCredentialsDescription": "Tak będzie działać uwierzytelnianie z serwerem dla zdalnego węzła", "siteCredentialsSave": "Zapisz dane logowania", "siteCredentialsSaveDescription": "Możesz to zobaczyć tylko raz. Upewnij się, że skopiuj je do bezpiecznego miejsca.", "siteInfo": "Informacje o witrynie", @@ -146,20 +157,25 @@ "shareErrorSelectResource": "Wybierz zasób", "proxyResourceTitle": "Zarządzaj zasobami publicznymi", "proxyResourceDescription": "Twórz i zarządzaj zasobami, które są publicznie dostępne w przeglądarce internetowej", + "proxyResourcesBannerTitle": "Publiczny dostęp za pośrednictwem sieci Web", + "proxyResourcesBannerDescription": "Zasoby publiczne to proxy HTTPS lub TCP/UDP dostępne dla każdego w internecie za pośrednictwem przeglądarki internetowej. W przeciwieństwie do zasobów prywatnych, nie wymagają oprogramowania po stronie klienta i mogą obejmować polityki dostępu świadome tożsamości i kontekstu.", "clientResourceTitle": "Zarządzaj zasobami prywatnymi", "clientResourceDescription": "Twórz i zarządzaj zasobami, które są dostępne tylko za pośrednictwem połączonego klienta", + "privateResourcesBannerTitle": "Zero zaufania do prywatnego dostępu", + "privateResourcesBannerDescription": "Zasoby prywatne korzystają z zabezpieczeń zero-trust, zapewniając dostęp do zasobów użytkownikom i maszynom, którym wyraźnie udzielasz dostępu. Połącz urządzenia użytkowników lub klientów maszyn z tymi zasobami przez bezpieczną prywatną sieć wirtualną.", "resourcesSearch": "Szukaj zasobów...", "resourceAdd": "Dodaj zasób", "resourceErrorDelte": "Błąd podczas usuwania zasobu", "authentication": "Uwierzytelnianie", "protected": "Chronione", "notProtected": "Niechronione", - "resourceMessageRemove": "Po usunięciu, zasób nie będzie już dostępny. Wszystkie cele związane z zasobem zostaną również usunięte.", + "resourceMessageRemove": "Po usunięciu zasób nie będzie już dostępny. Wszystkie cele związane z zasobem zostaną również usunięte.", "resourceQuestionRemove": "Czy na pewno chcesz usunąć zasób z organizacji?", "resourceHTTP": "Zasób HTTPS", - "resourceHTTPDescription": "Proxy żądania do aplikacji przez HTTPS przy użyciu poddomeny lub domeny bazowej.", + "resourceHTTPDescription": "Proxy zapytań przez HTTPS przy użyciu w pełni kwalifikowanej nazwy domeny.", "resourceRaw": "Surowy zasób TCP/UDP", - "resourceRawDescription": "Proxy żądania do aplikacji przez TCP/UDP przy użyciu numeru portu. Działa to tylko wtedy, gdy witryny są podłączone do węzłów.", + "resourceRawDescription": "Proxy zapytań przez surowe TCP/UDP przy użyciu numeru portu.", + "resourceRawDescriptionCloud": "Żądania proxy nad surowym TCP/UDP przy użyciu numeru portu. Wymaga stron aby połączyć się ze zdalnym węzłem.", "resourceCreate": "Utwórz zasób", "resourceCreateDescription": "Wykonaj poniższe kroki, aby utworzyć nowy zasób", "resourceSeeAll": "Zobacz wszystkie zasoby", @@ -186,6 +202,7 @@ "protocolSelect": "Wybierz protokół", "resourcePortNumber": "Numer portu", "resourcePortNumberDescription": "Numer portu zewnętrznego do żądań proxy.", + "back": "Powrót", "cancel": "Anuluj", "resourceConfig": "Snippety konfiguracji", "resourceConfigDescription": "Skopiuj i wklej te fragmenty konfiguracji, aby skonfigurować zasób TCP/UDP", @@ -202,7 +219,7 @@ "general": "Ogólny", "generalSettings": "Ustawienia ogólne", "proxy": "Serwer pośredniczący", - "internal": "Wewętrzny", + "internal": "Wewnętrzny", "rules": "Regulamin", "resourceSettingDescription": "Skonfiguruj ustawienia zasobu", "resourceSetting": "Ustawienia {resourceName}", @@ -215,7 +232,7 @@ "saveGeneralSettings": "Zapisz ustawienia ogólne", "saveSettings": "Zapisz ustawienia", "orgDangerZone": "Strefa zagrożenia", - "orgDangerZoneDescription": "Po usunięciu tego organa nie ma odwrotu. Upewnij się.", + "orgDangerZoneDescription": "Po usunięciu tej organizacji nie ma odwrotu. Upewnij się.", "orgDelete": "Usuń organizację", "orgDeleteConfirm": "Potwierdź usunięcie organizacji", "orgMessageRemove": "Ta akcja jest nieodwracalna i usunie wszystkie powiązane dane.", @@ -231,6 +248,17 @@ "orgErrorDeleteMessage": "Wystąpił błąd podczas usuwania organizacji.", "orgDeleted": "Organizacja usunięta", "orgDeletedMessage": "Organizacja i jej dane zostały usunięte.", + "deleteAccount": "Usuń konto", + "deleteAccountDescription": "Trwale usuń swoje konto, wszystkie organizacje, które posiadasz, oraz wszystkie dane w ramach tych organizacji. Tej operacji nie można cofnąć.", + "deleteAccountButton": "Usuń konto", + "deleteAccountConfirmTitle": "Usuń konto", + "deleteAccountConfirmMessage": "Spowoduje to trwałe usunięcie konta, wszystkich organizacji, które posiadasz, oraz wszystkich danych w tych organizacjach. Tej operacji nie można cofnąć.", + "deleteAccountConfirmString": "usuń konto", + "deleteAccountSuccess": "Konto usunięte", + "deleteAccountSuccessMessage": "Twoje konto zostało usunięte.", + "deleteAccountError": "Nie udało się usunąć konta", + "deleteAccountPreviewAccount": "Twoje konto", + "deleteAccountPreviewOrgs": "Organizacje, które jesteś właścicielem (i wszystkie ich dane)", "orgMissing": "Brak ID organizacji", "orgMissingMessage": "Nie można ponownie wygenerować zaproszenia bez ID organizacji.", "accessUsersManage": "Zarządzaj użytkownikami", @@ -247,6 +275,8 @@ "accessRolesSearch": "Szukaj ról...", "accessRolesAdd": "Dodaj rolę", "accessRoleDelete": "Usuń rolę", + "accessApprovalsManage": "Zarządzaj zatwierdzaniem", + "accessApprovalsDescription": "Przeglądaj i zarządzaj oczekującymi zatwierdzeniami dostępu do tej organizacji", "description": "Opis", "inviteTitle": "Otwórz zaproszenia", "inviteDescription": "Zarządzaj zaproszeniami dla innych użytkowników do dołączenia do organizacji", @@ -322,7 +352,7 @@ "licenseErrorKeyActivate": "Nie udało się aktywować klucza licencji", "licenseErrorKeyActivateDescription": "Wystąpił błąd podczas aktywacji klucza licencyjnego.", "licenseAbout": "O licencjonowaniu", - "communityEdition": "Edycja Społeczności", + "communityEdition": "Edycja Społecznościowa", "licenseAboutDescription": "Dotyczy to przedsiębiorstw i przedsiębiorstw, którzy stosują Pangolin w środowisku handlowym. Jeśli używasz Pangolin do użytku osobistego, możesz zignorować tę sekcję.", "licenseKeyActivated": "Klucz licencyjny aktywowany", "licenseKeyActivatedDescription": "Klucz licencyjny został pomyślnie aktywowany.", @@ -440,6 +470,20 @@ "selectDuration": "Wybierz okres", "selectResource": "Wybierz zasób", "filterByResource": "Filtruj według zasobów", + "selectApprovalState": "Wybierz województwo zatwierdzające", + "filterByApprovalState": "Filtruj według państwa zatwierdzenia", + "approvalListEmpty": "Brak zatwierdzeń", + "approvalState": "Państwo zatwierdzające", + "approvalLoadMore": "Załaduj więcej", + "loadingApprovals": "Wczytywanie zatwierdzeń", + "approve": "Zatwierdź", + "approved": "Zatwierdzone", + "denied": "Odmowa", + "deniedApproval": "Odrzucono zatwierdzenie", + "all": "Wszystko", + "deny": "Odmowa", + "viewDetails": "Zobacz szczegóły", + "requestingNewDeviceApproval": "zażądano nowego urządzenia", "resetFilters": "Resetuj filtry", "totalBlocked": "Żądania zablokowane przez Pangolina", "totalRequests": "Wszystkich Żądań", @@ -456,7 +500,7 @@ "idpSelect": "Wybierz dostawcę tożsamości dla użytkownika zewnętrznego", "idpNotConfigured": "Nie skonfigurowano żadnych dostawców tożsamości. Skonfiguruj dostawcę tożsamości przed utworzeniem użytkowników zewnętrznych.", "usernameUniq": "Musi to odpowiadać unikalnej nazwie użytkownika istniejącej u wybranego dostawcy tożsamości.", - "emailOptional": "Email (Opcjonalnie)", + "emailOptional": "E-mail (Opcjonalnie)", "nameOptional": "Nazwa (Opcjonalnie)", "accessControls": "Kontrola dostępu", "userDescription2": "Zarządzaj ustawieniami tego użytkownika", @@ -607,6 +651,7 @@ "resourcesErrorUpdate": "Nie udało się przełączyć zasobu", "resourcesErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji zasobu", "access": "Dostęp", + "accessControl": "Kontrola dostępu", "shareLink": "Link udostępniania {resource}", "resourceSelect": "Wybierz zasób", "shareLinks": "Linki udostępniania", @@ -668,7 +713,7 @@ "resourceErrorWhitelistSave": "Nie udało się zapisać białej listy", "resourceErrorWhitelistSaveDescription": "Wystąpił błąd podczas zapisywania białej listy", "resourcePasswordSubmit": "Włącz ochronę hasłem", - "resourcePasswordProtection": "Ochrona haseł {status}", + "resourcePasswordProtection": "Ochrona hasłem {status}", "resourcePasswordRemove": "Hasło zasobu zostało usunięte", "resourcePasswordRemoveDescription": "Hasło zasobu zostało pomyślnie usunięte", "resourcePasswordSetup": "Ustawiono hasło zasobu", @@ -687,7 +732,7 @@ "resourceRoleDescription": "Administratorzy zawsze mają dostęp do tego zasobu.", "resourceUsersRoles": "Kontrola dostępu", "resourceUsersRolesDescription": "Skonfiguruj, którzy użytkownicy i role mogą odwiedzać ten zasób", - "resourceUsersRolesSubmit": "Zapisz użytkowników i role", + "resourceUsersRolesSubmit": "Zapisz kontrole dostępu", "resourceWhitelistSave": "Zapisano pomyślnie", "resourceWhitelistSaveDescription": "Ustawienia białej listy zostały zapisane", "ssoUse": "Użyj platformy SSO", @@ -719,22 +764,35 @@ "countries": "Kraje", "accessRoleCreate": "Utwórz rolę", "accessRoleCreateDescription": "Utwórz nową rolę aby zgrupować użytkowników i zarządzać ich uprawnieniami.", + "accessRoleEdit": "Edytuj rolę", + "accessRoleEditDescription": "Edytuj informacje o rolach.", "accessRoleCreateSubmit": "Utwórz rolę", "accessRoleCreated": "Rola utworzona", "accessRoleCreatedDescription": "Rola została pomyślnie utworzona.", "accessRoleErrorCreate": "Nie udało się utworzyć roli", "accessRoleErrorCreateDescription": "Wystąpił błąd podczas tworzenia roli.", + "accessRoleUpdateSubmit": "Aktualizuj rolę", + "accessRoleUpdated": "Rola zaktualizowana", + "accessRoleUpdatedDescription": "Rola została pomyślnie zaktualizowana.", + "accessApprovalUpdated": "Zatwierdzenie przetworzone", + "accessApprovalApprovedDescription": "Ustaw decyzję o zatwierdzeniu wniosku o zatwierdzenie.", + "accessApprovalDeniedDescription": "Ustaw decyzję o odrzuceniu wniosku o zatwierdzenie.", + "accessRoleErrorUpdate": "Nie udało się zaktualizować roli", + "accessRoleErrorUpdateDescription": "Wystąpił błąd podczas aktualizowania roli.", + "accessApprovalErrorUpdate": "Nie udało się przetworzyć zatwierdzenia", + "accessApprovalErrorUpdateDescription": "Wystąpił błąd podczas przetwarzania zatwierdzenia.", "accessRoleErrorNewRequired": "Nowa rola jest wymagana", "accessRoleErrorRemove": "Nie udało się usunąć roli", "accessRoleErrorRemoveDescription": "Wystąpił błąd podczas usuwania roli.", "accessRoleName": "Nazwa roli", - "accessRoleQuestionRemove": "Zamierzasz usunąć rolę {name}. Tej akcji nie można cofnąć.", + "accessRoleQuestionRemove": "Zamierzasz usunąć rolę `{name}`. Nie możesz cofnąć tej czynności.", "accessRoleRemove": "Usuń rolę", "accessRoleRemoveDescription": "Usuń rolę z organizacji", "accessRoleRemoveSubmit": "Usuń rolę", "accessRoleRemoved": "Rola usunięta", "accessRoleRemovedDescription": "Rola została pomyślnie usunięta.", "accessRoleRequiredRemove": "Przed usunięciem tej roli, wybierz nową rolę do której zostaną przeniesieni obecni członkowie.", + "network": "Sieć", "manage": "Zarządzaj", "sitesNotFound": "Nie znaleziono witryn.", "pangolinServerAdmin": "Administrator serwera - Pangolin", @@ -750,6 +808,9 @@ "sitestCountIncrease": "Zwiększ liczbę witryn", "idpManage": "Zarządzaj dostawcami tożsamości", "idpManageDescription": "Wyświetl i zarządzaj dostawcami tożsamości w systemie", + "idpGlobalModeBanner": "Dostawcy tożsamości (IdPs) na organizację są wyłączeni na tym serwerze. Używa globalnych IdP (współdzielonych ze wszystkimi organizacjami). Zarządzaj globalnymi IdP w panelu administracyjnym . Aby włączyć IdP na organizację, edytuj konfigurację serwera i ustaw tryb IdP na org. Zobacz dokumentację. Jeśli chcesz nadal używać globalnych IdP i sprawić, że zniknie to z ustawień organizacji, wyraźnie ustaw tryb globalny w konfiguracji.", + "idpGlobalModeBannerUpgradeRequired": "Dostawcy tożsamości (IdPs) na organizację są wyłączeni na tym serwerze. Używają globalnych IdP (współdzielonych między wszystkimi organizacjami). Zarządzaj globalnymi IdP w panelu administracyjnym . Aby korzystać z dostawców tożsamości na organizację, musisz zaktualizować do edycji Enterprise.", + "idpGlobalModeBannerLicenseRequired": "Dostawcy tożsamości (IdPs) na organizację są wyłączeni na tym serwerze. Używają globalnych IdP (współdzielonych między wszystkimi organizacjami). Zarządzaj globalnymi IdP w panelu administracyjnym . Aby korzystać z dostawców tożsamości na organizację, wymagana jest licencja Enterprise.", "idpDeletedDescription": "Dostawca tożsamości został pomyślnie usunięty", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Czy na pewno chcesz trwale usunąć dostawcę tożsamości?", @@ -789,7 +850,7 @@ "idpTokenUrl": "URL tokena", "idpTokenUrlDescription": "URL punktu końcowego tokena OAuth2", "idpOidcConfigureAlert": "Ważna informacja", - "idpOidcConfigureAlertDescription": "Po utworzeniu dostawcy tożsamości, musisz skonfigurować adres URL wywołania zwrotnego w ustawieniach dostawcy tożsamości. Adres zwrotny zostanie podany po pomyślnym utworzeniu.", + "idpOidcConfigureAlertDescription": "Po utworzeniu dostawcy tożsamości musisz skonfigurować adres URL wywołania zwrotnego w ustawieniach dostawcy tożsamości. Adres zwrotny zostanie podany po pomyślnym utworzeniu.", "idpToken": "Konfiguracja tokena", "idpTokenDescription": "Skonfiguruj jak wydobywać informacje o użytkowniku z tokena ID", "idpJmespathAbout": "O JMESPath", @@ -840,6 +901,7 @@ "orgPolicyConfig": "Skonfiguruj dostęp dla organizacji", "idpUpdatedDescription": "Dostawca tożsamości został pomyślnie zaktualizowany", "redirectUrl": "URL przekierowania", + "orgIdpRedirectUrls": "Przekieruj adresy URL", "redirectUrlAbout": "O URL przekierowania", "redirectUrlAboutDescription": "Jest to adres URL, na który użytkownicy zostaną przekierowani po uwierzytelnieniu. Musisz skonfigurować ten adres URL w ustawieniach dostawcy tożsamości.", "pangolinAuth": "Autoryzacja - Pangolin", @@ -943,13 +1005,13 @@ "passwordExpiryDescription": "Organizacja wymaga zmiany hasła co {maxDays} dni.", "changePasswordNow": "Zmień hasło teraz", "pincodeAuth": "Kod uwierzytelniający", - "pincodeSubmit2": "Wyślij kod", + "pincodeSubmit2": "Prześlij kod", "passwordResetSubmit": "Zażądaj resetowania", - "passwordResetAlreadyHaveCode": "Wprowadź kod resetowania hasła", + "passwordResetAlreadyHaveCode": "Wprowadź kod", "passwordResetSmtpRequired": "Skontaktuj się z administratorem", "passwordResetSmtpRequiredDescription": "Aby zresetować hasło, wymagany jest kod resetowania hasła. Skontaktuj się z administratorem.", "passwordBack": "Powrót do hasła", - "loginBack": "Wróć do logowania", + "loginBack": "Wróć do strony logowania głównego", "signup": "Zarejestruj się", "loginStart": "Zaloguj się, aby rozpocząć", "idpOidcTokenValidating": "Walidacja tokena OIDC", @@ -972,12 +1034,12 @@ "pangolinSetup": "Konfiguracja - Pangolin", "orgNameRequired": "Nazwa organizacji jest wymagana", "orgIdRequired": "ID organizacji jest wymagane", + "orgIdMaxLength": "Identyfikator organizacji musi mieć co najwyżej 32 znaki", "orgErrorCreate": "Wystąpił błąd podczas tworzenia organizacji", "pageNotFound": "Nie znaleziono strony", "pageNotFoundDescription": "Ups! Strona, której szukasz, nie istnieje.", "overview": "Przegląd", "home": "Strona główna", - "accessControl": "Kontrola dostępu", "settings": "Ustawienia", "usersAll": "Wszyscy użytkownicy", "license": "Licencja", @@ -1035,15 +1097,24 @@ "updateOrgUser": "Aktualizuj użytkownika Org", "createOrgUser": "Utwórz użytkownika Org", "actionUpdateOrg": "Aktualizuj organizację", + "actionRemoveInvitation": "Usuń zaproszenie", "actionUpdateUser": "Zaktualizuj użytkownika", "actionGetUser": "Pobierz użytkownika", "actionGetOrgUser": "Pobierz użytkownika organizacji", "actionListOrgDomains": "Lista domen organizacji", + "actionGetDomain": "Pobierz domenę", + "actionCreateOrgDomain": "Utwórz domenę", + "actionUpdateOrgDomain": "Aktualizuj domenę", + "actionDeleteOrgDomain": "Usuń domenę", + "actionGetDNSRecords": "Pobierz rekordy DNS", + "actionRestartOrgDomain": "Zrestartuj domenę", "actionCreateSite": "Utwórz witrynę", "actionDeleteSite": "Usuń witrynę", "actionGetSite": "Pobierz witrynę", "actionListSites": "Lista witryn", "actionApplyBlueprint": "Zastosuj schemat", + "actionListBlueprints": "Lista planów", + "actionGetBlueprint": "Pobierz plan", "setupToken": "Skonfiguruj token", "setupTokenDescription": "Wprowadź token konfiguracji z konsoli serwera.", "setupTokenRequired": "Wymagany jest token konfiguracji", @@ -1077,6 +1148,7 @@ "actionRemoveUser": "Usuń użytkownika", "actionListUsers": "Lista użytkowników", "actionAddUserRole": "Dodaj rolę użytkownika", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Wygeneruj token dostępu", "actionDeleteAccessToken": "Usuń token dostępu", "actionListAccessTokens": "Lista tokenów dostępu", @@ -1104,6 +1176,10 @@ "actionUpdateIdpOrg": "Aktualizuj organizację IDP", "actionCreateClient": "Utwórz klienta", "actionDeleteClient": "Usuń klienta", + "actionArchiveClient": "Zarchiwizuj klienta", + "actionUnarchiveClient": "Usuń archiwizację klienta", + "actionBlockClient": "Zablokuj klienta", + "actionUnblockClient": "Odblokuj klienta", "actionUpdateClient": "Aktualizuj klienta", "actionListClients": "Lista klientów", "actionGetClient": "Pobierz klienta", @@ -1117,17 +1193,18 @@ "actionViewLogs": "Zobacz dzienniki", "noneSelected": "Nie wybrano", "orgNotFound2": "Nie znaleziono organizacji.", - "searchProgress": "Szukaj...", + "searchPlaceholder": "Szukaj...", + "emptySearchOptions": "Nie znaleziono opcji", "create": "Utwórz", "orgs": "Organizacje", - "loginError": "Wystąpił błąd podczas logowania", - "loginRequiredForDevice": "Logowanie jest wymagane do uwierzytelnienia urządzenia.", + "loginError": "Wystąpił nieoczekiwany błąd. Spróbuj ponownie.", + "loginRequiredForDevice": "Logowanie jest wymagane dla Twojego urządzenia.", "passwordForgot": "Zapomniałeś hasła?", "otpAuth": "Uwierzytelnianie dwuskładnikowe", "otpAuthDescription": "Wprowadź kod z aplikacji uwierzytelniającej lub jeden z jednorazowych kodów zapasowych.", "otpAuthSubmit": "Wyślij kod", "idpContinue": "Lub kontynuuj z", - "otpAuthBack": "Powrót do logowania", + "otpAuthBack": "Powrót do hasła", "navbar": "Menu nawigacyjne", "navbarDescription": "Główne menu nawigacyjne aplikacji", "navbarDocsLink": "Dokumentacja", @@ -1175,11 +1252,13 @@ "sidebarOverview": "Przegląd", "sidebarHome": "Strona główna", "sidebarSites": "Witryny", + "sidebarApprovals": "Wnioski o zatwierdzenie", "sidebarResources": "Zasoby", "sidebarProxyResources": "Publiczne", "sidebarClientResources": "Prywatny", "sidebarAccessControl": "Kontrola dostępu", "sidebarLogsAndAnalytics": "Logi i Analityki", + "sidebarTeam": "Drużyna", "sidebarUsers": "Użytkownicy", "sidebarAdmin": "Administrator", "sidebarInvitations": "Zaproszenia", @@ -1190,14 +1269,16 @@ "sidebarAllUsers": "Wszyscy użytkownicy", "sidebarIdentityProviders": "Dostawcy tożsamości", "sidebarLicense": "Licencja", - "sidebarClients": "Klientami", - "sidebarUserDevices": "Użytkownicy", + "sidebarClients": "Klienty", + "sidebarUserDevices": "Urządzenia użytkownika", "sidebarMachineClients": "Maszyny", "sidebarDomains": "Domeny", - "sidebarGeneral": "Ogólny", + "sidebarGeneral": "Zarządzaj", "sidebarLogAndAnalytics": "Dziennik & Analityka", "sidebarBluePrints": "Schematy", "sidebarOrganization": "Organizacja", + "sidebarManagement": "Zarządzanie", + "sidebarBillingAndLicenses": "Płatność i licencje", "sidebarLogsAnalytics": "Analityka", "blueprints": "Schematy", "blueprintsDescription": "Zastosuj konfiguracje deklaracyjne i wyświetl poprzednie operacje", @@ -1219,7 +1300,6 @@ "parsedContents": "Przetworzona zawartość (tylko do odczytu)", "enableDockerSocket": "Włącz schemat dokera", "enableDockerSocketDescription": "Włącz etykietowanie kieszeni dokującej dla etykiet schematów. Ścieżka do gniazda musi być dostarczona do Newt.", - "enableDockerSocketLink": "Dowiedz się więcej", "viewDockerContainers": "Zobacz kontenery dokujące", "containersIn": "Pojemniki w {siteName}", "selectContainerDescription": "Wybierz dowolny kontener do użycia jako nazwa hosta dla tego celu. Kliknij port, aby użyć portu.", @@ -1263,6 +1343,7 @@ "setupErrorCreateAdmin": "Wystąpił błąd podczas tworzenia konta administratora serwera.", "certificateStatus": "Status certyfikatu", "loading": "Ładowanie", + "loadingAnalytics": "Ładowanie Analityki", "restart": "Uruchom ponownie", "domains": "Domeny", "domainsDescription": "Tworzenie domen dostępnych w organizacji i zarządzanie nimi", @@ -1290,6 +1371,7 @@ "refreshError": "Nie udało się odświeżyć danych", "verified": "Zatwierdzony", "pending": "Oczekuje", + "pendingApproval": "Oczekujące na zatwierdzenie", "sidebarBilling": "Fakturowanie", "billing": "Fakturowanie", "orgBillingDescription": "Zarządzaj informacjami rozliczeniowymi i subskrypcjami", @@ -1308,8 +1390,11 @@ "accountSetupSuccess": "Konfiguracja konta zakończona! Witaj w Pangolin!", "documentation": "Dokumentacja", "saveAllSettings": "Zapisz wszystkie ustawienia", + "saveResourceTargets": "Zapisz cele", + "saveResourceHttp": "Zapisz ustawienia proxy", + "saveProxyProtocol": "Zapisz ustawienia protokołu proxy", "settingsUpdated": "Ustawienia zaktualizowane", - "settingsUpdatedDescription": "Wszystkie ustawienia zostały pomyślnie zaktualizowane", + "settingsUpdatedDescription": "Ustawienia zostały pomyślnie zaktualizowane", "settingsErrorUpdate": "Nie udało się zaktualizować ustawień", "settingsErrorUpdateDescription": "Wystąpił błąd podczas aktualizacji ustawień", "sidebarCollapse": "Zwiń", @@ -1342,6 +1427,7 @@ "domainPickerNamespace": "Przestrzeń nazw: {namespace}", "domainPickerShowMore": "Pokaż więcej", "regionSelectorTitle": "Wybierz region", + "domainPickerRemoteExitNodeWarning": "Podane domeny nie są obsługiwane, gdy witryny łączą się ze zdalnymi węzłami wyjścia. Aby zasoby były dostępne w węzłach zdalnych, użyj domeny niestandardowej.", "regionSelectorInfo": "Wybór regionu pomaga nam zapewnić lepszą wydajność dla Twojej lokalizacji. Nie musisz być w tym samym regionie co Twój serwer.", "regionSelectorPlaceholder": "Wybierz region", "regionSelectorComingSoon": "Wkrótce dostępne", @@ -1351,10 +1437,11 @@ "billingUsageLimitsOverview": "Przegląd Limitów Użytkowania", "billingMonitorUsage": "Monitoruj swoje wykorzystanie w porównaniu do skonfigurowanych limitów. Jeśli potrzebujesz zwiększenia limitów, skontaktuj się z nami pod adresem support@pangolin.net.", "billingDataUsage": "Użycie danych", - "billingOnlineTime": "Czas Online Strony", - "billingUsers": "Aktywni użytkownicy", - "billingDomains": "Aktywne domeny", - "billingRemoteExitNodes": "Aktywne samodzielnie-hostowane węzły", + "billingSites": "Witryny", + "billingUsers": "Użytkownicy", + "billingDomains": "Domeny", + "billingOrganizations": "O masie całkowitej pojazdu przekraczającej 5 ton, ale nieprzekraczającej 5 ton", + "billingRemoteExitNodes": "Zdalne węzły", "billingNoLimitConfigured": "Nie skonfigurowano limitu", "billingEstimatedPeriod": "Szacowany Okres Rozliczeniowy", "billingIncludedUsage": "Zawarte użycie", @@ -1379,15 +1466,24 @@ "billingFailedToGetPortalUrl": "Nie udało się uzyskać adresu URL portalu", "billingPortalError": "Błąd Portalu", "billingDataUsageInfo": "Jesteś obciążony za wszystkie dane przesyłane przez bezpieczne tunele, gdy jesteś podłączony do chmury. Obejmuje to zarówno ruch przychodzący, jak i wychodzący we wszystkich Twoich witrynach. Gdy osiągniesz swój limit, twoje strony zostaną rozłączone, dopóki nie zaktualizujesz planu lub nie ograniczysz użycia. Dane nie będą naliczane przy użyciu węzłów.", - "billingOnlineTimeInfo": "Opłata zależy od tego, jak długo twoje strony pozostają połączone z chmurą. Na przykład 44,640 minut oznacza jedną stronę działającą 24/7 przez cały miesiąc. Kiedy osiągniesz swój limit, twoje strony zostaną rozłączone, dopóki nie zaktualizujesz planu lub nie zmniejsz jego wykorzystania. Czas nie będzie naliczany przy użyciu węzłów.", - "billingUsersInfo": "Opłata za każdego użytkownika w organizacji. Płatność jest obliczana codziennie na podstawie liczby aktywnych kont użytkowników w Twojej organizacji.", - "billingDomainInfo": "Opłata za każdą domenę w organizacji. Płatność jest obliczana codziennie na podstawie liczby aktywnych kont domen w Twojej organizacji.", - "billingRemoteExitNodesInfo": "Opłata za każdy zarządzany węzeł w organizacji. Płatność jest obliczana codziennie na podstawie liczby aktywnych zarządzanych węzłów w Twojej organizacji.", + "billingSInfo": "Ile stron możesz użyć", + "billingUsersInfo": "Ile użytkowników możesz użyć", + "billingDomainInfo": "Ile domen możesz użyć", + "billingRemoteExitNodesInfo": "Ile zdalnych węzłów możesz użyć", + "billingLicenseKeys": "Klucze licencyjne", + "billingLicenseKeysDescription": "Zarządzaj subskrypcjami kluczy licencyjnych", + "billingLicenseSubscription": "Subskrypcja licencji", + "billingInactive": "Nieaktywny", + "billingLicenseItem": "Element licencji", + "billingQuantity": "Ilość", + "billingTotal": "łącznie", + "billingModifyLicenses": "Modyfikuj subskrypcję licencji", "domainNotFound": "Nie znaleziono domeny", "domainNotFoundDescription": "Zasób jest wyłączony, ponieważ domena nie istnieje już w naszym systemie. Proszę ustawić nową domenę dla tego zasobu.", "failed": "Niepowodzenie", "createNewOrgDescription": "Utwórz nową organizację", "organization": "Organizacja", + "primary": "Podstawowy", "port": "Port", "securityKeyManage": "Zarządzaj kluczami bezpieczeństwa", "securityKeyDescription": "Dodaj lub usuń klucze bezpieczeństwa do uwierzytelniania bez hasła", @@ -1403,7 +1499,7 @@ "securityKeyRemoveSuccess": "Klucz bezpieczeństwa został pomyślnie usunięty", "securityKeyRemoveError": "Błąd podczas usuwania klucza bezpieczeństwa", "securityKeyLoadError": "Błąd podczas ładowania kluczy bezpieczeństwa", - "securityKeyLogin": "Zaloguj się kluczem bezpieczeństwa", + "securityKeyLogin": "Użyj klucza bezpieczeństwa", "securityKeyAuthError": "Błąd podczas uwierzytelniania kluczem bezpieczeństwa", "securityKeyRecommendation": "Rozważ zarejestrowanie innego klucza bezpieczeństwa na innym urządzeniu, aby upewnić się, że nie zostaniesz zablokowany z dostępu do swojego konta.", "registering": "Rejestracja...", @@ -1459,11 +1555,47 @@ "resourcePortRequired": "Numer portu jest wymagany dla zasobów non-HTTP", "resourcePortNotAllowed": "Numer portu nie powinien być ustawiony dla zasobów HTTP", "billingPricingCalculatorLink": "Kalkulator Cen", + "billingYourPlan": "Twój plan", + "billingViewOrModifyPlan": "Wyświetl lub zmodyfikuj swój aktualny plan", + "billingViewPlanDetails": "Zobacz szczegóły planu", + "billingUsageAndLimits": "Stosowanie i ograniczenia", + "billingViewUsageAndLimits": "Zobacz limity swojego planu i bieżące użycie", + "billingCurrentUsage": "Bieżące użycie", + "billingMaximumLimits": "Maksymalne limity", + "billingRemoteNodes": "Zdalne węzły", + "billingUnlimited": "Nieograniczona", + "billingPaidLicenseKeys": "Płatne klucze licencyjne", + "billingManageLicenseSubscription": "Zarządzaj subskrypcją płatnych własnych kluczy licencyjnych", + "billingCurrentKeys": "Bieżące klucze", + "billingModifyCurrentPlan": "Modyfikuj bieżący plan", + "billingConfirmUpgrade": "Potwierdź aktualizację", + "billingConfirmDowngrade": "Potwierdź obniżenie", + "billingConfirmUpgradeDescription": "Zamierzasz ulepszyć swój plan. Przejrzyj nowe limity i ceny poniżej.", + "billingConfirmDowngradeDescription": "Zamierzasz obniżyć swój plan. Przejrzyj nowe limity i ceny poniżej.", + "billingPlanIncludes": "Plan zawiera", + "billingProcessing": "Przetwarzanie...", + "billingConfirmUpgradeButton": "Potwierdź aktualizację", + "billingConfirmDowngradeButton": "Potwierdź obniżenie", + "billingLimitViolationWarning": "Użycie przekracza nowe limity planu", + "billingLimitViolationDescription": "Bieżące użycie przekracza limity tego planu. Po obniżeniu, wszystkie działania zostaną wyłączone, dopóki nie zmniejsz zużycia w ramach nowych limitów. Zapoznaj się z poniższymi funkcjami, które obecnie przekraczają limity. Limity naruszenia:", + "billingFeatureLossWarning": "Powiadomienie o dostępności funkcji", + "billingFeatureLossDescription": "Po obniżeniu wartości funkcje niedostępne w nowym planie zostaną automatycznie wyłączone. Niektóre ustawienia i konfiguracje mogą zostać utracone. Zapoznaj się z matrycą cenową, aby zrozumieć, które funkcje nie będą już dostępne.", + "billingUsageExceedsLimit": "Bieżące użycie ({current}) przekracza limit ({limit})", + "billingPastDueTitle": "Płatność w przeszłości", + "billingPastDueDescription": "Twoja płatność jest zaległa. Zaktualizuj metodę płatności, aby kontynuować korzystanie z funkcji aktualnego planu. Jeśli nie zostanie rozwiązana, Twoja subskrypcja zostanie anulowana i zostaniesz przywrócony do darmowego poziomu.", + "billingUnpaidTitle": "Subskrypcja niezapłacona", + "billingUnpaidDescription": "Twoja subskrypcja jest niezapłacona i została przywrócona do darmowego poziomu. Zaktualizuj swoją metodę płatności, aby przywrócić subskrypcję.", + "billingIncompleteTitle": "Płatność niezakończona", + "billingIncompleteDescription": "Twoja płatność jest niekompletna. Ukończ proces płatności, aby aktywować subskrypcję.", + "billingIncompleteExpiredTitle": "Płatność wygasła", + "billingIncompleteExpiredDescription": "Twoja płatność nigdy nie została zakończona i wygasła. Zostałeś przywrócony do darmowego poziomu. Zapisz się ponownie, aby przywrócić dostęp do płatnych funkcji.", + "billingManageSubscription": "Zarządzaj subskrypcją", + "billingResolvePaymentIssue": "Rozwiąż problem z płatnościami przed aktualizacją lub obniżeniem oceny", "signUpTerms": { "IAgreeToThe": "Zgadzam się z", "termsOfService": "warunkami usługi", "and": "oraz", - "privacyPolicy": "polityką prywatności" + "privacyPolicy": "polityka prywatności." }, "signUpMarketing": { "keepMeInTheLoop": "Zachowaj mnie w pętli z wiadomościami, aktualizacjami i nowymi funkcjami przez e-mail." @@ -1508,6 +1640,7 @@ "addNewTarget": "Dodaj nowy cel", "targetsList": "Lista celów", "advancedMode": "Tryb zaawansowany", + "advancedSettings": "Zaawansowane ustawienia", "targetErrorDuplicateTargetFound": "Znaleziono duplikat celu", "healthCheckHealthy": "Zdrowy", "healthCheckUnhealthy": "Niezdrowy", @@ -1529,6 +1662,26 @@ "IntervalSeconds": "Interwał Zdrowy", "timeoutSeconds": "Limit czasu (sek)", "timeIsInSeconds": "Czas w sekundach", + "requireDeviceApproval": "Wymagaj zatwierdzenia urządzenia", + "requireDeviceApprovalDescription": "Użytkownicy o tej roli potrzebują nowych urządzeń zatwierdzonych przez administratora, zanim będą mogli połączyć się i uzyskać dostęp do zasobów.", + "sshAccess": "Dostęp SSH", + "roleAllowSsh": "Zezwalaj na SSH", + "roleAllowSshAllow": "Zezwól", + "roleAllowSshDisallow": "Nie zezwalaj", + "roleAllowSshDescription": "Zezwalaj użytkownikom z tej roli na łączenie się z zasobami za pomocą SSH. Gdy wyłączone, rola nie może korzystać z dostępu SSH.", + "sshSudoMode": "Dostęp Sudo", + "sshSudoModeNone": "Brak", + "sshSudoModeNoneDescription": "Użytkownik nie może uruchamiać poleceń z sudo.", + "sshSudoModeFull": "Pełne Sudo", + "sshSudoModeFullDescription": "Użytkownik może uruchomić dowolne polecenie z sudo.", + "sshSudoModeCommands": "Polecenia", + "sshSudoModeCommandsDescription": "Użytkownik może uruchamiać tylko określone polecenia z sudo.", + "sshSudo": "Zezwól na sudo", + "sshSudoCommands": "Komendy Sudo", + "sshSudoCommandsDescription": "Lista poleceń oddzielonych przecinkami, które użytkownik może uruchamiać z sudo.", + "sshCreateHomeDir": "Utwórz katalog domowy", + "sshUnixGroups": "Grupy Unix", + "sshUnixGroupsDescription": "Oddzielone przecinkami grupy Unix, aby dodać użytkownika do docelowego hosta.", "retryAttempts": "Próby Ponowienia", "expectedResponseCodes": "Oczekiwane Kody Odpowiedzi", "expectedResponseCodesDescription": "Kod statusu HTTP, który wskazuje zdrowy status. Jeśli pozostanie pusty, uznaje się 200-300 za zdrowy.", @@ -1569,6 +1722,8 @@ "resourcesTableNoInternalResourcesFound": "Nie znaleziono wewnętrznych zasobów.", "resourcesTableDestination": "Miejsce docelowe", "resourcesTableAlias": "Alias", + "resourcesTableAliasAddress": "Adres aliasu", + "resourcesTableAliasAddressInfo": "Ten adres jest częścią podsieci użyteczności organizacji. Jest używany do rozwiązywania rekordów aliasu przy użyciu wewnętrznej rozdzielczości DNS.", "resourcesTableClients": "Klientami", "resourcesTableAndOnlyAccessibleInternally": "i są dostępne tylko wewnętrznie po połączeniu z klientem.", "resourcesTableNoTargets": "Brak celów", @@ -1616,9 +1771,8 @@ "createInternalResourceDialogResourceProperties": "Właściwości zasobów", "createInternalResourceDialogName": "Nazwa", "createInternalResourceDialogSite": "Witryna", - "createInternalResourceDialogSelectSite": "Wybierz stronę...", - "createInternalResourceDialogSearchSites": "Szukaj stron...", - "createInternalResourceDialogNoSitesFound": "Nie znaleziono stron.", + "selectSite": "Wybierz stronę...", + "noSitesFound": "Nie znaleziono stron.", "createInternalResourceDialogProtocol": "Protokół", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", @@ -1670,7 +1824,7 @@ "autoLoginErrorNoRedirectUrl": "Nie otrzymano URL przekierowania od dostawcy tożsamości.", "autoLoginErrorGeneratingUrl": "Nie udało się wygenerować URL uwierzytelniania.", "remoteExitNodeManageRemoteExitNodes": "Zdalne węzły", - "remoteExitNodeDescription": "Samodzielny host jeden lub więcej węzłów zdalnych, aby rozszerzyć łączność z siecią i zmniejszyć zależność od chmury", + "remoteExitNodeDescription": "Hosting własnych zdalnych węzłów przekaźnikowych i serwerów proxy", "remoteExitNodes": "Węzły", "searchRemoteExitNodes": "Szukaj węzłów...", "remoteExitNodeAdd": "Dodaj węzeł", @@ -1680,20 +1834,22 @@ "remoteExitNodeConfirmDelete": "Potwierdź usunięcie węzła", "remoteExitNodeDelete": "Usuń węzeł", "sidebarRemoteExitNodes": "Zdalne węzły", + "remoteExitNodeId": "ID", + "remoteExitNodeSecretKey": "Sekret", "remoteExitNodeCreate": { - "title": "Utwórz węzeł", - "description": "Utwórz nowy węzeł, aby rozszerzyć łączność z siecią", + "title": "Utwórz zdalny węzeł", + "description": "Utwórz nowy, samodzielnie hostowany węzeł przekaźnika zdalnego i serwera proxy", "viewAllButton": "Zobacz wszystkie węzły", "strategy": { "title": "Strategia Tworzenia", - "description": "Wybierz to, aby ręcznie skonfigurować węzeł lub wygenerować nowe poświadczenia.", + "description": "Wybierz sposób, w jaki chcesz utworzyć zdalny węzeł", "adopt": { "title": "Zaadoptuj Węzeł", "description": "Wybierz to, jeśli masz już dane logowania dla węzła." }, "generate": { "title": "Generuj Klucze", - "description": "Wybierz to, jeśli chcesz wygenerować nowe klucze dla węzła" + "description": "Wybierz to, jeśli chcesz wygenerować nowe klucze dla węzła." } }, "adopt": { @@ -1806,9 +1962,33 @@ "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Podsieć", "subnetDescription": "Podsieć dla konfiguracji sieci tej organizacji.", - "authPage": "Strona uwierzytelniania", - "authPageDescription": "Skonfiguruj stronę uwierzytelniania dla organizacji", + "customDomain": "Niestandardowa domena", + "authPage": "Strony uwierzytelniania", + "authPageDescription": "Ustaw niestandardową domenę dla stron uwierzytelniania organizacji", "authPageDomain": "Domena strony uwierzytelniania", + "authPageBranding": "Niestandardowy branding", + "authPageBrandingDescription": "Konfiguruj branding, który pojawia się na stronach uwierzytelniania dla tej organizacji", + "authPageBrandingUpdated": "Branding strony uwierzytelniania został pomyślnie zaktualizowany", + "authPageBrandingRemoved": "Marka strony uwierzytelniania została pomyślnie usunięta", + "authPageBrandingRemoveTitle": "Usuń markę strony uwierzytelniania", + "authPageBrandingQuestionRemove": "Czy na pewno chcesz usunąć branding dla stron uwierzytelniania?", + "authPageBrandingDeleteConfirm": "Potwierdź usunięcie brandingu", + "brandingLogoURL": "URL logo", + "brandingLogoURLOrPath": "Adres URL logo lub ścieżka", + "brandingLogoPathDescription": "Wprowadź adres URL lub ścieżkę lokalną.", + "brandingLogoURLDescription": "Wprowadź publicznie dostępny adres URL do obrazu logo.", + "brandingPrimaryColor": "Główny kolor", + "brandingLogoWidth": "Szerokość (piksele)", + "brandingLogoHeight": "Wysokość (piksele)", + "brandingOrgTitle": "Tytuł dla strony uwierzytelniania organizacji", + "brandingOrgDescription": "{orgName} zostanie zastąpione nazwą organizacji", + "brandingOrgSubtitle": "Podtytuł dla strony uwierzytelniania organizacji", + "brandingResourceTitle": "Tytuł dla strony uwierzytelniania zasobu", + "brandingResourceSubtitle": "Podtytuł dla strony uwierzytelniania zasobu", + "brandingResourceDescription": "{resourceName} zostanie zastąpione nazwą organizacji", + "saveAuthPageDomain": "Zapisz domenę", + "saveAuthPageBranding": "Zapisz branding", + "removeAuthPageBranding": "Usuń branding", "noDomainSet": "Nie ustawiono domeny", "changeDomain": "Zmień domenę", "selectDomain": "Wybierz domenę", @@ -1817,7 +1997,7 @@ "setAuthPageDomain": "Ustaw domenę strony uwierzytelniania", "failedToFetchCertificate": "Nie udało się pobrać certyfikatu", "failedToRestartCertificate": "Nie udało się ponownie uruchomić certyfikatu", - "addDomainToEnableCustomAuthPages": "Dodaj domenę, aby włączyć niestandardowe strony uwierzytelniania dla organizacji", + "addDomainToEnableCustomAuthPages": "Użytkownicy będą mogli uzyskać dostęp do strony logowania organizacji i zakończyć uwierzytelnianie zasobów za pomocą tej domeny.", "selectDomainForOrgAuthPage": "Wybierz domenę dla strony uwierzytelniania organizacji", "domainPickerProvidedDomain": "Dostarczona domena", "domainPickerFreeProvidedDomain": "Darmowa oferowana domena", @@ -1832,11 +2012,27 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" nie może być poprawne dla {domain}.", "domainPickerSubdomainSanitized": "Poddomena oczyszczona", "domainPickerSubdomainCorrected": "\"{sub}\" został skorygowany do \"{sanitized}\"", - "orgAuthSignInTitle": "Zaloguj się do organizacji", + "orgAuthSignInTitle": "Logowanie do organizacji", "orgAuthChooseIdpDescription": "Wybierz swojego dostawcę tożsamości, aby kontynuować", "orgAuthNoIdpConfigured": "Ta organizacja nie ma skonfigurowanych żadnych dostawców tożsamości. Zamiast tego możesz zalogować się za pomocą swojej tożsamości Pangolin.", "orgAuthSignInWithPangolin": "Zaloguj się używając Pangolin", + "orgAuthSignInToOrg": "Zaloguj się do organizacji", + "orgAuthSelectOrgTitle": "Logowanie do organizacji", + "orgAuthSelectOrgDescription": "Wprowadź identyfikator organizacji, aby kontynuować", + "orgAuthOrgIdPlaceholder": "twoja-organizacja", + "orgAuthOrgIdHelp": "Wpisz unikalny identyfikator swojej organizacji", + "orgAuthSelectOrgHelp": "Po wpisaniu ID organizacji zostaniesz przeniesiony na stronę logowania organizacji, gdzie możesz użyć SSO lub danych logowania organizacji.", + "orgAuthRememberOrgId": "Zapamiętaj ten identyfikator organizacji", + "orgAuthBackToSignIn": "Powrót do standardowego logowania", + "orgAuthNoAccount": "Nie masz konta?", "subscriptionRequiredToUse": "Do korzystania z tej funkcji wymagana jest subskrypcja.", + "mustUpgradeToUse": "Musisz uaktualnić subskrypcję, aby korzystać z tej funkcji.", + "subscriptionRequiredTierToUse": "Ta funkcja wymaga funkcji {tier} lub wyższej.", + "upgradeToTierToUse": "Aby skorzystać z tej funkcji, przejdź na {tier} lub wyższy pakiet.", + "subscriptionTierTier1": "Strona główna", + "subscriptionTierTier2": "Drużyna", + "subscriptionTierTier3": "Biznes", + "subscriptionTierEnterprise": "Przedsiębiorstwo", "idpDisabled": "Dostawcy tożsamości są wyłączeni", "orgAuthPageDisabled": "Strona autoryzacji organizacji jest wyłączona.", "domainRestartedDescription": "Weryfikacja domeny zrestartowana pomyślnie", @@ -1850,6 +2046,8 @@ "enableTwoFactorAuthentication": "Włącz uwierzytelnianie dwuskładnikowe", "completeSecuritySteps": "Zakończ kroki bezpieczeństwa", "securitySettings": "Ustawienia zabezpieczeń", + "dangerSection": "Strefa zagrożenia", + "dangerSectionDescription": "Trwale usuń wszystkie dane związane z tą organizacją", "securitySettingsDescription": "Skonfiguruj polityki bezpieczeństwa dla organizacji", "requireTwoFactorForAllUsers": "Wymagaj uwierzytelniania dwuetapowego dla wszystkich użytkowników", "requireTwoFactorDescription": "Po włączeniu wszyscy użytkownicy wewnętrzni w tej organizacji muszą mieć włączone uwierzytelnianie dwuskładnikowe, aby uzyskać dostęp do organizacji.", @@ -1887,7 +2085,7 @@ "securityPolicyChangeWarningText": "To wpłynie na wszystkich użytkowników w organizacji", "authPageErrorUpdateMessage": "Wystąpił błąd podczas aktualizacji ustawień strony uwierzytelniania", "authPageErrorUpdate": "Nie można zaktualizować strony uwierzytelniania", - "authPageUpdated": "Strona uwierzytelniania została pomyślnie zaktualizowana", + "authPageDomainUpdated": "Domena strony uwierzytelniania została pomyślnie zaktualizowana", "healthCheckNotAvailable": "Lokalny", "rewritePath": "Przepis Ścieżki", "rewritePathDescription": "Opcjonalnie przepisz ścieżkę przed przesłaniem do celu.", @@ -1915,8 +2113,15 @@ "beta": "Beta", "manageUserDevices": "Urządzenia użytkownika", "manageUserDevicesDescription": "Przeglądaj i zarządzaj urządzeniami, które użytkownicy używają do prywatnego łączenia się z zasobami", + "downloadClientBannerTitle": "Pobierz klienta Pangolin", + "downloadClientBannerDescription": "Pobierz klienta Pangolin dla swojego systemu, aby połączyć się z siecią Pangolin i uzyskać dostęp do zasobów prywatnie.", "manageMachineClients": "Zarządzaj klientami maszyn", "manageMachineClientsDescription": "Tworzenie i zarządzanie klientami, których serwery i systemy używają do prywatnego łączenia się z zasobami", + "machineClientsBannerTitle": "Serwery i systemy zautomatyzowane", + "machineClientsBannerDescription": "Klienci maszyn służą dla serwerów i systemów zautomatyzowanych, które nie są powiązane z konkretnym użytkownikiem. Uwierzytelniają się za pomocą identyfikatora i sekretu i mogą działać z Pangolin CLI, Olm CLI lub Olm jako kontener.", + "machineClientsBannerPangolinCLI": "Pangolin CLI", + "machineClientsBannerOlmCLI": "Olm CLI", + "machineClientsBannerOlmContainer": "Kontener Olm", "clientsTableUserClients": "Użytkownik", "clientsTableMachineClients": "Maszyna", "licenseTableValidUntil": "Ważny do", @@ -2015,6 +2220,32 @@ } } }, + "newPricingLicenseForm": { + "title": "Uzyskaj licencję", + "description": "Wybierz plan i powiedz nam, jak planujesz korzystać z Pangolin.", + "chooseTier": "Wybierz swój plan", + "viewPricingLink": "Zobacz cenniki, funkcje i limity", + "tiers": { + "starter": { + "title": "Rozpocznij", + "description": "Środki te przeznaczone są na pokrycie wydatków na personel i wydatków administracyjnych Agencji (tytuły 1 i 2) oraz jej wydatków operacyjnych (tytuł 3)." + }, + "scale": { + "title": "Skala", + "description": "Cechy przedsiębiorstw, 50 użytkowników, 50 obiektów i wsparcie priorytetowe." + } + }, + "personalUseOnly": "Wyłącznie do użytku osobistego (bezpłatna licencja – brak zamówień)", + "buttons": { + "continueToCheckout": "Przejdź do zamówienia" + }, + "toasts": { + "checkoutError": { + "title": "Błąd zamówienia", + "description": "Nie można uruchomić zamówienia. Spróbuj ponownie." + } + } + }, "priority": "Priorytet", "priorityDescription": "Najpierw oceniane są trasy priorytetowe. Priorytet = 100 oznacza automatyczne zamawianie (decyzje systemowe). Użyj innego numeru, aby wyegzekwować ręczny priorytet.", "instanceName": "Nazwa instancji", @@ -2060,13 +2291,15 @@ "request": "Żądanie", "requests": "Żądania", "logs": "Logi", - "logsSettingsDescription": "Monitoruj logi zebrane z tej orginizacji", + "logsSettingsDescription": "Monitorowanie logów zbieranych z tej organizacji", "searchLogs": "Szukaj dzienników...", "action": "Akcja", "actor": "Aktor", "timestamp": "Znacznik czasu", "accessLogs": "Logi dostępu", "exportCsv": "Eksportuj CSV", + "exportError": "Nieznany błąd podczas eksportowania CSV", + "exportCsvTooltip": "W obrębie zakresu czasowego", "actorId": "Identyfikator podmiotu", "allowedByRule": "Dozwolone przez regułę", "allowedNoAuth": "Dozwolone Brak Auth", @@ -2111,7 +2344,8 @@ "logRetentionEndOfFollowingYear": "Koniec następnego roku", "actionLogsDescription": "Zobacz historię działań wykonywanych w tej organizacji", "accessLogsDescription": "Wyświetl prośby o autoryzację dostępu do zasobów w tej organizacji", - "licenseRequiredToUse": "Licencja Enterprise jest wymagana do korzystania z tej funkcji.", + "licenseRequiredToUse": "Do korzystania z tej funkcji wymagana jest licencja Enterprise Edition lub Pangolin Cloud . Zarezerwuj wersję demonstracyjną lub wersję próbną POC.", + "ossEnterpriseEditionRequired": "Enterprise Edition jest wymagany do korzystania z tej funkcji. Ta funkcja jest również dostępna w Pangolin Cloud. Zarezerwuj demo lub okres próbny POC.", "certResolver": "Rozwiązywanie certyfikatów", "certResolverDescription": "Wybierz resolver certyfikatów do użycia dla tego zasobu.", "selectCertResolver": "Wybierz Resolver certyfikatów", @@ -2120,7 +2354,7 @@ "unverified": "Niezweryfikowane", "domainSetting": "Ustawienia domeny", "domainSettingDescription": "Skonfiguruj ustawienia domeny", - "preferWildcardCertDescription": "Próba wygenerowania certyfikatu wieloznacznego (wymaga poprawnie skonfigurowanego resolwera certyfikatów).", + "preferWildcardCertDescription": "Spróbuj wygenerować certyfikat wieloznaczny (wymaga odpowiednio skonfigurowanego resolvera certyfikatu).", "recordName": "Nazwa rekordu", "auto": "Auto", "TTL": "TTL", @@ -2172,6 +2406,8 @@ "deviceCodeInvalidFormat": "Kod musi mieć 9 znaków (np. A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Nieprawidłowy lub wygasły kod", "deviceCodeVerifyFailed": "Nie udało się zweryfikować kodu urządzenia", + "deviceCodeValidating": "Sprawdzanie kodu urządzenia...", + "deviceCodeVerifying": "Weryfikowanie autoryzacji urządzenia...", "signedInAs": "Zalogowany jako", "deviceCodeEnterPrompt": "Wprowadź kod wyświetlany na urządzeniu", "continue": "Kontynuuj", @@ -2184,7 +2420,7 @@ "deviceOrganizationsAccess": "Dostęp do wszystkich organizacji, do których Twoje konto ma dostęp", "deviceAuthorize": "Autoryzuj {applicationName}", "deviceConnected": "Urządzenie podłączone!", - "deviceAuthorizedMessage": "Urządzenie jest upoważnione do dostępu do Twojego konta.", + "deviceAuthorizedMessage": "Urządzenie jest autoryzowane do uzyskania dostępu do Twojego konta. Proszę wróć do aplikacji klienckiej.", "pangolinCloud": "Chmura Pangolin", "viewDevices": "Zobacz urządzenia", "viewDevicesDescription": "Zarządzaj podłączonymi urządzeniami", @@ -2215,7 +2451,7 @@ "enableSelected": "Włącz zaznaczone", "disableSelected": "Wyłącz zaznaczone", "checkSelectedStatus": "Sprawdź status zaznaczonych", - "clients": "Klientami", + "clients": "Klienty", "accessClientSelect": "Wybierz klientów komputera", "resourceClientDescription": "Klienci maszynowi, którzy mają dostęp do tego zasobu", "regenerate": "Wygeneruj ponownie", @@ -2244,8 +2480,9 @@ "niceIdCannotBeEmpty": "Niepoprawny identyfikator nie może być pusty", "enterIdentifier": "Wprowadź identyfikator", "identifier": "Identifier", - "deviceLoginUseDifferentAccount": "To nie? Użyj innego konta.", + "deviceLoginUseDifferentAccount": "Nie ty? Użyj innego konta.", "deviceLoginDeviceRequestingAccessToAccount": "Urządzenie żąda dostępu do tego konta.", + "loginSelectAuthenticationMethod": "Wybierz metodę uwierzytelniania aby kontynuować.", "noData": "Brak danych", "machineClients": "Klienci maszyn", "install": "Zainstaluj", @@ -2255,6 +2492,8 @@ "setupFailedToFetchSubnet": "Nie udało się pobrać domyślnej podsieci", "setupSubnetAdvanced": "Podsieć (zaawansowana)", "setupSubnetDescription": "Podsieć dla wewnętrznej sieci tej organizacji.", + "setupUtilitySubnet": "Podsieć narzędziowa (zaawansowana)", + "setupUtilitySubnetDescription": "Podsieć dla aliasów adresów i serwera DNS tej organizacji.", "siteRegenerateAndDisconnect": "Wygeneruj ponownie i rozłącz", "siteRegenerateAndDisconnectConfirmation": "Czy na pewno chcesz odzyskać dane logowania i odłączyć tę stronę?", "siteRegenerateAndDisconnectWarning": "Spowoduje to regenerację poświadczeń i natychmiastowe odłączenie witryny. Strona będzie musiała zostać zrestartowana z nowymi poświadczeniami.", @@ -2270,5 +2509,179 @@ "remoteExitNodeRegenerateAndDisconnectWarning": "Spowoduje to regenerację danych logowania i natychmiastowe odłączenie zdalnego węzła wyjścia. Węzeł zdalnego wyjścia będzie musiał zostać ponownie uruchomiony z nowymi danymi logowania.", "remoteExitNodeRegenerateCredentialsConfirmation": "Czy na pewno chcesz wygenerować dane logowania dla tego węzła zdalnego wyjścia?", "remoteExitNodeRegenerateCredentialsWarning": "Spowoduje to regenerację poświadczeń. Serwer wyjścia pozostanie podłączony do momentu ręcznego ponownego uruchomienia i użycia nowych poświadczeń.", - "agent": "Agent" + "agent": "Agent", + "personalUseOnly": "Tylko do użytku osobistego", + "loginPageLicenseWatermark": "Ta instancja jest licencjonowana tylko do użytku osobistego.", + "instanceIsUnlicensed": "Ta instancja nie jest licencjonowana.", + "portRestrictions": "Ograniczenia portu", + "allPorts": "Wszystko", + "custom": "Niestandardowe", + "allPortsAllowed": "Wszystkie porty dozwolone", + "allPortsBlocked": "Wszystkie porty zablokowane", + "tcpPortsDescription": "Określ, które porty TCP są dozwolone dla tego zasobu. Użyj '*' dla wszystkich portów, pozostaw puste, aby zablokować wszystkie lub wpisz listę portów i zakresów oddzielonych przecinkami (np. 80,443,8000-9000).", + "udpPortsDescription": "Określ, które porty UDP są dozwolone dla tego zasobu. Użyj '*' dla wszystkich portów, pozostaw puste, aby zablokować wszystkie lub wpisz listę portów i zakresów oddzielonych przecinkami (np. 53,123,500-600).", + "organizationLoginPageTitle": "Strona logowania organizacji", + "organizationLoginPageDescription": "Dostosuj stronę logowania dla tej organizacji", + "resourceLoginPageTitle": "Strona logowania zasobów", + "resourceLoginPageDescription": "Dostosuj stronę logowania dla poszczególnych zasobów", + "enterConfirmation": "Wprowadź potwierdzenie", + "blueprintViewDetails": "Szczegóły", + "defaultIdentityProvider": "Domyślny dostawca tożsamości", + "defaultIdentityProviderDescription": "Gdy zostanie wybrany domyślny dostawca tożsamości, użytkownik zostanie automatycznie przekierowany do dostawcy w celu uwierzytelnienia.", + "editInternalResourceDialogNetworkSettings": "Ustawienia sieci", + "editInternalResourceDialogAccessPolicy": "Polityka dostępowa", + "editInternalResourceDialogAddRoles": "Dodaj role", + "editInternalResourceDialogAddUsers": "Dodaj użytkowników", + "editInternalResourceDialogAddClients": "Dodaj klientów", + "editInternalResourceDialogDestinationLabel": "Miejsce docelowe", + "editInternalResourceDialogDestinationDescription": "Określ adres docelowy dla wewnętrznego zasobu. Może to być nazwa hosta, adres IP lub zakres CIDR, w zależności od wybranego trybu. Opcjonalnie ustaw wewnętrzny alias DNS dla łatwiejszej identyfikacji.", + "editInternalResourceDialogPortRestrictionsDescription": "Ogranicz dostęp do konkretnych portów TCP/UDP lub zezwól/zablokuj wszystkie porty.", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "Kontrola dostępu", + "editInternalResourceDialogAccessControlDescription": "Kontroluj, które role, użytkownicy i klienci maszyn mają dostęp do tego zasobu po połączeniu. Administratorzy zawsze mają dostęp.", + "editInternalResourceDialogPortRangeValidationError": "Zakres portów musi być \"*\" dla wszystkich portów lub listą portów i zakresów oddzielonych przecinkami (np. \"80,443,8000-9000\"). Porty muszą znajdować się w przedziale od 1 do 65535.", + "internalResourceAuthDaemonStrategy": "SSH Auth Daemon Lokalizacja", + "internalResourceAuthDaemonStrategyDescription": "Wybierz, gdzie działa demon uwierzytelniania SSH: na stronie (Newt) lub na zdalnym serwerze.", + "internalResourceAuthDaemonDescription": "Uwierzytelnianie SSH obsługuje podpisywanie klucza SSH i uwierzytelnianie PAM dla tego zasobu. Wybierz, czy działa na stronie (Newt), czy na oddzielnym serwerze zdalnym. Zobacz dokumentację dla więcej.", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "Wybierz strategię", + "internalResourceAuthDaemonStrategyLabel": "Lokalizacja", + "internalResourceAuthDaemonSite": "Na stronie", + "internalResourceAuthDaemonSiteDescription": "Demon Auth działa na stronie (nowy).", + "internalResourceAuthDaemonRemote": "Zdalny host", + "internalResourceAuthDaemonRemoteDescription": "Demon Auth działa na serwerze, który nie jest stroną.", + "internalResourceAuthDaemonPort": "Port Daemon (opcjonalnie)", + "orgAuthWhatsThis": "Gdzie mogę znaleźć swój identyfikator organizacji?", + "learnMore": "Dowiedz się więcej", + "backToHome": "Wróć do strony głównej", + "needToSignInToOrg": "Czy potrzebujesz użyć dostawcy tożsamości organizacji?", + "maintenanceMode": "Tryb konserwacji", + "maintenanceModeDescription": "Wyświetl stronę konserwacyjną odwiedzającym", + "maintenanceModeType": "Typ trybu konserwacji", + "showMaintenancePage": "Pokaż odwiedzającym stronę konserwacji", + "enableMaintenanceMode": "Włącz tryb konserwacji", + "automatic": "Automatycznie", + "automaticModeDescription": "Pokaż stronę konserwacyjną tylko wtedy, gdy wszystkie cele zaplecza są wyłączone lub niezdrowe. Twój zasób działa nadal normalnie, o ile przynajmniej jeden cel jest zdrowy.", + "forced": "Wymuszone", + "forcedModeDescription": "Zawsze pokazuj stronę konserwacyjną, niezależnie od stanu zdrowia zaplecza. Użyj tego w przypadku planowanej konserwacji, gdy chcesz zapobiec wszelkiemu dostępowi.", + "warning:": "Ostrzeżenie:", + "forcedeModeWarning": "Cały ruch zostanie skierowany na stronę konserwacyjną. Twoje zasoby zaplecza nie otrzymają żadnych żądań.", + "pageTitle": "Tytuł strony", + "pageTitleDescription": "Główny nagłówek wyświetlany na stronie konserwacyjnej", + "maintenancePageMessage": "Komunikat konserwacyjny", + "maintenancePageMessagePlaceholder": "Wrócimy wkrótce! Nasza strona przechodzi obecnie zaplanowaną konserwację.", + "maintenancePageMessageDescription": "Szczegółowy komunikat wyjaśniający konserwację", + "maintenancePageTimeTitle": "Szacowany czas zakończenia (opcjonalnie)", + "maintenanceTime": "np. 2 godziny, 1 listopad o 17:00", + "maintenanceEstimatedTimeDescription": "Kiedy oczekujesz zakończenia konserwacji", + "editDomain": "Edytuj domenę", + "editDomainDescription": "Wybierz domenę dla swojego zasobu", + "maintenanceModeDisabledTooltip": "Ta funkcja wymaga ważnej licencji do aktywacji.", + "maintenanceScreenTitle": "Usługa chwilowo niedostępna", + "maintenanceScreenMessage": "Obecnie doświadczamy problemów technicznych. Proszę sprawdzić ponownie wkrótce.", + "maintenanceScreenEstimatedCompletion": "Szacowane zakończenie:", + "createInternalResourceDialogDestinationRequired": "Miejsce docelowe jest wymagane", + "available": "Dostępny", + "archived": "Zarchiwizowane", + "noArchivedDevices": "Nie znaleziono zarchiwizowanych urządzeń", + "deviceArchived": "Urządzenie zarchiwizowane", + "deviceArchivedDescription": "Urządzenie zostało pomyślnie zarchiwizowane.", + "errorArchivingDevice": "Błąd podczas archiwizacji urządzenia", + "failedToArchiveDevice": "Nie udało się zarchiwizować urządzenia", + "deviceQuestionArchive": "Czy na pewno chcesz zarchiwizować to urządzenie?", + "deviceMessageArchive": "Urządzenie zostanie zarchiwizowane i usunięte z listy aktywnych urządzeń.", + "deviceArchiveConfirm": "Archiwizuj urządzenie", + "archiveDevice": "Archiwizuj urządzenie", + "archive": "Archiwum", + "deviceUnarchived": "Urządzenie niezarchiwizowane", + "deviceUnarchivedDescription": "Urządzenie zostało pomyślnie usunięte.", + "errorUnarchivingDevice": "Błąd podczas usuwania archiwizacji urządzenia", + "failedToUnarchiveDevice": "Nie udało się odarchiwizować urządzenia", + "unarchive": "Usuń z archiwum", + "archiveClient": "Zarchiwizuj klienta", + "archiveClientQuestion": "Czy na pewno chcesz zarchiwizować tego klienta?", + "archiveClientMessage": "Klient zostanie zarchiwizowany i usunięty z listy aktywnych klientów.", + "archiveClientConfirm": "Zarchiwizuj klienta", + "blockClient": "Zablokuj klienta", + "blockClientQuestion": "Czy na pewno chcesz zablokować tego klienta?", + "blockClientMessage": "Urządzenie zostanie wymuszone do rozłączenia, jeśli jest obecnie podłączone. Możesz odblokować urządzenie później.", + "blockClientConfirm": "Zablokuj klienta", + "active": "Aktywne", + "usernameOrEmail": "Nazwa użytkownika lub e-mail", + "selectYourOrganization": "Wybierz swoją organizację", + "signInTo": "Zaloguj się do", + "signInWithPassword": "Kontynuuj z hasłem", + "noAuthMethodsAvailable": "Brak dostępnych metod uwierzytelniania dla tej organizacji.", + "enterPassword": "Wprowadź hasło", + "enterMfaCode": "Wprowadź kod z aplikacji uwierzytelniającej", + "securityKeyRequired": "Aby się zalogować, użyj klucza bezpieczeństwa.", + "needToUseAnotherAccount": "Potrzebujesz użyć innego konta?", + "loginLegalDisclaimer": "Klikając na przycisk poniżej, potwierdzasz, że przeczytałeś, rozumiesz, i zaakceptuj Warunki świadczenia usługi i Polityka prywatności.", + "termsOfService": "Warunki korzystania z usługi", + "privacyPolicy": "Polityka prywatności", + "userNotFoundWithUsername": "Nie znaleziono użytkownika o tej nazwie użytkownika.", + "verify": "Weryfikacja", + "signIn": "Zaloguj się", + "forgotPassword": "Zapomniałeś hasła?", + "orgSignInTip": "Jeśli zalogowałeś się wcześniej, możesz wprowadzić nazwę użytkownika lub e-mail powyżej, aby uwierzytelnić się z dostawcą tożsamości organizacji. To łatwiejsze!", + "continueAnyway": "Kontynuuj mimo to", + "dontShowAgain": "Nie pokazuj ponownie", + "orgSignInNotice": "Czy wiedziałeś?", + "signupOrgNotice": "Próbujesz się zalogować?", + "signupOrgTip": "Czy próbujesz zalogować się za pośrednictwem dostawcy tożsamości organizacji?", + "signupOrgLink": "Zamiast tego zaloguj się lub zarejestruj w swojej organizacji", + "verifyEmailLogInWithDifferentAccount": "Użyj innego konta", + "logIn": "Zaloguj się", + "deviceInformation": "Informacje o urządzeniu", + "deviceInformationDescription": "Informacje o urządzeniu i agentach", + "deviceSecurity": "Bezpieczeństwo urządzenia", + "deviceSecurityDescription": "Informacje o bezpieczeństwie urządzenia", + "platform": "Platforma", + "macosVersion": "Wersja macOS", + "windowsVersion": "Wersja Windows", + "iosVersion": "Wersja iOS", + "androidVersion": "Wersja Androida", + "osVersion": "Wersja systemu operacyjnego", + "kernelVersion": "Wersja jądra", + "deviceModel": "Model urządzenia", + "serialNumber": "Numer seryjny", + "hostname": "Hostname", + "firstSeen": "Widziany po raz pierwszy", + "lastSeen": "Ostatnio widziane", + "biometricsEnabled": "Biometria włączona", + "diskEncrypted": "Dysk zaszyfrowany", + "firewallEnabled": "Zapora włączona", + "autoUpdatesEnabled": "Automatyczne aktualizacje włączone", + "tpmAvailable": "TPM dostępne", + "windowsAntivirusEnabled": "Antywirus włączony", + "macosSipEnabled": "Ochrona integralności systemu (SIP)", + "macosGatekeeperEnabled": "Gatekeeper", + "macosFirewallStealthMode": "Tryb Stealth zapory", + "linuxAppArmorEnabled": "Zbroja aplikacji", + "linuxSELinuxEnabled": "SELinux", + "deviceSettingsDescription": "Wyświetl informacje o urządzeniu i ustawienia", + "devicePendingApprovalDescription": "To urządzenie czeka na zatwierdzenie", + "deviceBlockedDescription": "To urządzenie jest obecnie zablokowane. Nie będzie można połączyć się z żadnymi zasobami, chyba że zostanie odblokowane.", + "unblockClient": "Odblokuj klienta", + "unblockClientDescription": "Urządzenie zostało odblokowane", + "unarchiveClient": "Usuń archiwizację klienta", + "unarchiveClientDescription": "Urządzenie zostało odarchiwizowane", + "block": "Blok", + "unblock": "Odblokuj", + "deviceActions": "Akcje urządzenia", + "deviceActionsDescription": "Zarządzaj stanem urządzenia i dostępem", + "devicePendingApprovalBannerDescription": "To urządzenie oczekuje na zatwierdzenie. Nie będzie można połączyć się z zasobami, dopóki nie zostanie zatwierdzone.", + "connected": "Połączono", + "disconnected": "Rozłączony", + "approvalsEmptyStateTitle": "Zatwierdzanie urządzenia nie włączone", + "approvalsEmptyStateDescription": "Włącz zatwierdzanie urządzeń dla ról aby wymagać zgody administratora, zanim użytkownicy będą mogli podłączyć nowe urządzenia.", + "approvalsEmptyStateStep1Title": "Przejdź do ról", + "approvalsEmptyStateStep1Description": "Przejdź do ustawień ról swojej organizacji, aby skonfigurować zatwierdzenia urządzenia.", + "approvalsEmptyStateStep2Title": "Włącz zatwierdzanie urządzenia", + "approvalsEmptyStateStep2Description": "Edytuj rolę i włącz opcję \"Wymagaj zatwierdzenia urządzenia\". Użytkownicy z tą rolą będą potrzebowali zatwierdzenia administratora dla nowych urządzeń.", + "approvalsEmptyStatePreviewDescription": "Podgląd: Gdy włączone, oczekujące prośby o sprawdzenie pojawią się tutaj", + "approvalsEmptyStateButtonText": "Zarządzaj rolami", + "domainErrorTitle": "Mamy problem z weryfikacją Twojej domeny" } diff --git a/messages/pt-PT.json b/messages/pt-PT.json index 41ae04b0d..b121f4b16 100644 --- a/messages/pt-PT.json +++ b/messages/pt-PT.json @@ -1,5 +1,7 @@ { "setupCreate": "Criar a organização, o site e os recursos", + "headerAuthCompatibilityInfo": "Habilite isso para forçar uma resposta 401 Unauthorized quando um token de autenticação estiver faltando. Isso é necessário para navegadores ou bibliotecas HTTP específicas que não enviam credenciais sem um desafio do servidor.", + "headerAuthCompatibility": "Compatibilidade Estendida", "setupNewOrg": "Nova organização", "setupCreateOrg": "Criar Organização", "setupCreateResources": "Criar recursos", @@ -16,6 +18,8 @@ "componentsMember": "É membro de {count, plural, =0 {nenhuma organização} one {uma organização} other {# organizações}}.", "componentsInvalidKey": "Chaves de licença inválidas ou expiradas detectadas. Siga os termos da licença para continuar usando todos os recursos.", "dismiss": "Rejeitar", + "subscriptionViolationMessage": "Você está além dos seus limites para o seu plano atual. Corrija o problema removendo sites, usuários, ou outros recursos para ficar em seu plano.", + "subscriptionViolationViewBilling": "Ver faturamento", "componentsLicenseViolation": "Violação de Licença: Este servidor está usando sites {usedSites} que excedem o limite licenciado de sites {maxSites} . Siga os termos da licença para continuar usando todos os recursos.", "componentsSupporterMessage": "Obrigado por apoiar o Pangolin como um {tier}!", "inviteErrorNotValid": "Desculpe, mas parece que o convite que está a tentar aceder não foi aceito ou não é mais válido.", @@ -33,7 +37,7 @@ "password": "Palavra-passe", "confirmPassword": "Confirmar senha", "createAccount": "Criar conta", - "viewSettings": "Visualizar configurações", + "viewSettings": "Ver Configurações", "delete": "apagar", "name": "Nome:", "online": "Disponível", @@ -51,6 +55,12 @@ "siteQuestionRemove": "Você tem certeza que deseja remover este site da organização?", "siteManageSites": "Gerir sites", "siteDescription": "Criar e gerenciar sites para ativar a conectividade a redes privadas", + "sitesBannerTitle": "Conectar a Qualquer Rede", + "sitesBannerDescription": "Um site é uma conexão a uma rede remota que permite ao Pangolin fornecer acesso a recursos, sejam eles públicos ou privados, a usuários em qualquer lugar. Instale o conector de rede do site (Newt) em qualquer lugar onde você possa executar um binário ou contêiner para estabelecer a conexão.", + "sitesBannerButtonText": "Instalar Site", + "approvalsBannerTitle": "Aprovar ou negar acesso ao dispositivo", + "approvalsBannerDescription": "Revisar e aprovar ou negar solicitações de acesso ao dispositivo de usuários. Quando as aprovações do dispositivo são necessárias, os usuários devem obter a aprovação do administrador antes que seus dispositivos possam se conectar aos recursos da sua organização.", + "approvalsBannerButtonText": "Saiba mais", "siteCreate": "Criar site", "siteCreateDescription2": "Siga os passos abaixo para criar e conectar um novo site", "siteCreateDescription": "Crie um novo site para começar a conectar os recursos", @@ -100,6 +110,7 @@ "siteTunnelDescription": "Determine como você deseja se conectar ao site", "siteNewtCredentials": "Credenciais", "siteNewtCredentialsDescription": "É assim que o site se autentica com o servidor", + "remoteNodeCredentialsDescription": "É assim que o nó remoto se autenticará com o servidor", "siteCredentialsSave": "Salvar as Credenciais", "siteCredentialsSaveDescription": "Você só será capaz de ver esta vez. Certifique-se de copiá-lo para um lugar seguro.", "siteInfo": "Informações do Site", @@ -146,8 +157,12 @@ "shareErrorSelectResource": "Por favor, selecione um recurso", "proxyResourceTitle": "Gerenciar Recursos Públicos", "proxyResourceDescription": "Criar e gerenciar recursos que são acessíveis publicamente por meio de um navegador da web", + "proxyResourcesBannerTitle": "Acesso Público via Web", + "proxyResourcesBannerDescription": "Os recursos públicos são proxies HTTPS ou TCP/UDP acessíveis a qualquer pessoa na internet por meio de um navegador web. Ao contrário dos recursos privados, eles não requerem software do lado do cliente e podem incluir políticas de acesso conscientes de identidade e contexto.", "clientResourceTitle": "Gerenciar recursos privados", "clientResourceDescription": "Criar e gerenciar recursos que só são acessíveis por meio de um cliente conectado", + "privateResourcesBannerTitle": "Acesso Privado com Confiança Zero", + "privateResourcesBannerDescription": "Os recursos privados usam segurança de zero confiança, garantindo que usuários e máquinas possam acessar apenas os recursos que você concede explicitamente. Conecte dispositivos de usuários ou clientes de máquinas para acessar esses recursos por meio de uma rede privada virtual segura.", "resourcesSearch": "Procurar recursos...", "resourceAdd": "Adicionar Recurso", "resourceErrorDelte": "Erro ao apagar recurso", @@ -157,9 +172,10 @@ "resourceMessageRemove": "Uma vez removido, o recurso não estará mais acessível. Todos os alvos associados ao recurso também serão removidos.", "resourceQuestionRemove": "Você tem certeza que deseja remover o recurso da organização?", "resourceHTTP": "Recurso HTTPS", - "resourceHTTPDescription": "O proxy solicita ao aplicativo via HTTPS usando um subdomínio ou domínio base.", + "resourceHTTPDescription": "Proxies requests sobre HTTPS usando um nome de domínio totalmente qualificado.", "resourceRaw": "Recurso TCP/UDP bruto", - "resourceRawDescription": "O proxy solicita ao aplicativo sobre TCP/UDP usando um número de porta. Isso só funciona quando os sites estão conectados a nós.", + "resourceRawDescription": "Proxies solicitações sobre TCP/UDP bruto usando um número de porta.", + "resourceRawDescriptionCloud": "Proxy solicita por TCP/UDP bruto usando um número de porta. Requer que sites se conectem a um nó remoto.", "resourceCreate": "Criar Recurso", "resourceCreateDescription": "Siga os passos abaixo para criar um novo recurso", "resourceSeeAll": "Ver todos os recursos", @@ -186,6 +202,7 @@ "protocolSelect": "Selecione um protocolo", "resourcePortNumber": "Número da Porta", "resourcePortNumberDescription": "O número da porta externa para requisições de proxy.", + "back": "Anterior", "cancel": "cancelar", "resourceConfig": "Snippets de Configuração", "resourceConfigDescription": "Copie e cole estes snippets de configuração para configurar o recurso TCP/UDP", @@ -231,6 +248,17 @@ "orgErrorDeleteMessage": "Ocorreu um erro ao apagar a organização.", "orgDeleted": "Organização excluída", "orgDeletedMessage": "A organização e seus dados foram excluídos.", + "deleteAccount": "Excluir Conta", + "deleteAccountDescription": "Exclua permanentemente sua conta, todas as organizações que você possui e todos os dados nessas organizações. Isso não pode ser desfeito.", + "deleteAccountButton": "Excluir Conta", + "deleteAccountConfirmTitle": "Excluir Conta", + "deleteAccountConfirmMessage": "Isto limpará permanentemente sua conta, todas as organizações que você possui e todos os dados dentro dessas organizações. Isso não pode ser desfeito.", + "deleteAccountConfirmString": "excluir conta", + "deleteAccountSuccess": "Conta excluída", + "deleteAccountSuccessMessage": "Sua conta foi excluída.", + "deleteAccountError": "Falha ao excluir conta", + "deleteAccountPreviewAccount": "Sua conta", + "deleteAccountPreviewOrgs": "Organizações que você possui (e todos os dados deles)", "orgMissing": "ID da Organização Ausente", "orgMissingMessage": "Não é possível regenerar o convite sem um ID de organização.", "accessUsersManage": "Gerir Utilizadores", @@ -247,6 +275,8 @@ "accessRolesSearch": "Pesquisar funções...", "accessRolesAdd": "Adicionar função", "accessRoleDelete": "Excluir Papel", + "accessApprovalsManage": "Gerenciar aprovações", + "accessApprovalsDescription": "Visualizar e gerenciar aprovações pendentes para acesso a esta organização", "description": "Descrição:", "inviteTitle": "Convites Abertos", "inviteDescription": "Gerenciar convites para outros usuários participarem da organização", @@ -419,7 +449,7 @@ "userErrorExistsDescription": "Este utilizador já é membro da organização.", "inviteError": "Falha ao convidar utilizador", "inviteErrorDescription": "Ocorreu um erro ao convidar o utilizador", - "userInvited": "Usuário convidado", + "userInvited": "Usuário Convidado", "userInvitedDescription": "O utilizador foi convidado com sucesso.", "userErrorCreate": "Falha ao criar utilizador", "userErrorCreateDescription": "Ocorreu um erro ao criar o utilizador", @@ -440,6 +470,20 @@ "selectDuration": "Selecionar duração", "selectResource": "Selecionar Recurso", "filterByResource": "Filtrar por Recurso", + "selectApprovalState": "Selecionar Estado de Aprovação", + "filterByApprovalState": "Filtrar por estado de aprovação", + "approvalListEmpty": "Sem aprovações", + "approvalState": "Estado de aprovação", + "approvalLoadMore": "Carregue mais", + "loadingApprovals": "Carregando aprovações", + "approve": "Aprovar", + "approved": "Aceito", + "denied": "Negado", + "deniedApproval": "Aprovação Negada", + "all": "Todos", + "deny": "Recusar", + "viewDetails": "Visualizar Detalhes", + "requestingNewDeviceApproval": "solicitou um novo dispositivo", "resetFilters": "Redefinir filtros", "totalBlocked": "Solicitações bloqueadas pelo Pangolin", "totalRequests": "Total de pedidos", @@ -607,6 +651,7 @@ "resourcesErrorUpdate": "Falha ao alternar recurso", "resourcesErrorUpdateDescription": "Ocorreu um erro ao atualizar o recurso", "access": "Acesso", + "accessControl": "Controle de Acesso", "shareLink": "Link de Compartilhamento {resource}", "resourceSelect": "Selecionar recurso", "shareLinks": "Links de Compartilhamento", @@ -687,7 +732,7 @@ "resourceRoleDescription": "Administradores sempre podem aceder este recurso.", "resourceUsersRoles": "Controlos de Acesso", "resourceUsersRolesDescription": "Configure quais utilizadores e funções podem visitar este recurso", - "resourceUsersRolesSubmit": "Guardar Utilizadores e Funções", + "resourceUsersRolesSubmit": "Guardar Controlos de Acesso", "resourceWhitelistSave": "Salvo com sucesso", "resourceWhitelistSaveDescription": "As configurações da lista permitida foram salvas", "ssoUse": "Usar SSO da Plataforma", @@ -719,22 +764,35 @@ "countries": "Países", "accessRoleCreate": "Criar Função", "accessRoleCreateDescription": "Crie uma nova função para agrupar utilizadores e gerir suas permissões.", + "accessRoleEdit": "Editar Permissão", + "accessRoleEditDescription": "Editar informações do papel.", "accessRoleCreateSubmit": "Criar Função", "accessRoleCreated": "Função criada", "accessRoleCreatedDescription": "A função foi criada com sucesso.", "accessRoleErrorCreate": "Falha ao criar função", "accessRoleErrorCreateDescription": "Ocorreu um erro ao criar a função.", + "accessRoleUpdateSubmit": "Atualizar Função", + "accessRoleUpdated": "Função atualizada", + "accessRoleUpdatedDescription": "A função foi atualizada com sucesso.", + "accessApprovalUpdated": "Aprovação processada", + "accessApprovalApprovedDescription": "Definir decisão de solicitação de aprovação para aprovada.", + "accessApprovalDeniedDescription": "Definir decisão de solicitação de aprovação para negada.", + "accessRoleErrorUpdate": "Falha ao atualizar papel", + "accessRoleErrorUpdateDescription": "Ocorreu um erro ao atualizar a função.", + "accessApprovalErrorUpdate": "Não foi possível processar a aprovação", + "accessApprovalErrorUpdateDescription": "Ocorreu um erro ao processar a aprovação.", "accessRoleErrorNewRequired": "Nova função é necessária", "accessRoleErrorRemove": "Falha ao remover função", "accessRoleErrorRemoveDescription": "Ocorreu um erro ao remover a função.", "accessRoleName": "Nome da Função", - "accessRoleQuestionRemove": "Você está prestes a apagar a função {name}. Você não pode desfazer esta ação.", + "accessRoleQuestionRemove": "Você está prestes a apagar o papel `{name}. Você não pode desfazer esta ação.", "accessRoleRemove": "Remover Função", "accessRoleRemoveDescription": "Remover uma função da organização", "accessRoleRemoveSubmit": "Remover Função", "accessRoleRemoved": "Função removida", "accessRoleRemovedDescription": "A função foi removida com sucesso.", "accessRoleRequiredRemove": "Antes de apagar esta função, selecione uma nova função para transferir os membros existentes.", + "network": "Rede", "manage": "Gerir", "sitesNotFound": "Nenhum site encontrado.", "pangolinServerAdmin": "Administrador do Servidor - Pangolin", @@ -750,6 +808,9 @@ "sitestCountIncrease": "Aumentar contagem de sites", "idpManage": "Gerir Provedores de Identidade", "idpManageDescription": "Visualizar e gerir provedores de identidade no sistema", + "idpGlobalModeBanner": "Provedores de identidade (Pds) por organização estão desabilitados neste servidor. Ele está usando IdPs globais (compartilhados entre todas as organizações). Gerencie IdPs no painel admin. Para habilitar IdPs por organização, edite a configuração do servidor e defina o modo IdP como org. Veja a documentação. Se quiser continuar usando IdPs globais e fazer isso desaparecer das configurações da organização, defina explicitamente o modo como global na configuração.", + "idpGlobalModeBannerUpgradeRequired": "Os provedores de identidade (IdPs) por organização estão desativados neste servidor. Ele está usando IdPs globais (compartilhados entre todas as organizações). Gerencie os IdPs globais no painel administrativo. Para usar provedores de identidade por organização, você deve atualizar para a edição Enterprise.", + "idpGlobalModeBannerLicenseRequired": "Os provedores de identidade (IdPs) por organização estão desativados neste servidor. Ele está usando IdPs globais (compartilhados entre todas as organizações). Gerencie os IdPs globais no painel administrativo. Para usar provedores de identidade por organização, é necessário uma licença Enterprise.", "idpDeletedDescription": "Provedor de identidade eliminado com sucesso", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Tem certeza que deseja eliminar permanentemente o provedor de identidade?", @@ -840,6 +901,7 @@ "orgPolicyConfig": "Configurar acesso para uma organização", "idpUpdatedDescription": "Provedor de identidade atualizado com sucesso", "redirectUrl": "URL de Redirecionamento", + "orgIdpRedirectUrls": "Redirecionar URLs", "redirectUrlAbout": "Sobre o URL de Redirecionamento", "redirectUrlAboutDescription": "Essa é a URL para a qual os usuários serão redirecionados após a autenticação. Você precisa configurar esta URL nas configurações do provedor de identidade.", "pangolinAuth": "Autenticação - Pangolin", @@ -943,13 +1005,13 @@ "passwordExpiryDescription": "Esta organização exige que você altere sua senha a cada {maxDays} dias.", "changePasswordNow": "Alterar a senha agora", "pincodeAuth": "Código do Autenticador", - "pincodeSubmit2": "Submeter Código", + "pincodeSubmit2": "Enviar código", "passwordResetSubmit": "Solicitar Redefinição", - "passwordResetAlreadyHaveCode": "Digitar Código de Redefinição de Senha", + "passwordResetAlreadyHaveCode": "Inserir Código", "passwordResetSmtpRequired": "Por favor, contate o administrador", "passwordResetSmtpRequiredDescription": "É necessário um código de redefinição de senha para redefinir sua senha. Por favor, contate o administrador para assistência.", "passwordBack": "Voltar à Palavra-passe", - "loginBack": "Voltar ao início de sessão", + "loginBack": "Voltar para a página principal de acesso", "signup": "Registar", "loginStart": "Inicie sessão para começar", "idpOidcTokenValidating": "A validar token OIDC", @@ -972,12 +1034,12 @@ "pangolinSetup": "Configuração - Pangolin", "orgNameRequired": "O nome da organização é obrigatório", "orgIdRequired": "O ID da organização é obrigatório", + "orgIdMaxLength": "ID da organização deve ter no máximo 32 caracteres", "orgErrorCreate": "Ocorreu um erro ao criar a organização", "pageNotFound": "Página Não Encontrada", "pageNotFoundDescription": "Ops! A página que você está procurando não existe.", "overview": "Visão Geral", "home": "Início", - "accessControl": "Controle de Acesso", "settings": "Configurações", "usersAll": "Todos os Utilizadores", "license": "Licença", @@ -1035,15 +1097,24 @@ "updateOrgUser": "Atualizar utilizador Org", "createOrgUser": "Criar utilizador Org", "actionUpdateOrg": "Atualizar Organização", + "actionRemoveInvitation": "Remover Convite", "actionUpdateUser": "Atualizar Usuário", "actionGetUser": "Obter Usuário", "actionGetOrgUser": "Obter Utilizador da Organização", "actionListOrgDomains": "Listar Domínios da Organização", + "actionGetDomain": "Obter domínio", + "actionCreateOrgDomain": "Criar domínio", + "actionUpdateOrgDomain": "Atualizar domínio", + "actionDeleteOrgDomain": "Excluir domínio", + "actionGetDNSRecords": "Obter registros de DNS", + "actionRestartOrgDomain": "Reiniciar domínio", "actionCreateSite": "Criar Site", "actionDeleteSite": "Eliminar Site", "actionGetSite": "Obter Site", "actionListSites": "Listar Sites", "actionApplyBlueprint": "Aplicar Diagrama", + "actionListBlueprints": "Listar Modelos", + "actionGetBlueprint": "Obter Modelo", "setupToken": "Configuração do Token", "setupTokenDescription": "Digite o token de configuração do console do servidor.", "setupTokenRequired": "Token de configuração é necessário", @@ -1077,6 +1148,7 @@ "actionRemoveUser": "Remover Utilizador", "actionListUsers": "Listar Utilizadores", "actionAddUserRole": "Adicionar Função ao Utilizador", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Gerar Token de Acesso", "actionDeleteAccessToken": "Eliminar Token de Acesso", "actionListAccessTokens": "Listar Tokens de Acesso", @@ -1104,6 +1176,10 @@ "actionUpdateIdpOrg": "Atualizar Organização IDP", "actionCreateClient": "Criar Cliente", "actionDeleteClient": "Excluir Cliente", + "actionArchiveClient": "Arquivar Cliente", + "actionUnarchiveClient": "Desarquivar Cliente", + "actionBlockClient": "Bloco do Cliente", + "actionUnblockClient": "Desbloquear Cliente", "actionUpdateClient": "Atualizar Cliente", "actionListClients": "Listar Clientes", "actionGetClient": "Obter Cliente", @@ -1117,17 +1193,18 @@ "actionViewLogs": "Visualizar registros", "noneSelected": "Nenhum selecionado", "orgNotFound2": "Nenhuma organização encontrada.", - "searchProgress": "Pesquisar...", + "searchPlaceholder": "Buscar...", + "emptySearchOptions": "Nenhuma opção encontrada", "create": "Criar", "orgs": "Organizações", - "loginError": "Ocorreu um erro ao iniciar sessão", - "loginRequiredForDevice": "É necessário entrar para autenticar seu dispositivo.", + "loginError": "Ocorreu um erro inesperado. Por favor, tente novamente.", + "loginRequiredForDevice": "O login é necessário para seu dispositivo.", "passwordForgot": "Esqueceu a sua palavra-passe?", "otpAuth": "Autenticação de Dois Fatores", "otpAuthDescription": "Insira o código da sua aplicação de autenticação ou um dos seus códigos de backup de uso único.", "otpAuthSubmit": "Submeter Código", "idpContinue": "Ou continuar com", - "otpAuthBack": "Voltar ao Início de Sessão", + "otpAuthBack": "Voltar à Palavra-passe", "navbar": "Menu de Navegação", "navbarDescription": "Menu de navegação principal da aplicação", "navbarDocsLink": "Documentação", @@ -1175,11 +1252,13 @@ "sidebarOverview": "Geral", "sidebarHome": "Residencial", "sidebarSites": "sites", + "sidebarApprovals": "Solicitações de aprovação", "sidebarResources": "Recursos", "sidebarProxyResources": "Público", "sidebarClientResources": "Privado", "sidebarAccessControl": "Controle de Acesso", "sidebarLogsAndAnalytics": "Registros e Análises", + "sidebarTeam": "Equipe", "sidebarUsers": "Utilizadores", "sidebarAdmin": "Administrador", "sidebarInvitations": "Convites", @@ -1191,13 +1270,15 @@ "sidebarIdentityProviders": "Provedores de identidade", "sidebarLicense": "Tipo:", "sidebarClients": "Clientes", - "sidebarUserDevices": "Utilizadores", + "sidebarUserDevices": "Dispositivos do usuário", "sidebarMachineClients": "Máquinas", "sidebarDomains": "Domínios", - "sidebarGeneral": "Gerais", + "sidebarGeneral": "Gerir", "sidebarLogAndAnalytics": "Registo & Análise", "sidebarBluePrints": "Diagramas", "sidebarOrganization": "Organização", + "sidebarManagement": "Gestão", + "sidebarBillingAndLicenses": "Faturamento e Licenças", "sidebarLogsAnalytics": "Análises", "blueprints": "Diagramas", "blueprintsDescription": "Aplicar configurações declarativas e ver execuções anteriores", @@ -1219,7 +1300,6 @@ "parsedContents": "Conteúdo analisado (Somente Leitura)", "enableDockerSocket": "Habilitar o Diagrama Docker", "enableDockerSocketDescription": "Ativar a scraping de rótulo Docker para rótulos de diagramas. Caminho de Socket deve ser fornecido para Newt.", - "enableDockerSocketLink": "Saiba mais", "viewDockerContainers": "Ver contêineres Docker", "containersIn": "Contêineres em {siteName}", "selectContainerDescription": "Selecione qualquer contêiner para usar como hostname para este alvo. Clique em uma porta para usar uma porta.", @@ -1263,6 +1343,7 @@ "setupErrorCreateAdmin": "Ocorreu um erro ao criar a conta de administrador do servidor.", "certificateStatus": "Status do Certificado", "loading": "Carregando", + "loadingAnalytics": "Carregando Analytics", "restart": "Reiniciar", "domains": "Domínios", "domainsDescription": "Criar e gerenciar domínios disponíveis na organização", @@ -1290,6 +1371,7 @@ "refreshError": "Falha ao atualizar dados", "verified": "Verificado", "pending": "Pendente", + "pendingApproval": "Aprovação pendente", "sidebarBilling": "Faturamento", "billing": "Faturamento", "orgBillingDescription": "Gerenciar informações e assinaturas de cobrança", @@ -1308,8 +1390,11 @@ "accountSetupSuccess": "Configuração da conta concluída! Bem-vindo ao Pangolin!", "documentation": "Documentação", "saveAllSettings": "Guardar Todas as Configurações", + "saveResourceTargets": "Guardar Alvos", + "saveResourceHttp": "Guardar Configurações de Proxy", + "saveProxyProtocol": "Salvar configurações do protocolo de proxy", "settingsUpdated": "Configurações atualizadas", - "settingsUpdatedDescription": "Todas as configurações foram atualizadas com sucesso", + "settingsUpdatedDescription": "Configurações atualizadas com sucesso", "settingsErrorUpdate": "Falha ao atualizar configurações", "settingsErrorUpdateDescription": "Ocorreu um erro ao atualizar configurações", "sidebarCollapse": "Recolher", @@ -1342,6 +1427,7 @@ "domainPickerNamespace": "Namespace: {namespace}", "domainPickerShowMore": "Mostrar Mais", "regionSelectorTitle": "Selecionar Região", + "domainPickerRemoteExitNodeWarning": "Domínios fornecidos não são suportados quando os sites se conectam a nós de saída remota. Para recursos disponíveis em nós remotos, use um domínio personalizado.", "regionSelectorInfo": "Selecionar uma região nos ajuda a fornecer melhor desempenho para sua localização. Você não precisa estar na mesma região que seu servidor.", "regionSelectorPlaceholder": "Escolher uma região", "regionSelectorComingSoon": "Em breve", @@ -1351,10 +1437,11 @@ "billingUsageLimitsOverview": "Visão Geral dos Limites de Uso", "billingMonitorUsage": "Monitore seu uso em relação aos limites configurados. Se precisar aumentar esses limites, entre em contato conosco support@pangolin.net.", "billingDataUsage": "Uso de Dados", - "billingOnlineTime": "Tempo Online do Site", - "billingUsers": "Usuários Ativos", - "billingDomains": "Domínios Ativos", - "billingRemoteExitNodes": "Nodos Auto-Hospedados Ativos", + "billingSites": "sites", + "billingUsers": "Utilizadores", + "billingDomains": "Domínios", + "billingOrganizations": "Órgãos", + "billingRemoteExitNodes": "Nós remotos", "billingNoLimitConfigured": "Nenhum limite configurado", "billingEstimatedPeriod": "Período Estimado de Cobrança", "billingIncludedUsage": "Uso Incluído", @@ -1379,15 +1466,24 @@ "billingFailedToGetPortalUrl": "Falha ao obter URL do portal", "billingPortalError": "Erro do Portal", "billingDataUsageInfo": "Você é cobrado por todos os dados transferidos através de seus túneis seguros quando conectado à nuvem. Isso inclui o tráfego de entrada e saída em todos os seus sites. Quando você atingir o seu limite, seus sites desconectarão até que você atualize seu plano ou reduza o uso. Os dados não serão cobrados ao usar os nós.", - "billingOnlineTimeInfo": "Cobrança de acordo com o tempo em que seus sites permanecem conectados à nuvem. Por exemplo, 44,640 minutos é igual a um site que roda 24/7 para um mês inteiro. Quando você atinge o seu limite, seus sites desconectarão até que você faça o upgrade do seu plano ou reduza o uso. O tempo não é cobrado ao usar nós.", - "billingUsersInfo": "A cobrança é feita por cada usuário na organização. A cobrança é feita diariamente com base no número de contas de usuário ativas na sua organização.", - "billingDomainInfo": "A cobrança é feita por cada domínio da organização. A cobrança é feita diariamente com base no número de contas de domínio ativas na sua organização.", - "billingRemoteExitNodesInfo": "Você é cobrado por cada nó gerenciado na organização. A cobrança é calculada diariamente com base no número de nós gerenciados ativos em sua organização.", + "billingSInfo": "Quantos sites você pode usar", + "billingUsersInfo": "Quantos usuários você pode usar", + "billingDomainInfo": "Quantos domínios você pode usar", + "billingRemoteExitNodesInfo": "Quantos nós remotos você pode usar", + "billingLicenseKeys": "Chaves de Licença", + "billingLicenseKeysDescription": "Gerenciar suas subscrições de chave de licença", + "billingLicenseSubscription": "Assinatura de Licença", + "billingInactive": "Inativo", + "billingLicenseItem": "Item de Licença", + "billingQuantity": "Quantidade", + "billingTotal": "total:", + "billingModifyLicenses": "Modificar assinatura de licença", "domainNotFound": "Domínio Não Encontrado", "domainNotFoundDescription": "Este recurso está desativado porque o domínio não existe mais em nosso sistema. Defina um novo domínio para este recurso.", "failed": "Falhou", "createNewOrgDescription": "Crie uma nova organização", "organization": "Organização", + "primary": "Primário", "port": "Porta", "securityKeyManage": "Gerir chaves de segurança", "securityKeyDescription": "Adicionar ou remover chaves de segurança para autenticação sem senha", @@ -1403,7 +1499,7 @@ "securityKeyRemoveSuccess": "Chave de segurança removida com sucesso", "securityKeyRemoveError": "Erro ao remover chave de segurança", "securityKeyLoadError": "Erro ao carregar chaves de segurança", - "securityKeyLogin": "Continuar com a chave de segurança", + "securityKeyLogin": "Usar chave de segurança", "securityKeyAuthError": "Erro ao autenticar com chave de segurança", "securityKeyRecommendation": "Considere registrar outra chave de segurança em um dispositivo diferente para garantir que você não fique bloqueado da sua conta.", "registering": "Registrando...", @@ -1459,11 +1555,47 @@ "resourcePortRequired": "Número da porta é obrigatório para recursos não-HTTP", "resourcePortNotAllowed": "Número da porta não deve ser definido para recursos HTTP", "billingPricingCalculatorLink": "Calculadora de Preços", + "billingYourPlan": "Seu plano", + "billingViewOrModifyPlan": "Ver ou modificar seu plano atual", + "billingViewPlanDetails": "Ver detalhes do plano", + "billingUsageAndLimits": "Uso e Limites", + "billingViewUsageAndLimits": "Ver os limites do seu plano e o uso atual", + "billingCurrentUsage": "Uso atual", + "billingMaximumLimits": "Limite Máximo", + "billingRemoteNodes": "Nós remotos", + "billingUnlimited": "Ilimitado", + "billingPaidLicenseKeys": "Chaves de licença paga", + "billingManageLicenseSubscription": "Gerencie sua assinatura para as chaves de licenças auto-hospedadas pagas", + "billingCurrentKeys": "Chaves atuais", + "billingModifyCurrentPlan": "Modificar o Plano Atual", + "billingConfirmUpgrade": "Confirmar a atualização", + "billingConfirmDowngrade": "Confirmar downgrade", + "billingConfirmUpgradeDescription": "Você está prestes a atualizar seu plano. Revise os novos limites e preços abaixo.", + "billingConfirmDowngradeDescription": "Você está prestes a fazer o downgrade do seu plano. Revise os novos limites e preços abaixo.", + "billingPlanIncludes": "Plano Inclui", + "billingProcessing": "Processandochar@@0", + "billingConfirmUpgradeButton": "Confirmar a atualização", + "billingConfirmDowngradeButton": "Confirmar downgrade", + "billingLimitViolationWarning": "Uso excede novos limites de plano", + "billingLimitViolationDescription": "Seu uso atual excede os limites deste plano. Após desclassificação, todas as ações serão desabilitadas até que você reduza o uso dentro dos novos limites. Por favor, reveja os recursos abaixo que atualmente estão acima dos limites. Limites de violação:", + "billingFeatureLossWarning": "Aviso de disponibilidade de recursos", + "billingFeatureLossDescription": "Ao fazer o downgrading, recursos não disponíveis no novo plano serão desativados automaticamente. Algumas configurações e configurações podem ser perdidas. Por favor, revise a matriz de preços para entender quais características não estarão mais disponíveis.", + "billingUsageExceedsLimit": "Uso atual ({current}) excede o limite ({limit})", + "billingPastDueTitle": "Pagamento passado devido", + "billingPastDueDescription": "Seu pagamento está vencido. Por favor, atualize seu método de pagamento para continuar usando os recursos do seu plano atual. Se não for resolvido, sua assinatura será cancelada e você será revertido para o nível gratuito.", + "billingUnpaidTitle": "Assinatura não paga", + "billingUnpaidDescription": "Sua assinatura não foi paga e você voltou para o nível gratuito. Atualize o seu método de pagamento para restaurar sua assinatura.", + "billingIncompleteTitle": "Pagamento Incompleto", + "billingIncompleteDescription": "Seu pagamento está incompleto. Por favor, complete o processo de pagamento para ativar sua assinatura.", + "billingIncompleteExpiredTitle": "Pagamento expirado", + "billingIncompleteExpiredDescription": "Seu pagamento nunca foi concluído e expirou. Você foi revertido para o nível gratuito. Por favor, inscreva-se novamente para restaurar o acesso a recursos pagos.", + "billingManageSubscription": "Gerencie sua assinatura", + "billingResolvePaymentIssue": "Por favor, resolva seu problema de pagamento antes de atualizar ou rebaixar", "signUpTerms": { "IAgreeToThe": "Concordo com", "termsOfService": "os termos de serviço", "and": "e", - "privacyPolicy": "política de privacidade" + "privacyPolicy": "política de privacidade." }, "signUpMarketing": { "keepMeInTheLoop": "Mantenha-me à disposição com notícias, atualizações e novos recursos por e-mail." @@ -1508,6 +1640,7 @@ "addNewTarget": "Adicionar Novo Alvo", "targetsList": "Lista de Alvos", "advancedMode": "Modo Avançado", + "advancedSettings": "Configurações Avançadas", "targetErrorDuplicateTargetFound": "Alvo duplicado encontrado", "healthCheckHealthy": "Saudável", "healthCheckUnhealthy": "Não Saudável", @@ -1529,6 +1662,26 @@ "IntervalSeconds": "Intervalo Saudável", "timeoutSeconds": "Tempo limite (seg)", "timeIsInSeconds": "O tempo está em segundos", + "requireDeviceApproval": "Exigir aprovação do dispositivo", + "requireDeviceApprovalDescription": "Usuários com esta função precisam de novos dispositivos aprovados por um administrador antes que eles possam se conectar e acessar recursos.", + "sshAccess": "Acesso SSH", + "roleAllowSsh": "Permitir SSH", + "roleAllowSshAllow": "Autorizar", + "roleAllowSshDisallow": "Anular", + "roleAllowSshDescription": "Permitir que usuários com esta função se conectem a recursos via SSH. Quando desativado, a função não pode usar o acesso SSH.", + "sshSudoMode": "Acesso Sudo", + "sshSudoModeNone": "Nenhuma", + "sshSudoModeNoneDescription": "O usuário não pode executar comandos com o sudo.", + "sshSudoModeFull": "Sudo Completo", + "sshSudoModeFullDescription": "O usuário pode executar qualquer comando com sudo.", + "sshSudoModeCommands": "Comandos", + "sshSudoModeCommandsDescription": "Usuário só pode executar os comandos especificados com sudo.", + "sshSudo": "Permitir sudo", + "sshSudoCommands": "Comandos Sudo", + "sshSudoCommandsDescription": "Lista separada por vírgulas de comandos que o usuário pode executar com sudo.", + "sshCreateHomeDir": "Criar Diretório Inicial", + "sshUnixGroups": "Grupos Unix", + "sshUnixGroupsDescription": "Grupos Unix separados por vírgulas para adicionar o usuário no host alvo.", "retryAttempts": "Tentativas de Repetição", "expectedResponseCodes": "Códigos de Resposta Esperados", "expectedResponseCodesDescription": "Código de status HTTP que indica estado saudável. Se deixado em branco, 200-300 é considerado saudável.", @@ -1569,6 +1722,8 @@ "resourcesTableNoInternalResourcesFound": "Nenhum recurso interno encontrado.", "resourcesTableDestination": "Destino", "resourcesTableAlias": "Alias", + "resourcesTableAliasAddress": "Endereço do Pseudônimo", + "resourcesTableAliasAddressInfo": "Este endereço faz parte da sub-rede de utilitários da organização. É usado para resolver registros de alias usando resolução de DNS interno.", "resourcesTableClients": "Clientes", "resourcesTableAndOnlyAccessibleInternally": "e são acessíveis apenas internamente quando conectados com um cliente.", "resourcesTableNoTargets": "Nenhum alvo", @@ -1616,9 +1771,8 @@ "createInternalResourceDialogResourceProperties": "Propriedades do Recurso", "createInternalResourceDialogName": "Nome", "createInternalResourceDialogSite": "Site", - "createInternalResourceDialogSelectSite": "Selecionar site...", - "createInternalResourceDialogSearchSites": "Procurar sites...", - "createInternalResourceDialogNoSitesFound": "Nenhum site encontrado.", + "selectSite": "Selecionar site...", + "noSitesFound": "Nenhum site encontrado.", "createInternalResourceDialogProtocol": "Protocolo", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", @@ -1658,7 +1812,7 @@ "siteAddressDescription": "Endereço interno do site. Deve estar dentro da sub-rede da organização.", "siteNameDescription": "O nome de exibição do site que pode ser alterado mais tarde.", "autoLoginExternalIdp": "Login Automático com IDP Externo", - "autoLoginExternalIdpDescription": "Redirecionar imediatamente o utilizador para o IDP externo para autenticação.", + "autoLoginExternalIdpDescription": "Redirecionar imediatamente o usuário para o provedor de identidade externo para autenticação.", "selectIdp": "Selecionar IDP", "selectIdpPlaceholder": "Escolher um IDP...", "selectIdpRequired": "Por favor, selecione um IDP quando o login automático estiver ativado.", @@ -1670,7 +1824,7 @@ "autoLoginErrorNoRedirectUrl": "Nenhum URL de redirecionamento recebido do provedor de identidade.", "autoLoginErrorGeneratingUrl": "Falha ao gerar URL de autenticação.", "remoteExitNodeManageRemoteExitNodes": "Nós remotos", - "remoteExitNodeDescription": "Auto-hospedar um ou mais nós remotos para estender a conectividade de rede e reduzir a dependência da nuvem", + "remoteExitNodeDescription": "Hospede seus próprios nós de retransmissão e proxy servidores remotamente", "remoteExitNodes": "Nós", "searchRemoteExitNodes": "Buscar nós...", "remoteExitNodeAdd": "Adicionar node", @@ -1680,20 +1834,22 @@ "remoteExitNodeConfirmDelete": "Confirmar exclusão do nó", "remoteExitNodeDelete": "Excluir nó", "sidebarRemoteExitNodes": "Nós remotos", + "remoteExitNodeId": "ID", + "remoteExitNodeSecretKey": "Chave Secreta", "remoteExitNodeCreate": { - "title": "Criar nó", - "description": "Crie um novo nó para estender a conectividade de rede", + "title": "Criar Nó Remoto", + "description": "Crie um novo nó de retransmissão e proxy servidor auto-hospedado", "viewAllButton": "Ver Todos os Nós", "strategy": { "title": "Estratégia de Criação", - "description": "Escolha esta opção para configurar o nó manualmente ou gerar novas credenciais.", + "description": "Selecione como você deseja criar o nó remoto", "adopt": { "title": "Adotar Nodo", "description": "Escolha isto se você já tem credenciais para o nó." }, "generate": { "title": "Gerar Chaves", - "description": "Escolha esta opção se você quer gerar novas chaves para o nó" + "description": "Escolha esta opção se você quer gerar novas chaves para o nó." } }, "adopt": { @@ -1806,9 +1962,33 @@ "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Sub-rede", "subnetDescription": "A sub-rede para a configuração de rede dessa organização.", - "authPage": "Página de Autenticação", - "authPageDescription": "Configurar a página de autenticação para a organização", + "customDomain": "Domínio Personalizado", + "authPage": "Páginas de Autenticação", + "authPageDescription": "Defina um domínio personalizado para as páginas de autenticação da organização", "authPageDomain": "Domínio de Página Autenticação", + "authPageBranding": "Marcação Personalizada", + "authPageBrandingDescription": "Configure a marcação que aparece nas páginas de autenticação para esta organização", + "authPageBrandingUpdated": "Marca de Autenticação atualizada com sucesso", + "authPageBrandingRemoved": "Marca de Autenticação removida com sucesso", + "authPageBrandingRemoveTitle": "Remover Marca de Autenticação", + "authPageBrandingQuestionRemove": "Tem certeza de que deseja remover a marcação das Páginas de Autenticação?", + "authPageBrandingDeleteConfirm": "Confirmar Exclusão de Marca", + "brandingLogoURL": "URL do Logo", + "brandingLogoURLOrPath": "URL ou caminho do logotipo", + "brandingLogoPathDescription": "Insira uma URL ou um caminho local.", + "brandingLogoURLDescription": "Digite uma URL publicamente acessível para a sua imagem do logotipo.", + "brandingPrimaryColor": "Cor Primária", + "brandingLogoWidth": "Largura (px)", + "brandingLogoHeight": "Altura (px)", + "brandingOrgTitle": "Título para Página de Autenticação da Organização", + "brandingOrgDescription": "{orgName} será substituído pelo nome da organização", + "brandingOrgSubtitle": "Subtítulo para Página de Autenticação da Organização", + "brandingResourceTitle": "Título para Página de Autenticação do Recurso", + "brandingResourceSubtitle": "Subtítulo para Página de Autenticação do Recurso", + "brandingResourceDescription": "{resourceName} será substituído pelo nome da organização", + "saveAuthPageDomain": "Salvar Domínio", + "saveAuthPageBranding": "Salvar Marcação", + "removeAuthPageBranding": "Remover Marcação", "noDomainSet": "Nenhum domínio definido", "changeDomain": "Alterar domínio", "selectDomain": "Selecionar domínio", @@ -1817,7 +1997,7 @@ "setAuthPageDomain": "Definir domínio da página de autenticação", "failedToFetchCertificate": "Falha ao buscar o certificado", "failedToRestartCertificate": "Falha ao reiniciar o certificado", - "addDomainToEnableCustomAuthPages": "Adicione um domínio para habilitar páginas de autenticação personalizadas para a organização", + "addDomainToEnableCustomAuthPages": "Os usuários poderão acessar a página de login da organização e completar a autenticação do recurso usando este domínio.", "selectDomainForOrgAuthPage": "Selecione um domínio para a página de autenticação da organização", "domainPickerProvidedDomain": "Domínio fornecido", "domainPickerFreeProvidedDomain": "Domínio fornecido grátis", @@ -1832,11 +2012,27 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" não pôde ser válido para {domain}.", "domainPickerSubdomainSanitized": "Subdomínio banalizado", "domainPickerSubdomainCorrected": "\"{sub}\" foi corrigido para \"{sanitized}\"", - "orgAuthSignInTitle": "Fazer login na organização", + "orgAuthSignInTitle": "Entrada da Organização", "orgAuthChooseIdpDescription": "Escolha o seu provedor de identidade para continuar", "orgAuthNoIdpConfigured": "Esta organização não tem nenhum provedor de identidade configurado. Você pode entrar com a identidade do seu Pangolin.", "orgAuthSignInWithPangolin": "Entrar com o Pangolin", + "orgAuthSignInToOrg": "Fazer login em uma organização", + "orgAuthSelectOrgTitle": "Entrada da Organização", + "orgAuthSelectOrgDescription": "Digite seu ID da organização para continuar", + "orgAuthOrgIdPlaceholder": "sua-organização", + "orgAuthOrgIdHelp": "Digite o identificador único da sua organização", + "orgAuthSelectOrgHelp": "Após inserir o seu ID da organização, você será redirecionado para a página de entrada da organização onde poderá usar SSO ou as credenciais da organização.", + "orgAuthRememberOrgId": "Lembrar este ID da organização", + "orgAuthBackToSignIn": "Voltar para entrada padrão", + "orgAuthNoAccount": "Não possui uma conta?", "subscriptionRequiredToUse": "Uma assinatura é necessária para usar esse recurso.", + "mustUpgradeToUse": "Você deve atualizar sua assinatura para usar este recurso.", + "subscriptionRequiredTierToUse": "Esta função requer {tier} ou superior.", + "upgradeToTierToUse": "Atualize para {tier} ou superior para usar este recurso.", + "subscriptionTierTier1": "Residencial", + "subscriptionTierTier2": "Equipe", + "subscriptionTierTier3": "Empresas", + "subscriptionTierEnterprise": "Empresa", "idpDisabled": "Provedores de identidade estão desabilitados.", "orgAuthPageDisabled": "A página de autenticação da organização está desativada.", "domainRestartedDescription": "Verificação de domínio reiniciado com sucesso", @@ -1850,6 +2046,8 @@ "enableTwoFactorAuthentication": "Ativar autenticação de dois fatores", "completeSecuritySteps": "Passos de segurança completos", "securitySettings": "Configurações de Segurança", + "dangerSection": "Zona de Perigo", + "dangerSectionDescription": "Excluir permanentemente todos os dados associados a esta organização", "securitySettingsDescription": "Configurar políticas de segurança para a organização", "requireTwoFactorForAllUsers": "Exigir autenticação dupla para todos os usuários", "requireTwoFactorDescription": "Quando ativado, todos os usuários internos nesta organização devem ter a autenticação de dois fatores ativada para acessar a organização.", @@ -1887,7 +2085,7 @@ "securityPolicyChangeWarningText": "Isso afetará todos os usuários da organização", "authPageErrorUpdateMessage": "Ocorreu um erro ao atualizar as configurações da página de autenticação", "authPageErrorUpdate": "Não é possível atualizar a página de autenticação", - "authPageUpdated": "Página de autenticação atualizada com sucesso", + "authPageDomainUpdated": "Domínio da Página de Autenticação atualizado com sucesso", "healthCheckNotAvailable": "Localização", "rewritePath": "Reescrever Caminho", "rewritePathDescription": "Opcionalmente reescreva o caminho antes de encaminhar ao destino.", @@ -1915,8 +2113,15 @@ "beta": "Beta", "manageUserDevices": "Dispositivos do usuário", "manageUserDevicesDescription": "Ver e gerenciar dispositivos que os usuários usam para se conectar de forma privada aos recursos", + "downloadClientBannerTitle": "Baixar Cliente Pangolin", + "downloadClientBannerDescription": "Baixe o cliente Pangolin para seu sistema e conecte-se à rede Pangolin para acessar recursos de forma privada.", "manageMachineClients": "Gerenciar Clientes de Máquina", "manageMachineClientsDescription": "Crie e gerencie clientes que servidores e sistemas usam para se conectar de forma privada aos recursos", + "machineClientsBannerTitle": "Servidores e Sistemas Automatizados", + "machineClientsBannerDescription": "Clientes de máquina são para servidores e sistemas automatizados que não estão associados a um usuário específico. Eles autenticam com um ID e segredo, e podem ser executados com CLI Pangolin, CLI Olm, ou Olm como um contêiner.", + "machineClientsBannerPangolinCLI": "CLI de Pangolin", + "machineClientsBannerOlmCLI": "CLI Olm", + "machineClientsBannerOlmContainer": "Contêiner Olm", "clientsTableUserClients": "Utilizador", "clientsTableMachineClients": "Máquina", "licenseTableValidUntil": "Válido até", @@ -2015,6 +2220,32 @@ } } }, + "newPricingLicenseForm": { + "title": "Obtenha uma licença", + "description": "Escolha um plano e nos diga como você planeja usar o Pangolin.", + "chooseTier": "Escolha seu plano", + "viewPricingLink": "Veja os preços, recursos e limites", + "tiers": { + "starter": { + "title": "Iniciante", + "description": "Recursos de empresa, 25 usuários, 25 sites e apoio da comunidade." + }, + "scale": { + "title": "Escala", + "description": "Recursos de empresa, 50 usuários, 50 sites e apoio prioritário." + } + }, + "personalUseOnly": "Apenas uso pessoal (licença gratuita — sem check-out)", + "buttons": { + "continueToCheckout": "Continuar com checkout" + }, + "toasts": { + "checkoutError": { + "title": "Erro no check-out", + "description": "Não foi possível iniciar o checkout. Por favor, tente novamente." + } + } + }, "priority": "Prioridade", "priorityDescription": "Rotas de alta prioridade são avaliadas primeiro. Prioridade = 100 significa ordem automática (decisões do sistema). Use outro número para aplicar prioridade manual.", "instanceName": "Nome da Instância", @@ -2060,13 +2291,15 @@ "request": "Pedir", "requests": "Solicitações", "logs": "Registros", - "logsSettingsDescription": "Monitorar logs coletados desta orginização", + "logsSettingsDescription": "Monitore os logs coletados desta organização", "searchLogs": "Pesquisar registros...", "action": "Acão", "actor": "Ator", "timestamp": "Timestamp", "accessLogs": "Logs de Acesso", "exportCsv": "Exportar como CSV", + "exportError": "Erro desconhecido ao exportar CSV", + "exportCsvTooltip": "Dentro do Intervalo de Tempo", "actorId": "ID do ator", "allowedByRule": "Permitido por regra", "allowedNoAuth": "Não Permitido Nenhuma Autenticação", @@ -2111,7 +2344,8 @@ "logRetentionEndOfFollowingYear": "Fim do ano seguinte", "actionLogsDescription": "Visualizar histórico de ações realizadas nesta organização", "accessLogsDescription": "Ver solicitações de autenticação de recursos nesta organização", - "licenseRequiredToUse": "É necessária uma licença empresarial para usar esse recurso.", + "licenseRequiredToUse": "Uma licença Enterprise Edition ou Pangolin Cloud é necessária para usar este recurso. Reserve um teste de demonstração ou POC.", + "ossEnterpriseEditionRequired": "O Enterprise Edition é necessário para usar este recurso. Este recurso também está disponível no Pangolin Cloud. Reserve uma demonstração ou avaliação POC.", "certResolver": "Resolvedor de Certificado", "certResolverDescription": "Selecione o resolvedor de certificados para este recurso.", "selectCertResolver": "Selecionar solucionador de certificado", @@ -2120,7 +2354,7 @@ "unverified": "Não verificado", "domainSetting": "Configurações do domínio", "domainSettingDescription": "Configurar configurações para o domínio", - "preferWildcardCertDescription": "Tentativa de gerar um certificado coringa (requer um resolvedor de certificado devidamente configurado).", + "preferWildcardCertDescription": "Tente gerar um certificado curingado (requer um resolvedor de certificado configurado corretamente).", "recordName": "Nome da gravação", "auto": "Automático", "TTL": "TTL", @@ -2172,6 +2406,8 @@ "deviceCodeInvalidFormat": "O código deve ter 9 caracteres (ex.: A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Código inválido ou expirado", "deviceCodeVerifyFailed": "Falha ao verificar o código do dispositivo", + "deviceCodeValidating": "Validando código do dispositivo...", + "deviceCodeVerifying": "Verificando autorização do dispositivo...", "signedInAs": "Sessão iniciada como", "deviceCodeEnterPrompt": "Digite o código exibido no dispositivo", "continue": "Continuar", @@ -2184,7 +2420,7 @@ "deviceOrganizationsAccess": "Acesso a todas as organizações que sua conta tem acesso a", "deviceAuthorize": "Autorizar {applicationName}", "deviceConnected": "Dispositivo Conectado!", - "deviceAuthorizedMessage": "O dispositivo está autorizado a acessar sua conta.", + "deviceAuthorizedMessage": "O dispositivo está autorizado a acessar sua conta. Por favor, retorne ao aplicativo cliente.", "pangolinCloud": "Nuvem do Pangolin", "viewDevices": "Ver Dispositivos", "viewDevicesDescription": "Gerencie seus dispositivos conectados", @@ -2246,6 +2482,7 @@ "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Não é você? Use uma conta diferente.", "deviceLoginDeviceRequestingAccessToAccount": "Um dispositivo está solicitando acesso a essa conta.", + "loginSelectAuthenticationMethod": "Selecione um método de autenticação para continuar.", "noData": "Nenhum dado encontrado", "machineClients": "Clientes de máquina", "install": "Instale", @@ -2255,6 +2492,8 @@ "setupFailedToFetchSubnet": "Falha ao buscar a subrede padrão", "setupSubnetAdvanced": "Sub-rede (Avançado)", "setupSubnetDescription": "A sub-rede para a rede interna desta organização.", + "setupUtilitySubnet": "Sub-rede Utilitária (Avançado)", + "setupUtilitySubnetDescription": "A sub-rede para os endereços de alias e servidor DNS desta organização.", "siteRegenerateAndDisconnect": "Regerar e Desconectar", "siteRegenerateAndDisconnectConfirmation": "Você tem certeza que deseja regenerar as credenciais e desconectar este site?", "siteRegenerateAndDisconnectWarning": "Isto irá regenerar as credenciais e desconectar imediatamente o site. O site precisará ser reiniciado com as novas credenciais.", @@ -2270,5 +2509,179 @@ "remoteExitNodeRegenerateAndDisconnectWarning": "Isto irá regenerar as credenciais e desconectar imediatamente o nó de saída remota. O nó de saída remota precisará ser reiniciado com as novas credenciais.", "remoteExitNodeRegenerateCredentialsConfirmation": "Você tem certeza que deseja regenerar as credenciais para este nó de saída remota?", "remoteExitNodeRegenerateCredentialsWarning": "Isto irá regenerar as credenciais. O nó de saída remota permanecerá conectado até que você o reinicie manualmente e use as novas credenciais.", - "agent": "Representante" + "agent": "Representante", + "personalUseOnly": "Uso Pessoal Apenas", + "loginPageLicenseWatermark": "Esta instância está licenciada apenas para uso pessoal.", + "instanceIsUnlicensed": "Esta instância não está licenciada.", + "portRestrictions": "Restrições de Porta", + "allPorts": "Todos", + "custom": "Personalizado", + "allPortsAllowed": "Todas as Portas Permitidas", + "allPortsBlocked": "Todas as Portas Bloqueadas", + "tcpPortsDescription": "Especifique quais portas TCP são permitidas para este recurso. Use '*' para todas as portas, deixe vazio para bloquear todas, ou insira uma lista de portas separadas por vírgulas e intervalos (por exemplo, 80,443,8000-9000).", + "udpPortsDescription": "Especifique quais portas UDP são permitidas para este recurso. Use '*' para todas as portas, deixe vazio para bloquear todas, ou insira uma lista de portas separadas por vírgulas e intervalos (por exemplo, 53,123,500-600).", + "organizationLoginPageTitle": "Página de Login da Organização", + "organizationLoginPageDescription": "Personalize a página de login para esta organização", + "resourceLoginPageTitle": "Página de Login de Recurso", + "resourceLoginPageDescription": "Personalize a página de login para recursos individuais", + "enterConfirmation": "Inserir confirmação", + "blueprintViewDetails": "Detalhes", + "defaultIdentityProvider": "Provedor de Identidade Padrão", + "defaultIdentityProviderDescription": "Quando um provedor de identidade padrão for selecionado, o usuário será automaticamente redirecionado para o provedor de autenticação.", + "editInternalResourceDialogNetworkSettings": "Configurações de Rede", + "editInternalResourceDialogAccessPolicy": "Política de Acesso", + "editInternalResourceDialogAddRoles": "Adicionar Funções", + "editInternalResourceDialogAddUsers": "Adicionar Usuários", + "editInternalResourceDialogAddClients": "Adicionar Clientes", + "editInternalResourceDialogDestinationLabel": "Destino", + "editInternalResourceDialogDestinationDescription": "Especifique o endereço de destino para o recurso interno. Isso pode ser um nome de host, endereço IP ou intervalo CIDR, dependendo do modo selecionado. Opcionalmente, defina um alias interno de DNS para facilitar a identificação.", + "editInternalResourceDialogPortRestrictionsDescription": "Restrinja o acesso a portas TCP/UDP específicas ou permita/bloqueie todas as portas.", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "Controle de Acesso", + "editInternalResourceDialogAccessControlDescription": "Controle quais funções, usuários e clientes de máquina podem acessar este recurso quando conectados. Os administradores sempre têm acesso.", + "editInternalResourceDialogPortRangeValidationError": "O intervalo de portas deve ser \"*\" para todas as portas, ou uma lista de portas e intervalos separados por vírgulas (por exemplo, \"80,443,8000-9000\"). As portas devem estar entre 1 e 65535.", + "internalResourceAuthDaemonStrategy": "Local do Daemon de autenticação SSH", + "internalResourceAuthDaemonStrategyDescription": "Escolha onde o daemon de autenticação SSH funciona: no site (Newt) ou em um host remoto.", + "internalResourceAuthDaemonDescription": "A autenticação SSH daemon lida com assinatura de chave SSH e autenticação PAM para este recurso. Escolha se ele é executado no site (Newt) ou em um host remoto separado. Veja a documentação para mais informações.", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "Selecione a estratégia", + "internalResourceAuthDaemonStrategyLabel": "Local:", + "internalResourceAuthDaemonSite": "No Site", + "internalResourceAuthDaemonSiteDescription": "O serviço de autenticação é executado no site (Newt).", + "internalResourceAuthDaemonRemote": "Host Remoto", + "internalResourceAuthDaemonRemoteDescription": "O serviço de autenticação é executado em um host que não é o site.", + "internalResourceAuthDaemonPort": "Porta do Daemon (opcional)", + "orgAuthWhatsThis": "Onde posso encontrar meu ID da organização?", + "learnMore": "Saiba mais", + "backToHome": "Voltar para a página inicial", + "needToSignInToOrg": "Precisa usar o provedor de identidade da sua organização?", + "maintenanceMode": "Modo de Manutenção", + "maintenanceModeDescription": "Exibir uma página de manutenção para os visitantes", + "maintenanceModeType": "Tipo de Modo de Manutenção", + "showMaintenancePage": "Mostrar uma página de manutenção para os visitantes", + "enableMaintenanceMode": "Ativar Modo de Manutenção", + "automatic": "Automático", + "automaticModeDescription": "Exibir página de manutenção apenas quando todos os destinos de back-end estiverem inativos ou não saudáveis. Seu recurso continua funcionando normalmente desde que pelo menos um destino esteja saudável.", + "forced": "Forçado", + "forcedModeDescription": "Sempre mostre a página de manutenção, independentemente da saúde do backend. Use isso para manutenção planejada quando você deseja impedir todo acesso.", + "warning:": "Aviso:", + "forcedeModeWarning": "Todo o tráfego será direcionado para a página de manutenção. Seus recursos de back-end não receberão nenhuma solicitação.", + "pageTitle": "Título da Página", + "pageTitleDescription": "O título principal exibido na página de manutenção", + "maintenancePageMessage": "Mensagem de Manutenção", + "maintenancePageMessagePlaceholder": "Voltaremos em breve! Nosso site está passando por manutenção programada.", + "maintenancePageMessageDescription": "Mensagem detalhada explicando a manutenção", + "maintenancePageTimeTitle": "Hora de Conclusão Estimada (Opcional)", + "maintenanceTime": "por exemplo, 2 horas, 1 de Nov às 17h00", + "maintenanceEstimatedTimeDescription": "Quando você espera que a manutenção seja concluída", + "editDomain": "Editar Domínio", + "editDomainDescription": "Selecione um domínio para o seu recurso", + "maintenanceModeDisabledTooltip": "Este recurso requer uma licença válida para ativar.", + "maintenanceScreenTitle": "Serviço Temporariamente Indisponível", + "maintenanceScreenMessage": "Estamos enfrentando dificuldades técnicas no momento. Por favor, volte em breve.", + "maintenanceScreenEstimatedCompletion": "Conclusão Estimada:", + "createInternalResourceDialogDestinationRequired": "Destino é obrigatório", + "available": "Disponível", + "archived": "Arquivado", + "noArchivedDevices": "Nenhum dispositivo arquivado encontrado", + "deviceArchived": "Dispositivo arquivado", + "deviceArchivedDescription": "O dispositivo foi arquivado com sucesso.", + "errorArchivingDevice": "Erro ao arquivar dispositivo", + "failedToArchiveDevice": "Falha ao arquivar dispositivo", + "deviceQuestionArchive": "Tem certeza que deseja arquivar este dispositivo?", + "deviceMessageArchive": "O dispositivo será arquivado e removido da sua lista de dispositivos ativos.", + "deviceArchiveConfirm": "Arquivar dispositivo", + "archiveDevice": "Arquivar dispositivo", + "archive": "Arquivo", + "deviceUnarchived": "Dispositivo desarquivado", + "deviceUnarchivedDescription": "O dispositivo foi desarquivado com sucesso.", + "errorUnarchivingDevice": "Erro ao desarquivar dispositivo", + "failedToUnarchiveDevice": "Falha ao desarquivar dispositivo", + "unarchive": "Desarquivar", + "archiveClient": "Arquivar Cliente", + "archiveClientQuestion": "Tem certeza que deseja arquivar este cliente?", + "archiveClientMessage": "O cliente será arquivado e removido da sua lista de clientes ativos.", + "archiveClientConfirm": "Arquivar Cliente", + "blockClient": "Bloco do Cliente", + "blockClientQuestion": "Tem certeza que deseja bloquear este cliente?", + "blockClientMessage": "O dispositivo será forçado a desconectar se estiver conectado. Você pode desbloquear o dispositivo mais tarde.", + "blockClientConfirm": "Bloco do Cliente", + "active": "ativo", + "usernameOrEmail": "Usuário ou Email", + "selectYourOrganization": "Selecione sua organização", + "signInTo": "Iniciar sessão em", + "signInWithPassword": "Continuar com a senha", + "noAuthMethodsAvailable": "Nenhum método de autenticação disponível para esta organização.", + "enterPassword": "Digite sua senha", + "enterMfaCode": "Insira o código do seu aplicativo autenticador", + "securityKeyRequired": "Por favor, utilize sua chave de segurança para entrar.", + "needToUseAnotherAccount": "Precisa usar uma conta diferente?", + "loginLegalDisclaimer": "Ao clicar nos botões abaixo, você reconhece que leu, entende e concorda com os Termos de Serviço e a Política de Privacidade.", + "termsOfService": "Termos de Serviço", + "privacyPolicy": "Política de Privacidade", + "userNotFoundWithUsername": "Nenhum usuário encontrado com este nome de usuário.", + "verify": "Verificar", + "signIn": "Iniciar sessão", + "forgotPassword": "Esqueceu a senha?", + "orgSignInTip": "Se você já fez login antes, você pode digitar seu nome de usuário ou e-mail acima para autenticar com o provedor de identidade da sua organização. É mais fácil!", + "continueAnyway": "Continuar mesmo assim", + "dontShowAgain": "Não mostrar novamente", + "orgSignInNotice": "Você sabia?", + "signupOrgNotice": "Tentando fazer login?", + "signupOrgTip": "Você está tentando entrar através do provedor de identidade da sua organização?", + "signupOrgLink": "Faça login ou inscreva-se com sua organização em vez disso", + "verifyEmailLogInWithDifferentAccount": "Use uma Conta Diferente", + "logIn": "Iniciar sessão", + "deviceInformation": "Informações do dispositivo", + "deviceInformationDescription": "Informações sobre o dispositivo e o agente", + "deviceSecurity": "Segurança do dispositivo", + "deviceSecurityDescription": "Informações sobre postagem de segurança", + "platform": "Plataforma", + "macosVersion": "Versão do macOS", + "windowsVersion": "Versão do Windows", + "iosVersion": "Versão para iOS", + "androidVersion": "Versão do Android", + "osVersion": "Versão do SO", + "kernelVersion": "Versão do Kernel", + "deviceModel": "Modelo do dispositivo", + "serialNumber": "Número de Série", + "hostname": "Hostname", + "firstSeen": "Visto primeiro", + "lastSeen": "Visto por último", + "biometricsEnabled": "Biometria habilitada", + "diskEncrypted": "Disco criptografado", + "firewallEnabled": "Firewall habilitado", + "autoUpdatesEnabled": "Atualizações Automáticas Habilitadas", + "tpmAvailable": "TPM disponível", + "windowsAntivirusEnabled": "Antivírus habilitado", + "macosSipEnabled": "Proteção da Integridade do Sistema (SIP)", + "macosGatekeeperEnabled": "Gatekeeper", + "macosFirewallStealthMode": "Modo Furtivo do Firewall", + "linuxAppArmorEnabled": "AppArmor", + "linuxSELinuxEnabled": "SELinux", + "deviceSettingsDescription": "Ver informações e configurações do dispositivo", + "devicePendingApprovalDescription": "Este dispositivo está aguardando aprovação", + "deviceBlockedDescription": "Este dispositivo está bloqueado no momento. Ele não será capaz de se conectar a qualquer recurso a menos que seja desbloqueado.", + "unblockClient": "Desbloquear Cliente", + "unblockClientDescription": "O dispositivo foi desbloqueado", + "unarchiveClient": "Desarquivar Cliente", + "unarchiveClientDescription": "O dispositivo foi desarquivado", + "block": "Bloquear", + "unblock": "Desbloquear", + "deviceActions": "Ações do dispositivo", + "deviceActionsDescription": "Gerenciar status e acesso do dispositivo", + "devicePendingApprovalBannerDescription": "Este dispositivo está pendente de aprovação. Não será possível conectar-se a recursos até ser aprovado.", + "connected": "Conectado", + "disconnected": "Desconectado", + "approvalsEmptyStateTitle": "Aprovações do dispositivo não habilitado", + "approvalsEmptyStateDescription": "Habilitar aprovações do dispositivo para cargos que exigem aprovação do administrador antes que os usuários possam conectar novos dispositivos.", + "approvalsEmptyStateStep1Title": "Ir para Funções", + "approvalsEmptyStateStep1Description": "Navegue até as configurações dos papéis da sua organização para configurar as aprovações de dispositivo.", + "approvalsEmptyStateStep2Title": "Habilitar Aprovações do Dispositivo", + "approvalsEmptyStateStep2Description": "Editar uma função e habilitar a opção 'Exigir aprovação de dispositivos'. Usuários com essa função precisarão de aprovação de administrador para novos dispositivos.", + "approvalsEmptyStatePreviewDescription": "Pré-visualização: Quando ativado, solicitações de dispositivo pendentes aparecerão aqui para revisão", + "approvalsEmptyStateButtonText": "Gerir Funções", + "domainErrorTitle": "Estamos tendo problemas ao verificar seu domínio" } diff --git a/messages/ru-RU.json b/messages/ru-RU.json index 95814125d..98a787f45 100644 --- a/messages/ru-RU.json +++ b/messages/ru-RU.json @@ -1,5 +1,7 @@ { "setupCreate": "Создать организацию, сайт и ресурсы", + "headerAuthCompatibilityInfo": "Включите это, чтобы принудительно вернуть ответ 401 Unauthorized, если отсутствует токен аутентификации. Это требуется для браузеров или определенных библиотек HTTP, которые не отправляют учетные данные без запроса сервера.", + "headerAuthCompatibility": "Дополнительная совместимость", "setupNewOrg": "Новая организация", "setupCreateOrg": "Создать организацию", "setupCreateResources": "Создать ресурсы", @@ -16,6 +18,8 @@ "componentsMember": "Вы состоите в {count, plural, =0 {0 организациях} one {# организации} few {# организациях} many {# организациях} other {# организациях}}.", "componentsInvalidKey": "Обнаружены недействительные или просроченные лицензионные ключи. Соблюдайте условия лицензии для использования всех функций.", "dismiss": "Отменить", + "subscriptionViolationMessage": "Вы превысили лимиты для вашего текущего плана. Исправьте проблему, удалив сайты, пользователей или другие ресурсы, чтобы остаться в пределах вашего плана.", + "subscriptionViolationViewBilling": "Просмотр биллинга", "componentsLicenseViolation": "Нарушение лицензии: Сервер использует {usedSites} сайтов, что превышает лицензионный лимит в {maxSites} сайтов. Соблюдайте условия лицензии для использования всех функций.", "componentsSupporterMessage": "Спасибо за поддержку Pangolin в качестве {tier}!", "inviteErrorNotValid": "Извините, но это приглашение не было принято или срок его действия истёк.", @@ -33,7 +37,7 @@ "password": "Пароль", "confirmPassword": "Подтвердите пароль", "createAccount": "Создать учётную запись", - "viewSettings": "Посмотреть настройки", + "viewSettings": "Просмотреть настройки", "delete": "Удалить", "name": "Имя", "online": "Онлайн", @@ -51,6 +55,12 @@ "siteQuestionRemove": "Вы уверены, что хотите удалить сайт из организации?", "siteManageSites": "Управление сайтами", "siteDescription": "Создание и управление сайтами, чтобы включить подключение к приватным сетям", + "sitesBannerTitle": "Подключить любую сеть", + "sitesBannerDescription": "Сайт — это соединение с удаленной сетью, которое позволяет Pangolin предоставлять доступ к ресурсам, будь они общедоступными или частными, пользователям в любом месте. Установите сетевой коннектор сайта (Newt) там, где можно запустить исполняемый файл или контейнер, чтобы установить соединение.", + "sitesBannerButtonText": "Установить сайт", + "approvalsBannerTitle": "Одобрить или запретить доступ к устройству", + "approvalsBannerDescription": "Просмотрите и подтвердите или отклоните запросы на доступ к устройству от пользователей. Когда требуется подтверждение устройства, пользователи должны получить одобрение администратора, прежде чем их устройства смогут подключиться к ресурсам вашей организации.", + "approvalsBannerButtonText": "Узнать больше", "siteCreate": "Создать сайт", "siteCreateDescription2": "Следуйте инструкциям ниже для создания и подключения нового сайта", "siteCreateDescription": "Создайте новый сайт для начала подключения ресурсов", @@ -100,6 +110,7 @@ "siteTunnelDescription": "Определите, как вы хотите подключиться к сайту", "siteNewtCredentials": "Полномочия", "siteNewtCredentialsDescription": "Вот как сайт будет аутентифицироваться с сервером", + "remoteNodeCredentialsDescription": "Так удалённый узел будет выполнять аутентификацию на сервере", "siteCredentialsSave": "Сохранить учетные данные", "siteCredentialsSaveDescription": "Вы сможете увидеть эти данные только один раз. Обязательно скопируйте их в безопасное место.", "siteInfo": "Информация о сайте", @@ -146,8 +157,12 @@ "shareErrorSelectResource": "Пожалуйста, выберите ресурс", "proxyResourceTitle": "Управление публичными ресурсами", "proxyResourceDescription": "Создание и управление ресурсами, которые доступны через веб-браузер", + "proxyResourcesBannerTitle": "Общедоступный доступ через веб", + "proxyResourcesBannerDescription": "Общедоступные ресурсы — это прокси-по HTTPS или TCP/UDP, доступные любому пользователю в Интернете через веб-браузер. В отличие от частных ресурсов, они не требуют программного обеспечения на стороне клиента и могут включать политики доступа на основе идентификации и контекста.", "clientResourceTitle": "Управление приватными ресурсами", "clientResourceDescription": "Создание и управление ресурсами, которые доступны только через подключенный клиент", + "privateResourcesBannerTitle": "Частный доступ с нулевым доверием", + "privateResourcesBannerDescription": "Частные ресурсы используют безопасность с нулевым доверием, обеспечивая доступ пользователей и устройств только к ресурсам, к которым вы явно предоставили доступ. Подключите пользовательские устройства или машинных клиентов, чтобы получить доступ к этим ресурсам через безопасную виртуальную частную сеть.", "resourcesSearch": "Поиск ресурсов...", "resourceAdd": "Добавить ресурс", "resourceErrorDelte": "Ошибка при удалении ресурса", @@ -157,9 +172,10 @@ "resourceMessageRemove": "После удаления ресурс больше не будет доступен. Все целевые узлы, связанные с ресурсом, также будут удалены.", "resourceQuestionRemove": "Вы уверены, что хотите удалить ресурс из организации?", "resourceHTTP": "HTTPS-ресурс", - "resourceHTTPDescription": "Прокси-запросы к приложению по HTTPS с помощью поддомена или базового домена.", + "resourceHTTPDescription": "Проксировать запросы через HTTPS с использованием полного доменного имени.", "resourceRaw": "Сырой TCP/UDP-ресурс", - "resourceRawDescription": "Прокси запрашивает приложение через TCP/UDP по номеру порта. Это работает только тогда, когда сайты подключены к узлам.", + "resourceRawDescription": "Проксировать запросы по сырому TCP/UDP с использованием номера порта.", + "resourceRawDescriptionCloud": "Прокси запросы через необработанный TCP/UDP с использованием номера порта. Требуется подключение сайтов к удаленному узлу.", "resourceCreate": "Создание ресурса", "resourceCreateDescription": "Следуйте инструкциям ниже для создания нового ресурса", "resourceSeeAll": "Посмотреть все ресурсы", @@ -186,6 +202,7 @@ "protocolSelect": "Выберите протокол", "resourcePortNumber": "Номер порта", "resourcePortNumberDescription": "Внешний номер порта для проксирования запросов.", + "back": "Назад", "cancel": "Отмена", "resourceConfig": "Фрагменты конфигурации", "resourceConfigDescription": "Скопируйте и вставьте эти сниппеты для настройки TCP/UDP ресурса", @@ -231,6 +248,17 @@ "orgErrorDeleteMessage": "Произошла ошибка при удалении организации.", "orgDeleted": "Организация удалена", "orgDeletedMessage": "Организация и её данные были удалены.", + "deleteAccount": "Удалить аккаунт", + "deleteAccountDescription": "Окончательно удалить учетную запись, все организации, которые вы владеете, и все данные этих организаций не могут быть отменены.", + "deleteAccountButton": "Удалить аккаунт", + "deleteAccountConfirmTitle": "Удалить аккаунт", + "deleteAccountConfirmMessage": "Это очистит ваш аккаунт, все организации, которым вы владеете, и все данные этих организаций не могут быть отменены.", + "deleteAccountConfirmString": "удалить аккаунт", + "deleteAccountSuccess": "Учетная запись удалена", + "deleteAccountSuccessMessage": "Ваша учетная запись удалена.", + "deleteAccountError": "Не удалось удалить аккаунт", + "deleteAccountPreviewAccount": "Ваша учетная запись", + "deleteAccountPreviewOrgs": "Организации, которые вы владеете (и все их данные)", "orgMissing": "Отсутствует ID организации", "orgMissingMessage": "Невозможно восстановить приглашение без ID организации.", "accessUsersManage": "Управление пользователями", @@ -247,6 +275,8 @@ "accessRolesSearch": "Поиск ролей...", "accessRolesAdd": "Добавить роль", "accessRoleDelete": "Удалить роль", + "accessApprovalsManage": "Управление утверждениями", + "accessApprovalsDescription": "Просмотр и управление утверждениями в ожидании доступа к этой организации", "description": "Описание", "inviteTitle": "Открытые приглашения", "inviteDescription": "Управление приглашениями для присоединения других пользователей к организации", @@ -440,6 +470,20 @@ "selectDuration": "Укажите срок действия", "selectResource": "Выберите ресурс", "filterByResource": "Фильтровать по ресурсам", + "selectApprovalState": "Выберите состояние одобрения", + "filterByApprovalState": "Фильтр по состоянию утверждения", + "approvalListEmpty": "Нет утверждений", + "approvalState": "Состояние одобрения", + "approvalLoadMore": "Загрузить еще", + "loadingApprovals": "Загрузка утверждений", + "approve": "Одобрить", + "approved": "Одобрено", + "denied": "Отказано", + "deniedApproval": "Отказано в одобрении", + "all": "Все", + "deny": "Запретить", + "viewDetails": "Детали", + "requestingNewDeviceApproval": "запросил новое устройство", "resetFilters": "Сбросить фильтры", "totalBlocked": "Запросы заблокированы Панголином", "totalRequests": "Всего запросов", @@ -607,6 +651,7 @@ "resourcesErrorUpdate": "Не удалось переключить ресурс", "resourcesErrorUpdateDescription": "Произошла ошибка при обновлении ресурса", "access": "Доступ", + "accessControl": "Контроль доступа", "shareLink": "Общая ссылка {resource}", "resourceSelect": "Выберите ресурс", "shareLinks": "Общие ссылки", @@ -687,7 +732,7 @@ "resourceRoleDescription": "Администраторы всегда имеют доступ к этому ресурсу.", "resourceUsersRoles": "Контроль доступа", "resourceUsersRolesDescription": "Выберите пользователей и роли с доступом к этому ресурсу", - "resourceUsersRolesSubmit": "Сохранить пользователей и роли", + "resourceUsersRolesSubmit": "Сохранить контроль доступа", "resourceWhitelistSave": "Успешно сохранено", "resourceWhitelistSaveDescription": "Настройки белого списка были сохранены", "ssoUse": "Использовать Platform SSO", @@ -719,22 +764,35 @@ "countries": "Страны", "accessRoleCreate": "Создание роли", "accessRoleCreateDescription": "Создайте новую роль для группы пользователей и выдавайте им разрешения.", + "accessRoleEdit": "Изменить роль", + "accessRoleEditDescription": "Редактировать информацию о роли.", "accessRoleCreateSubmit": "Создать роль", "accessRoleCreated": "Роль создана", "accessRoleCreatedDescription": "Роль была успешно создана.", "accessRoleErrorCreate": "Не удалось создать роль", "accessRoleErrorCreateDescription": "Произошла ошибка при создании роли.", + "accessRoleUpdateSubmit": "Обновить роль", + "accessRoleUpdated": "Роль обновлена", + "accessRoleUpdatedDescription": "Роль была успешно обновлена.", + "accessApprovalUpdated": "Выполнено утверждение", + "accessApprovalApprovedDescription": "Принять решение об утверждении запроса.", + "accessApprovalDeniedDescription": "Отказано в запросе об утверждении.", + "accessRoleErrorUpdate": "Не удалось обновить роль", + "accessRoleErrorUpdateDescription": "Произошла ошибка при обновлении роли.", + "accessApprovalErrorUpdate": "Не удалось обработать подтверждение", + "accessApprovalErrorUpdateDescription": "Произошла ошибка при обработке одобрения.", "accessRoleErrorNewRequired": "Новая роль обязательна", "accessRoleErrorRemove": "Не удалось удалить роль", "accessRoleErrorRemoveDescription": "Произошла ошибка при удалении роли.", "accessRoleName": "Название роли", - "accessRoleQuestionRemove": "Вы собираетесь удалить роль {name}. Это действие нельзя отменить.", + "accessRoleQuestionRemove": "Вы собираетесь удалить `{name}` роль. Это действие нельзя отменить.", "accessRoleRemove": "Удалить роль", "accessRoleRemoveDescription": "Удалить роль из организации", "accessRoleRemoveSubmit": "Удалить роль", "accessRoleRemoved": "Роль удалена", "accessRoleRemovedDescription": "Роль была успешно удалена.", "accessRoleRequiredRemove": "Перед удалением этой роли выберите новую роль для переноса существующих участников.", + "network": "Сеть", "manage": "Управление", "sitesNotFound": "Сайты не найдены.", "pangolinServerAdmin": "Администратор сервера - Pangolin", @@ -750,6 +808,9 @@ "sitestCountIncrease": "Увеличить количество сайтов", "idpManage": "Управление поставщиками удостоверений", "idpManageDescription": "Просмотр и управление поставщиками удостоверений в системе", + "idpGlobalModeBanner": "Поставщики удостоверений (IdP) для каждой организации отключены на этом сервере. Используются глобальные IdP (общие для всех организаций). Управляйте глобальными IdP в админ-панели. Чтобы включить IdP для каждой организации, отредактируйте конфигурацию сервера и установите режим IdP в org. См. документацию. Если вы хотите продолжать использовать глобальные IdP и скрыть это из настроек организации, явно установите режим в глобальном конфиге.", + "idpGlobalModeBannerUpgradeRequired": "Поставщики удостоверений (IdP) для каждой организации отключены на этом сервере. Используются глобальные IdP (общие для всех организаций). Управляйте глобальными IdP в админ-панели. Чтобы использовать поставщиков удостоверений для каждой организации, необходимо обновить систему до версии Enterprise.", + "idpGlobalModeBannerLicenseRequired": "Поставщики удостоверений (IdP) для каждой организации отключены на этом сервере. Используются глобальные IdP (общие для всех организаций). Управляйте глобальными IdP в админ-панели. Для использования поставщиков удостоверений на организацию требуется лицензия Enterprise.", "idpDeletedDescription": "Поставщик удостоверений успешно удалён", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Вы уверены, что хотите навсегда удалить поставщика удостоверений?", @@ -840,6 +901,7 @@ "orgPolicyConfig": "Настроить доступ для организации", "idpUpdatedDescription": "Поставщик удостоверений успешно обновлён", "redirectUrl": "URL редиректа", + "orgIdpRedirectUrls": "Перенаправление URL", "redirectUrlAbout": "О редиректе URL", "redirectUrlAboutDescription": "Это URL, на который пользователи будут перенаправлены после аутентификации. Вам нужно настроить этот URL в настройках провайдера.", "pangolinAuth": "Аутентификация - Pangolin", @@ -945,11 +1007,11 @@ "pincodeAuth": "Код аутентификатора", "pincodeSubmit2": "Отправить код", "passwordResetSubmit": "Запросить сброс", - "passwordResetAlreadyHaveCode": "Введите код сброса пароля", + "passwordResetAlreadyHaveCode": "Введите код", "passwordResetSmtpRequired": "Пожалуйста, обратитесь к администратору", "passwordResetSmtpRequiredDescription": "Для сброса пароля необходим код сброса пароля. Обратитесь к администратору за помощью.", "passwordBack": "Назад к паролю", - "loginBack": "Вернуться к входу", + "loginBack": "Вернуться на главную страницу входа", "signup": "Регистрация", "loginStart": "Войдите для начала работы", "idpOidcTokenValidating": "Проверка OIDC токена", @@ -972,12 +1034,12 @@ "pangolinSetup": "Настройка - Pangolin", "orgNameRequired": "Название организации обязательно", "orgIdRequired": "ID организации обязателен", + "orgIdMaxLength": "ID организации должен быть не более 32 символов", "orgErrorCreate": "Произошла ошибка при создании организации", "pageNotFound": "Страница не найдена", "pageNotFoundDescription": "Упс! Страница, которую вы ищете, не существует.", "overview": "Обзор", "home": "Главная", - "accessControl": "Контроль доступа", "settings": "Настройки", "usersAll": "Все пользователи", "license": "Лицензия", @@ -1035,15 +1097,24 @@ "updateOrgUser": "Обновить пользователя Org", "createOrgUser": "Создать пользователя Org", "actionUpdateOrg": "Обновить организацию", + "actionRemoveInvitation": "Удалить приглашение", "actionUpdateUser": "Обновить пользователя", "actionGetUser": "Получить пользователя", "actionGetOrgUser": "Получить пользователя организации", "actionListOrgDomains": "Список доменов организации", + "actionGetDomain": "Получить домен", + "actionCreateOrgDomain": "Создать домен", + "actionUpdateOrgDomain": "Обновить домен", + "actionDeleteOrgDomain": "Удалить домен", + "actionGetDNSRecords": "Получить записи DNS", + "actionRestartOrgDomain": "Перезапустить домен", "actionCreateSite": "Создать сайт", "actionDeleteSite": "Удалить сайт", "actionGetSite": "Получить сайт", "actionListSites": "Список сайтов", "actionApplyBlueprint": "Применить чертёж", + "actionListBlueprints": "Список чертежей", + "actionGetBlueprint": "Получить чертёж", "setupToken": "Код настройки", "setupTokenDescription": "Введите токен настройки из консоли сервера.", "setupTokenRequired": "Токен настройки обязателен", @@ -1077,6 +1148,7 @@ "actionRemoveUser": "Удалить пользователя", "actionListUsers": "Список пользователей", "actionAddUserRole": "Добавить роль пользователя", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Сгенерировать токен доступа", "actionDeleteAccessToken": "Удалить токен доступа", "actionListAccessTokens": "Список токенов доступа", @@ -1104,6 +1176,10 @@ "actionUpdateIdpOrg": "Обновить организацию IDP", "actionCreateClient": "Создать Клиента", "actionDeleteClient": "Удалить Клиента", + "actionArchiveClient": "Архивировать клиента", + "actionUnarchiveClient": "Разархивировать клиента", + "actionBlockClient": "Блокировать клиента", + "actionUnblockClient": "Разблокировать клиента", "actionUpdateClient": "Обновить Клиента", "actionListClients": "Список Клиентов", "actionGetClient": "Получить Клиента", @@ -1117,17 +1193,18 @@ "actionViewLogs": "Просмотр журналов", "noneSelected": "Ничего не выбрано", "orgNotFound2": "Организации не найдены.", - "searchProgress": "Поиск...", + "searchPlaceholder": "Поиск...", + "emptySearchOptions": "Опции не найдены", "create": "Создать", "orgs": "Организации", - "loginError": "Произошла ошибка при входе", - "loginRequiredForDevice": "Для аутентификации устройства необходимо войти в систему.", + "loginError": "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз.", + "loginRequiredForDevice": "Логин необходим для вашего устройства.", "passwordForgot": "Забыли пароль?", "otpAuth": "Двухфакторная аутентификация", "otpAuthDescription": "Введите код из вашего приложения-аутентификатора или один из ваших одноразовых резервных кодов.", "otpAuthSubmit": "Отправить код", "idpContinue": "Или продолжить с", - "otpAuthBack": "Вернуться к входу", + "otpAuthBack": "Назад к паролю", "navbar": "Навигационное меню", "navbarDescription": "Главное навигационное меню приложения", "navbarDocsLink": "Документация", @@ -1175,11 +1252,13 @@ "sidebarOverview": "Обзор", "sidebarHome": "Главная", "sidebarSites": "Сайты", + "sidebarApprovals": "Запросы на утверждение", "sidebarResources": "Ресурсы", "sidebarProxyResources": "Публичный", "sidebarClientResources": "Приватный", "sidebarAccessControl": "Контроль доступа", "sidebarLogsAndAnalytics": "Журналы и аналитика", + "sidebarTeam": "Команда", "sidebarUsers": "Пользователи", "sidebarAdmin": "Админ", "sidebarInvitations": "Приглашения", @@ -1191,13 +1270,15 @@ "sidebarIdentityProviders": "Поставщики удостоверений", "sidebarLicense": "Лицензия", "sidebarClients": "Клиенты", - "sidebarUserDevices": "Пользователи", + "sidebarUserDevices": "Устройства пользователя", "sidebarMachineClients": "Машины", "sidebarDomains": "Домены", - "sidebarGeneral": "Общие", + "sidebarGeneral": "Управление", "sidebarLogAndAnalytics": "Журнал и аналитика", "sidebarBluePrints": "Чертежи", "sidebarOrganization": "Организация", + "sidebarManagement": "Управление", + "sidebarBillingAndLicenses": "Биллинг и лицензии", "sidebarLogsAnalytics": "Статистика", "blueprints": "Чертежи", "blueprintsDescription": "Применить декларирующие конфигурации и просмотреть предыдущие запуски", @@ -1219,7 +1300,6 @@ "parsedContents": "Переработанное содержимое (только для чтения)", "enableDockerSocket": "Включить чертёж Docker", "enableDockerSocketDescription": "Включить scraping ярлыка Docker Socket для ярлыков чертежей. Путь к сокету должен быть предоставлен в Newt.", - "enableDockerSocketLink": "Узнать больше", "viewDockerContainers": "Просмотр контейнеров Docker", "containersIn": "Контейнеры в {siteName}", "selectContainerDescription": "Выберите любой контейнер для использования в качестве имени хоста для этой цели. Нажмите на порт, чтобы использовать порт.", @@ -1263,6 +1343,7 @@ "setupErrorCreateAdmin": "Произошла ошибка при создании учётной записи администратора сервера.", "certificateStatus": "Статус сертификата", "loading": "Загрузка", + "loadingAnalytics": "Загрузка аналитики", "restart": "Перезагрузка", "domains": "Домены", "domainsDescription": "Создание и управление доменами, доступными в организации", @@ -1290,6 +1371,7 @@ "refreshError": "Не удалось обновить данные", "verified": "Подтверждено", "pending": "В ожидании", + "pendingApproval": "Ожидает утверждения", "sidebarBilling": "Выставление счетов", "billing": "Выставление счетов", "orgBillingDescription": "Управление платежной информацией и подписками", @@ -1308,8 +1390,11 @@ "accountSetupSuccess": "Настройка аккаунта завершена! Добро пожаловать в Pangolin!", "documentation": "Документация", "saveAllSettings": "Сохранить все настройки", + "saveResourceTargets": "Сохранить цели", + "saveResourceHttp": "Сохранить настройки прокси", + "saveProxyProtocol": "Сохранить настройки прокси-протокола", "settingsUpdated": "Настройки обновлены", - "settingsUpdatedDescription": "Все настройки успешно обновлены", + "settingsUpdatedDescription": "Настройки успешно обновлены", "settingsErrorUpdate": "Не удалось обновить настройки", "settingsErrorUpdateDescription": "Произошла ошибка при обновлении настроек", "sidebarCollapse": "Свернуть", @@ -1342,6 +1427,7 @@ "domainPickerNamespace": "Пространство имен: {namespace}", "domainPickerShowMore": "Показать еще", "regionSelectorTitle": "Выберите регион", + "domainPickerRemoteExitNodeWarning": "Предоставленные домены не поддерживаются при подключении сайтов к удаленным узлам. Для доступа к ресурсам на удаленных узлах используйте пользовательский домен.", "regionSelectorInfo": "Выбор региона помогает нам обеспечить лучшее качество обслуживания для вашего расположения. Вам необязательно находиться в том же регионе, что и ваш сервер.", "regionSelectorPlaceholder": "Выбор региона", "regionSelectorComingSoon": "Скоро будет", @@ -1351,10 +1437,11 @@ "billingUsageLimitsOverview": "Обзор лимитов использования", "billingMonitorUsage": "Контролируйте использование в соответствии с установленными лимитами. Если вам требуется увеличение лимитов, пожалуйста, свяжитесь с нами support@pangolin.net.", "billingDataUsage": "Использование данных", - "billingOnlineTime": "Время работы сайта", - "billingUsers": "Активные пользователи", - "billingDomains": "Активные домены", - "billingRemoteExitNodes": "Активные самоуправляемые узлы", + "billingSites": "Сайты", + "billingUsers": "Пользователи", + "billingDomains": "Домены", + "billingOrganizations": "Орги", + "billingRemoteExitNodes": "Удаленные узлы", "billingNoLimitConfigured": "Лимит не установлен", "billingEstimatedPeriod": "Предполагаемый период выставления счетов", "billingIncludedUsage": "Включенное использование", @@ -1379,15 +1466,24 @@ "billingFailedToGetPortalUrl": "Не удалось получить URL-адрес портала", "billingPortalError": "Ошибка портала", "billingDataUsageInfo": "Вы несете ответственность за все данные, переданные через безопасные туннели при подключении к облаку. Это включает как входящий, так и исходящий трафик на всех ваших сайтах. При достижении лимита ваши сайты будут отключаться до тех пор, пока вы не обновите план или не уменьшите его использование. При использовании узлов не взимается плата.", - "billingOnlineTimeInfo": "Вы тарифицируете на то, как долго ваши сайты будут подключены к облаку. Например, 44 640 минут равны одному сайту, работающему круглосуточно за весь месяц. Когда вы достигните лимита, ваши сайты будут отключаться до тех пор, пока вы не обновите тарифный план или не сократите нагрузку. При использовании узлов не тарифицируется.", - "billingUsersInfo": "Вы оплачиваете за каждого пользователя в организации. Платеж рассчитывается ежедневно в зависимости от количества активных учетных записей в вашем органе.", - "billingDomainInfo": "Вы платите за каждый домен в организации. Платеж рассчитывается ежедневно в зависимости от количества активных доменных аккаунтов в вашем органе.", - "billingRemoteExitNodesInfo": "Вы платите за каждый управляемый узел организации. Платёж рассчитывается ежедневно на основе количества активных управляемых узлов в вашем органе.", + "billingSInfo": "Сколько сайтов вы можете использовать", + "billingUsersInfo": "Сколько пользователей вы можете использовать", + "billingDomainInfo": "Сколько доменов вы можете использовать", + "billingRemoteExitNodesInfo": "Сколько удаленных узлов вы можете использовать", + "billingLicenseKeys": "Лицензионные ключи", + "billingLicenseKeysDescription": "Управление подписками на лицензионные ключи", + "billingLicenseSubscription": "Лицензионное соглашение", + "billingInactive": "Неактивный", + "billingLicenseItem": "Элемент лицензии", + "billingQuantity": "Количество", + "billingTotal": "итого", + "billingModifyLicenses": "Изменить лицензию подписки", "domainNotFound": "Домен не найден", "domainNotFoundDescription": "Этот ресурс отключен, так как домен больше не существует в нашей системе. Пожалуйста, установите новый домен для этого ресурса.", "failed": "Ошибка", "createNewOrgDescription": "Создать новую организацию", "organization": "Организация", + "primary": "Первичный", "port": "Порт", "securityKeyManage": "Управление ключами безопасности", "securityKeyDescription": "Добавить или удалить ключи безопасности для аутентификации без пароля", @@ -1403,7 +1499,7 @@ "securityKeyRemoveSuccess": "Ключ безопасности успешно удален", "securityKeyRemoveError": "Не удалось удалить ключ безопасности", "securityKeyLoadError": "Не удалось загрузить ключи безопасности", - "securityKeyLogin": "Продолжить с ключом безопасности", + "securityKeyLogin": "Использовать ключ безопасности", "securityKeyAuthError": "Не удалось аутентифицироваться с ключом безопасности", "securityKeyRecommendation": "Зарегистрируйте резервный ключ безопасности на другом устройстве, чтобы всегда иметь доступ к вашему аккаунту.", "registering": "Регистрация...", @@ -1459,11 +1555,47 @@ "resourcePortRequired": "Номер порта необходим для не-HTTP ресурсов", "resourcePortNotAllowed": "Номер порта не должен быть установлен для HTTP ресурсов", "billingPricingCalculatorLink": "Калькулятор расценок", + "billingYourPlan": "Ваш план", + "billingViewOrModifyPlan": "Просмотреть или изменить ваш текущий тариф", + "billingViewPlanDetails": "Подробности плана", + "billingUsageAndLimits": "Использование и ограничения", + "billingViewUsageAndLimits": "Просмотр лимитов и текущего использования вашего плана", + "billingCurrentUsage": "Текущее использование", + "billingMaximumLimits": "Максимальные ограничения", + "billingRemoteNodes": "Удаленные узлы", + "billingUnlimited": "Неограниченный", + "billingPaidLicenseKeys": "Платные лицензионные ключи", + "billingManageLicenseSubscription": "Управление подпиской на платные лицензионные ключи собственного хостинга", + "billingCurrentKeys": "Текущие ключи", + "billingModifyCurrentPlan": "Изменить текущий план", + "billingConfirmUpgrade": "Подтвердить обновление", + "billingConfirmDowngrade": "Подтверждение понижения", + "billingConfirmUpgradeDescription": "Вы собираетесь обновить тарифный план. Проверьте новые лимиты и цены ниже.", + "billingConfirmDowngradeDescription": "Вы собираетесь понизить тарифный план. Проверьте новые ограничения и цены ниже.", + "billingPlanIncludes": "Включает план", + "billingProcessing": "Обработка...", + "billingConfirmUpgradeButton": "Подтвердить обновление", + "billingConfirmDowngradeButton": "Подтверждение понижения", + "billingLimitViolationWarning": "Превышено количество новых лимитов плана", + "billingLimitViolationDescription": "Ваше текущее использование превышает лимиты этого плана. После понижения значения все действия будут отключены до уменьшения использования в пределах новых лимитов. Пожалуйста, ознакомьтесь с функциями, которые в настоящее время превышают лимиты. Ограничения:", + "billingFeatureLossWarning": "Уведомление о доступности функций", + "billingFeatureLossDescription": "При переходе на другой тарифный план функции не будут автоматически отключены. Некоторые настройки и конфигурации могут быть потеряны. Пожалуйста, ознакомьтесь с матрицей ценообразования, чтобы понять, какие функции больше не будут доступны.", + "billingUsageExceedsLimit": "Текущее использование ({current}) превышает предел ({limit})", + "billingPastDueTitle": "Платеж просрочен", + "billingPastDueDescription": "Ваш платеж просрочен. Пожалуйста, обновите способ оплаты, чтобы продолжить использовать текущие функции. Если ваша подписка не будет решена, она будет отменена, и вы вернетесь к бесплатному уровню.", + "billingUnpaidTitle": "Подписка не оплачена", + "billingUnpaidDescription": "Ваша подписка не оплачена, и вы были возвращены к бесплатному уровню. Пожалуйста, обновите способ оплаты, чтобы восстановить вашу подписку.", + "billingIncompleteTitle": "Платеж не завершен", + "billingIncompleteDescription": "Ваш платеж не завершен. Пожалуйста, завершите процесс оплаты, чтобы активировать вашу подписку.", + "billingIncompleteExpiredTitle": "Платеж просрочен", + "billingIncompleteExpiredDescription": "Ваш платеж не был завершен и истек. Вы были возвращены к бесплатному уровню. Пожалуйста, подпишитесь снова, чтобы восстановить доступ к платным функциям.", + "billingManageSubscription": "Управление подпиской", + "billingResolvePaymentIssue": "Пожалуйста, решите проблему оплаты перед обновлением или понижением сорта", "signUpTerms": { "IAgreeToThe": "Я согласен с", "termsOfService": "условия использования", "and": "и", - "privacyPolicy": "политика конфиденциальности" + "privacyPolicy": "политика конфиденциальности." }, "signUpMarketing": { "keepMeInTheLoop": "Держите меня в цикле с новостями, обновлениями и новыми функциями по электронной почте." @@ -1508,6 +1640,7 @@ "addNewTarget": "Добавить новую цель", "targetsList": "Список целей", "advancedMode": "Расширенный режим", + "advancedSettings": "Расширенные настройки", "targetErrorDuplicateTargetFound": "Обнаружена дублирующаяся цель", "healthCheckHealthy": "Здоровый", "healthCheckUnhealthy": "Нездоровый", @@ -1529,6 +1662,26 @@ "IntervalSeconds": "Интервал здоровых состояний", "timeoutSeconds": "Таймаут (сек)", "timeIsInSeconds": "Время указано в секундах", + "requireDeviceApproval": "Требовать подтверждения устройства", + "requireDeviceApprovalDescription": "Пользователям с этой ролью нужны новые устройства, одобренные администратором, прежде чем они смогут подключаться и получать доступ к ресурсам.", + "sshAccess": "SSH доступ", + "roleAllowSsh": "Разрешить SSH", + "roleAllowSshAllow": "Разрешить", + "roleAllowSshDisallow": "Запретить", + "roleAllowSshDescription": "Разрешить пользователям с этой ролью подключаться к ресурсам через SSH. Если отключено, роль не может использовать доступ SSH.", + "sshSudoMode": "Sudo доступ", + "sshSudoModeNone": "Нет", + "sshSudoModeNoneDescription": "Пользователь не может запускать команды с sudo.", + "sshSudoModeFull": "Полная судо", + "sshSudoModeFullDescription": "Пользователь может запускать любую команду с помощью sudo.", + "sshSudoModeCommands": "Команды", + "sshSudoModeCommandsDescription": "Пользователь может запускать только указанные команды с помощью sudo.", + "sshSudo": "Разрешить sudo", + "sshSudoCommands": "Sudo Команды", + "sshSudoCommandsDescription": "Список команд, разделенных запятыми, которые пользователю разрешено запускать с помощью sudo.", + "sshCreateHomeDir": "Создать домашний каталог", + "sshUnixGroups": "Unix группы", + "sshUnixGroupsDescription": "Группы Unix через запятую, чтобы добавить пользователя на целевой хост.", "retryAttempts": "Количество попыток повторного запроса", "expectedResponseCodes": "Ожидаемые коды ответов", "expectedResponseCodesDescription": "HTTP-код состояния, указывающий на здоровое состояние. Если оставить пустым, 200-300 считается здоровым.", @@ -1569,6 +1722,8 @@ "resourcesTableNoInternalResourcesFound": "Внутренних ресурсов не найдено.", "resourcesTableDestination": "Пункт назначения", "resourcesTableAlias": "Alias", + "resourcesTableAliasAddress": "Псевдоним адреса", + "resourcesTableAliasAddressInfo": "Этот адрес является частью вспомогательной подсети организации. Он используется для разрешения псевдонимов с использованием внутреннего разрешения DNS.", "resourcesTableClients": "Клиенты", "resourcesTableAndOnlyAccessibleInternally": "и доступны только внутренне при подключении с клиентом.", "resourcesTableNoTargets": "Нет ярлыков", @@ -1616,9 +1771,8 @@ "createInternalResourceDialogResourceProperties": "Свойства ресурса", "createInternalResourceDialogName": "Имя", "createInternalResourceDialogSite": "Сайт", - "createInternalResourceDialogSelectSite": "Выберите сайт...", - "createInternalResourceDialogSearchSites": "Поиск сайтов...", - "createInternalResourceDialogNoSitesFound": "Сайты не найдены.", + "selectSite": "Выберите сайт...", + "noSitesFound": "Сайты не найдены.", "createInternalResourceDialogProtocol": "Протокол", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", @@ -1658,7 +1812,7 @@ "siteAddressDescription": "Внутренний адрес сайта. Должен находиться в подсети организации.", "siteNameDescription": "Отображаемое имя сайта, которое может быть изменено позже.", "autoLoginExternalIdp": "Автоматический вход с внешним провайдером", - "autoLoginExternalIdpDescription": "Немедленно перенаправьте пользователя к внешнему провайдеру для аутентификации.", + "autoLoginExternalIdpDescription": "Немедленно перенаправьте пользователя к внешнему поставщику удостоверений для аутентификации.", "selectIdp": "Выберите провайдера", "selectIdpPlaceholder": "Выберите провайдера...", "selectIdpRequired": "Пожалуйста, выберите провайдера, когда автоматический вход включен.", @@ -1670,7 +1824,7 @@ "autoLoginErrorNoRedirectUrl": "URL-адрес перенаправления не получен от провайдера удостоверения.", "autoLoginErrorGeneratingUrl": "Не удалось сгенерировать URL-адрес аутентификации.", "remoteExitNodeManageRemoteExitNodes": "Удаленные узлы", - "remoteExitNodeDescription": "Самохост-один или несколько удаленных узлов для расширения сетевого соединения и уменьшения зависимости от облака", + "remoteExitNodeDescription": "Самостоятельно размещайте свои удаленные ретрансляторы и узлы прокси-сервера", "remoteExitNodes": "Узлы", "searchRemoteExitNodes": "Поиск узлов...", "remoteExitNodeAdd": "Добавить узел", @@ -1680,20 +1834,22 @@ "remoteExitNodeConfirmDelete": "Подтвердите удаление узла", "remoteExitNodeDelete": "Удалить узел", "sidebarRemoteExitNodes": "Удаленные узлы", + "remoteExitNodeId": "ID", + "remoteExitNodeSecretKey": "Секретный ключ", "remoteExitNodeCreate": { - "title": "Создать узел", - "description": "Создать новый узел для расширения сетевого подключения", + "title": "Создать удалённый узел", + "description": "Создайте новый самостоятельный удалённый ретранслятор и узел прокси-сервера", "viewAllButton": "Все узлы", "strategy": { "title": "Стратегия создания", - "description": "Выберите эту опцию для настройки узла или создания новых учетных данных.", + "description": "Выберите способ создания удалённого узла", "adopt": { "title": "Принять узел", "description": "Выберите это, если у вас уже есть учетные данные для узла." }, "generate": { "title": "Сгенерировать ключи", - "description": "Выберите это, если вы хотите создать новые ключи для узла" + "description": "Выберите это, если вы хотите создать новые ключи для узла." } }, "adopt": { @@ -1840,9 +1996,33 @@ "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "Подсеть", "subnetDescription": "Подсеть для конфигурации сети этой организации.", - "authPage": "Страница авторизации", - "authPageDescription": "Настроить страницу авторизации для организации", + "customDomain": "Пользовательский домен", + "authPage": "Страницы аутентификации", + "authPageDescription": "Установите пользовательский домен для страниц аутентификации организации", "authPageDomain": "Домен страницы авторизации", + "authPageBranding": "Пользовательское брендирование", + "authPageBrandingDescription": "Настройте брендирование, отображаемое на страницах аутентификации для этой организации", + "authPageBrandingUpdated": "Брендирование страницы аутентификации успешно обновлено", + "authPageBrandingRemoved": "Брендирование страницы аутентификации успешно удалено", + "authPageBrandingRemoveTitle": "Удалить брендирование страницы аутентификации", + "authPageBrandingQuestionRemove": "Вы уверены, что хотите удалить брендирование для страниц аутентификации?", + "authPageBrandingDeleteConfirm": "Подтвердить удаление брендирования", + "brandingLogoURL": "URL логотипа", + "brandingLogoURLOrPath": "URL логотипа или путь", + "brandingLogoPathDescription": "Введите URL или локальный путь.", + "brandingLogoURLDescription": "Введите публичный URL для изображения вашего логотипа.", + "brandingPrimaryColor": "Основной цвет", + "brandingLogoWidth": "Ширина (px)", + "brandingLogoHeight": "Высота (px)", + "brandingOrgTitle": "Заголовок для страницы аутентификации организации", + "brandingOrgDescription": "{orgName} будет заменен названием организации", + "brandingOrgSubtitle": "Подзаголовок страницы аутентификации организации", + "brandingResourceTitle": "Заголовок для страницы аутентификации ресурса", + "brandingResourceSubtitle": "Подзаголовок страницы аутентификации ресурса", + "brandingResourceDescription": "{resourceName} будет заменено на имя организации", + "saveAuthPageDomain": "Сохранить домен", + "saveAuthPageBranding": "Сохранить брендирование", + "removeAuthPageBranding": "Удалить брендирование", "noDomainSet": "Домен не установлен", "changeDomain": "Изменить домен", "selectDomain": "Выберите домен", @@ -1851,7 +2031,7 @@ "setAuthPageDomain": "Установить домен страницы авторизации", "failedToFetchCertificate": "Не удалось получить сертификат", "failedToRestartCertificate": "Не удалось перезапустить сертификат", - "addDomainToEnableCustomAuthPages": "Добавить домен для включения пользовательских страниц аутентификации для организации", + "addDomainToEnableCustomAuthPages": "Пользователи смогут получить доступ к странице входа в систему организации и завершить аутентификацию ресурса, используя этот домен.", "selectDomainForOrgAuthPage": "Выберите домен для страницы аутентификации организации", "domainPickerProvidedDomain": "Домен предоставлен", "domainPickerFreeProvidedDomain": "Бесплатный домен", @@ -1866,11 +2046,27 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" не может быть действительным для {domain}.", "domainPickerSubdomainSanitized": "Субдомен очищен", "domainPickerSubdomainCorrected": "\"{sub}\" был исправлен на \"{sanitized}\"", - "orgAuthSignInTitle": "Войти в организацию", + "orgAuthSignInTitle": "Вход в организацию", "orgAuthChooseIdpDescription": "Выберите своего поставщика удостоверений личности для продолжения", "orgAuthNoIdpConfigured": "Эта организация не имеет настроенных поставщиков идентификационных данных. Вместо этого вы можете войти в свой Pangolin.", "orgAuthSignInWithPangolin": "Войти через Pangolin", + "orgAuthSignInToOrg": "Войти в организацию", + "orgAuthSelectOrgTitle": "Вход в организацию", + "orgAuthSelectOrgDescription": "Введите ID вашей организации, чтобы продолжить", + "orgAuthOrgIdPlaceholder": "ваша-организация", + "orgAuthOrgIdHelp": "Введите уникальный идентификатор вашей организации", + "orgAuthSelectOrgHelp": "После ввода ID вашей организации вы попадете на страницу входа в вашу организацию, где сможете использовать SSO или учетные данные вашей организации.", + "orgAuthRememberOrgId": "Запомнить этот ID организации", + "orgAuthBackToSignIn": "Вернуться к стандартному входу", + "orgAuthNoAccount": "Нет учётной записи?", "subscriptionRequiredToUse": "Для использования этой функции требуется подписка.", + "mustUpgradeToUse": "Вы должны обновить подписку, чтобы использовать эту функцию.", + "subscriptionRequiredTierToUse": "Эта функция требует {tier} или выше.", + "upgradeToTierToUse": "Обновитесь до {tier} или выше, чтобы использовать эту функцию.", + "subscriptionTierTier1": "Главная", + "subscriptionTierTier2": "Команда", + "subscriptionTierTier3": "Бизнес", + "subscriptionTierEnterprise": "Предприятие", "idpDisabled": "Провайдеры идентификации отключены.", "orgAuthPageDisabled": "Страница авторизации организации отключена.", "domainRestartedDescription": "Проверка домена успешно перезапущена", @@ -1884,6 +2080,8 @@ "enableTwoFactorAuthentication": "Включить двухфакторную аутентификацию", "completeSecuritySteps": "Пройти шаги безопасности", "securitySettings": "Настройки безопасности", + "dangerSection": "Опасная зона", + "dangerSectionDescription": "Навсегда удалить все данные, связанные с этой организацией", "securitySettingsDescription": "Настройка политик безопасности для организации", "requireTwoFactorForAllUsers": "Требовать двухфакторную аутентификацию для всех пользователей", "requireTwoFactorDescription": "Когда включено, все внутренние пользователи в этой организации должны иметь двухфакторную аутентификацию для доступа к организации.", @@ -1921,7 +2119,7 @@ "securityPolicyChangeWarningText": "Это повлияет на всех пользователей организации", "authPageErrorUpdateMessage": "Произошла ошибка при обновлении настроек страницы авторизации", "authPageErrorUpdate": "Не удалось обновить страницу авторизации", - "authPageUpdated": "Страница авторизации успешно обновлена", + "authPageDomainUpdated": "Домен страницы аутентификации успешно обновлён", "healthCheckNotAvailable": "Локальный", "rewritePath": "Переписать путь", "rewritePathDescription": "При необходимости, измените путь перед пересылкой к целевому адресу.", @@ -1949,8 +2147,15 @@ "beta": "Бета", "manageUserDevices": "Устройства пользователя", "manageUserDevicesDescription": "Просмотр и управление устройствами, которые пользователи используют для приватного подключения к ресурсам", + "downloadClientBannerTitle": "Скачать клиент Pangolin", + "downloadClientBannerDescription": "Загрузите клиент Pangolin для вашей системы, чтобы подключиться к сети Pangolin и получить доступ к ресурсам в частном порядке.", "manageMachineClients": "Управление машинными клиентами", "manageMachineClientsDescription": "Создание и управление клиентами, которые используют серверы и системы для частного подключения к ресурсам", + "machineClientsBannerTitle": "Серверы и автоматизированные системы", + "machineClientsBannerDescription": "Клиенты для машин предназначены для серверов и автоматизированных систем, которые не связаны с конкретным пользователем. Они аутентифицируются по ID и секрету и могут работать с Pangolin CLI, Olm CLI или Olm как с контейнером.", + "machineClientsBannerPangolinCLI": "Pangolin CLI", + "machineClientsBannerOlmCLI": "Olm CLI", + "machineClientsBannerOlmContainer": "Olm как контейнер", "clientsTableUserClients": "Пользователь", "clientsTableMachineClients": "Машина", "licenseTableValidUntil": "Действителен до", @@ -2049,6 +2254,32 @@ } } }, + "newPricingLicenseForm": { + "title": "Получить лицензию", + "description": "Выберите план и расскажите нам, как вы планируете использовать Панголин.", + "chooseTier": "Выберите ваш план", + "viewPricingLink": "Смотрите цены, возможности и ограничения", + "tiers": { + "starter": { + "title": "Старт", + "description": "Функции предприятия, 25 пользователей, 25 сайтов, и поддержка сообщества." + }, + "scale": { + "title": "Масштаб", + "description": "Функции предприятия, 50 пользователей, 50 сайтов, а также приоритетная поддержка." + } + }, + "personalUseOnly": "Только для личного пользования (бесплатная лицензия — без оформления)", + "buttons": { + "continueToCheckout": "Продолжить оформление заказа" + }, + "toasts": { + "checkoutError": { + "title": "Ошибка оформления заказа", + "description": "Не удалось начать оформление заказа. Пожалуйста, попробуйте еще раз." + } + } + }, "priority": "Приоритет", "priorityDescription": "Маршруты с более высоким приоритетом оцениваются первым. Приоритет = 100 означает автоматическое упорядочение (решение системы). Используйте другой номер для обеспечения ручного приоритета.", "instanceName": "Имя экземпляра", @@ -2094,13 +2325,15 @@ "request": "Запросить", "requests": "Запросы", "logs": "Логи", - "logsSettingsDescription": "Отслеживать журналы, собранные в этой организации", + "logsSettingsDescription": "Мониторинг журналов, собранных от этой организации", "searchLogs": "Поиск журналов...", "action": "Действие", "actor": "Актер", "timestamp": "Отметка времени", "accessLogs": "Журналы доступа", "exportCsv": "Экспорт CSV", + "exportError": "Неизвестная ошибка при экспорте CSV", + "exportCsvTooltip": "В пределах диапазона времени", "actorId": "ID актера", "allowedByRule": "Разрешено правилом", "allowedNoAuth": "Разрешено без авторизации", @@ -2145,7 +2378,8 @@ "logRetentionEndOfFollowingYear": "Конец следующего года", "actionLogsDescription": "Просмотр истории действий, выполненных в этой организации", "accessLogsDescription": "Просмотр запросов авторизации доступа к ресурсам этой организации", - "licenseRequiredToUse": "Для использования этой функции требуется лицензия предприятия.", + "licenseRequiredToUse": "Требуется лицензия на Enterprise Edition или Pangolin Cloud для использования этой функции. Забронируйте демонстрацию или пробный POC.", + "ossEnterpriseEditionRequired": "Enterprise Edition требуется для использования этой функции. Эта функция также доступна в Pangolin Cloud. Забронируйте демонстрацию или пробный POC.", "certResolver": "Резольвер сертификата", "certResolverDescription": "Выберите резолвер сертификата, который будет использоваться для этого ресурса.", "selectCertResolver": "Выберите резолвер сертификата", @@ -2154,7 +2388,7 @@ "unverified": "Не подтверждено", "domainSetting": "Настройки домена", "domainSettingDescription": "Настройка параметров домена", - "preferWildcardCertDescription": "Попытка создания шаблона сертификата (требуется должным образом сконфигурированный резолвер сертификата).", + "preferWildcardCertDescription": "Попытка создать сертификат с подстановочными знаками (требуется правильно настроенное средство разрешения сертификатов).", "recordName": "Имя записи", "auto": "Авто", "TTL": "TTL", @@ -2206,6 +2440,8 @@ "deviceCodeInvalidFormat": "Код должен быть 9 символов (например, A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Неверный или просроченный код", "deviceCodeVerifyFailed": "Не удалось проверить код устройства", + "deviceCodeValidating": "Проверка кода устройства...", + "deviceCodeVerifying": "Проверка авторизации устройства...", "signedInAs": "Вы вошли как", "deviceCodeEnterPrompt": "Введите код, отображаемый на устройстве", "continue": "Продолжить", @@ -2218,7 +2454,7 @@ "deviceOrganizationsAccess": "Доступ ко всем организациям, к которым ваш аккаунт имеет доступ", "deviceAuthorize": "Авторизовать {applicationName}", "deviceConnected": "Устройство подключено!", - "deviceAuthorizedMessage": "Устройство авторизовано для доступа к вашей учетной записи.", + "deviceAuthorizedMessage": "Устройство авторизовано для доступа к вашей учетной записи. Вернитесь в клиентское приложение.", "pangolinCloud": "Облако Панголина", "viewDevices": "Просмотр устройств", "viewDevicesDescription": "Управление подключенными устройствами", @@ -2280,6 +2516,7 @@ "identifier": "Identifier", "deviceLoginUseDifferentAccount": "Не вы? Используйте другую учетную запись.", "deviceLoginDeviceRequestingAccessToAccount": "Устройство запрашивает доступ к этой учетной записи.", + "loginSelectAuthenticationMethod": "Выберите метод аутентификации для продолжения.", "noData": "Нет данных", "machineClients": "Машинные клиенты", "install": "Установить", @@ -2289,6 +2526,8 @@ "setupFailedToFetchSubnet": "Не удалось получить подсеть по умолчанию", "setupSubnetAdvanced": "Подсеть (Дополнительно)", "setupSubnetDescription": "Подсеть для внутренней сети этой организации.", + "setupUtilitySubnet": "Утилита подсети (расширенная)", + "setupUtilitySubnetDescription": "Подсеть для адресов псевдонимов этой организации и DNS-сервера.", "siteRegenerateAndDisconnect": "Сгенерировать и отключить", "siteRegenerateAndDisconnectConfirmation": "Вы уверены, что хотите сгенерировать учетные данные и отключить этот сайт?", "siteRegenerateAndDisconnectWarning": "Это позволит восстановить учетные данные и немедленно отключить сайт. Сайт будет перезапущен с новыми учетными данными.", @@ -2304,5 +2543,179 @@ "remoteExitNodeRegenerateAndDisconnectWarning": "Это позволит восстановить учётные данные и немедленно отключить удаленный узел выхода. Удаленный узел выхода должен быть перезапущен с новыми учетными данными.", "remoteExitNodeRegenerateCredentialsConfirmation": "Вы уверены, что хотите восстановить учетные данные для этого удаленного выхода узла?", "remoteExitNodeRegenerateCredentialsWarning": "Это позволит восстановить учетные данные. Удалённый узел останется подключенным, пока вы не перезапустите его вручную и воспользуетесь новыми учетными данными.", - "agent": "Агент" + "agent": "Агент", + "personalUseOnly": "Только для личного использования", + "loginPageLicenseWatermark": "Это экземпляр лицензирован только для личного использования.", + "instanceIsUnlicensed": "Этот экземпляр не лицензирован.", + "portRestrictions": "Ограничения портов", + "allPorts": "Все", + "custom": "Пользовательский", + "allPortsAllowed": "Все порты разрешены", + "allPortsBlocked": "Все порты заблокированы", + "tcpPortsDescription": "Укажите, какие TCP-порты разрешены для этого ресурса. Используйте '*' для всех портов, оставьте пустым, чтобы заблокировать все, или введите список портов и диапазонов через запятую (например, 80,443,8000-9000).", + "udpPortsDescription": "Укажите, какие UDP-порты разрешены для этого ресурса. Используйте '*' для всех портов, оставьте пустым, чтобы заблокировать все, или введите список портов и диапазонов через запятую (например, 53,123,500-600).", + "organizationLoginPageTitle": "Страница входа в систему организации", + "organizationLoginPageDescription": "Настройте страницу входа для этой организации", + "resourceLoginPageTitle": "Страница входа в систему ресурса", + "resourceLoginPageDescription": "Настройте страницу входа для отдельных ресурсов", + "enterConfirmation": "Введите подтверждение", + "blueprintViewDetails": "Подробности", + "defaultIdentityProvider": "Поставщик удостоверений по умолчанию", + "defaultIdentityProviderDescription": "Когда выбран поставщик идентификации по умолчанию, пользователь будет автоматически перенаправлен на провайдер для аутентификации.", + "editInternalResourceDialogNetworkSettings": "Настройки сети", + "editInternalResourceDialogAccessPolicy": "Политика доступа", + "editInternalResourceDialogAddRoles": "Добавить роли", + "editInternalResourceDialogAddUsers": "Добавить пользователей", + "editInternalResourceDialogAddClients": "Добавить клиентов", + "editInternalResourceDialogDestinationLabel": "Пункт назначения", + "editInternalResourceDialogDestinationDescription": "Укажите адрес назначения для внутреннего ресурса. Это может быть имя хоста, IP-адрес или диапазон CIDR в зависимости от выбранного режима. При необходимости установите внутренний DNS-алиас для облегчения идентификации.", + "editInternalResourceDialogPortRestrictionsDescription": "Ограничьте доступ к определенным TCP/UDP-портам или разрешите/заблокируйте все порты.", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "Контроль доступа", + "editInternalResourceDialogAccessControlDescription": "Контролируйте, какие роли, пользователи и машинные клиенты имеют доступ к этому ресурсу при подключении. Администраторы всегда имеют доступ.", + "editInternalResourceDialogPortRangeValidationError": "Диапазон портов должен быть \"*\" для всех портов или списком портов и диапазонов через запятую (например, \"80,443,8000-9000\"). Порты должны находиться в диапазоне от 1 до 65535.", + "internalResourceAuthDaemonStrategy": "Местоположение демона по SSH", + "internalResourceAuthDaemonStrategyDescription": "Выберите, где работает демон аутентификации SSH: на сайте (Newt) или на удаленном узле.", + "internalResourceAuthDaemonDescription": "Демон аутентификации SSH обрабатывает подписание ключей SSH и аутентификацию PAM для этого ресурса. Выберите, запускать ли его на сайте (Newt) или на отдельном удаленном хосте. Подробности смотрите в документации.", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "Выберите стратегию", + "internalResourceAuthDaemonStrategyLabel": "Местоположение", + "internalResourceAuthDaemonSite": "На сайте", + "internalResourceAuthDaemonSiteDescription": "На сайте работает демон Auth (Newt).", + "internalResourceAuthDaemonRemote": "Удаленный хост", + "internalResourceAuthDaemonRemoteDescription": "Демон Auth запускается на хост, который не является сайтом.", + "internalResourceAuthDaemonPort": "Порт демона (опционально)", + "orgAuthWhatsThis": "Где я могу найти ID моей организации?", + "learnMore": "Узнать больше", + "backToHome": "Вернуться домой", + "needToSignInToOrg": "Нужно использовать провайдера идентификаций вашей организации?", + "maintenanceMode": "Режим обслуживания", + "maintenanceModeDescription": "Показать страницу обслуживания посетителям", + "maintenanceModeType": "Тип режима обслуживания", + "showMaintenancePage": "Показать страницу обслуживания посетителям", + "enableMaintenanceMode": "Включить режим обслуживания", + "automatic": "Автоматический", + "automaticModeDescription": "Показывать страницу обслуживания только когда все цели бэкэнда недоступны или неисправны. Ваш ресурс продолжит работать нормально, пока хотя бы одна цель здорова.", + "forced": "Принудительно", + "forcedModeDescription": "Всегда показывать страницу обслуживания независимо от состояния бэкэнда. Используйте это для планового обслуживания, когда хотите предотвратить всех доступ.", + "warning:": "Предупреждение:", + "forcedeModeWarning": "Весь трафик будет направлен на страницу обслуживания. Ваши бекэнд ресурсы не будут получать никакие запросы.", + "pageTitle": "Заголовок страницы", + "pageTitleDescription": "Основной заголовок, отображаемый на странице обслуживания", + "maintenancePageMessage": "Сообщение об обслуживании", + "maintenancePageMessagePlaceholder": "Мы скоро вернемся! Наш сайт в настоящее время проходит плановое техническое обслуживание.", + "maintenancePageMessageDescription": "Подробное сообщение, объясняющее обслуживание", + "maintenancePageTimeTitle": "Предполагаемое время завершения (необязательно)", + "maintenanceTime": "например, 2 часа, 1 ноября в 5:00 вечера", + "maintenanceEstimatedTimeDescription": "Когда вы ожидаете завершения обслуживания", + "editDomain": "Редактировать домен", + "editDomainDescription": "Выберите домен для вашего ресурса", + "maintenanceModeDisabledTooltip": "Для использования этой функции требуется действующая лицензия.", + "maintenanceScreenTitle": "Сервис временно недоступен", + "maintenanceScreenMessage": "В настоящее время мы испытываем технические трудности. Пожалуйста, зайдите позже.", + "maintenanceScreenEstimatedCompletion": "Предполагаемое завершение:", + "createInternalResourceDialogDestinationRequired": "Укажите адрес назначения. Это может быть имя хоста или IP-адрес.", + "available": "Доступно", + "archived": "Архивировано", + "noArchivedDevices": "Архивные устройства не найдены", + "deviceArchived": "Устройство архивировано", + "deviceArchivedDescription": "Устройство успешно архивировано.", + "errorArchivingDevice": "Ошибка архивирования устройства", + "failedToArchiveDevice": "Не удалось архивировать устройство", + "deviceQuestionArchive": "Вы уверены, что хотите архивировать это устройство?", + "deviceMessageArchive": "Устройство будет архивировано и удалено из вашего списка активных устройств.", + "deviceArchiveConfirm": "Архивировать устройство", + "archiveDevice": "Архивировать устройство", + "archive": "Архивировать", + "deviceUnarchived": "Устройство разархивировано", + "deviceUnarchivedDescription": "Устройство было успешно разархивировано.", + "errorUnarchivingDevice": "Ошибка разархивирования устройства", + "failedToUnarchiveDevice": "Не удалось распаковать устройство", + "unarchive": "Разархивировать", + "archiveClient": "Архивировать клиента", + "archiveClientQuestion": "Вы уверены, что хотите архивировать этого клиента?", + "archiveClientMessage": "Клиент будет архивирован и удален из вашего активного списка клиентов.", + "archiveClientConfirm": "Архивировать клиента", + "blockClient": "Блокировать клиента", + "blockClientQuestion": "Вы уверены, что хотите заблокировать этого клиента?", + "blockClientMessage": "Устройство будет вынуждено отключиться, если подключено в данный момент. Вы можете разблокировать устройство позже.", + "blockClientConfirm": "Блокировать клиента", + "active": "Активный", + "usernameOrEmail": "Имя пользователя или Email", + "selectYourOrganization": "Выберите вашу организацию", + "signInTo": "Войти в", + "signInWithPassword": "Продолжить с паролем", + "noAuthMethodsAvailable": "Методы аутентификации для этой организации недоступны.", + "enterPassword": "Введите ваш пароль", + "enterMfaCode": "Введите код из вашего приложения-аутентификатора", + "securityKeyRequired": "Пожалуйста, используйте ваш защитный ключ для входа.", + "needToUseAnotherAccount": "Нужно использовать другой аккаунт?", + "loginLegalDisclaimer": "Нажимая на кнопки ниже, вы подтверждаете, что прочитали, поняли и согласны с Условиями использования и Политикой конфиденциальности.", + "termsOfService": "Условия предоставления услуг", + "privacyPolicy": "Политика конфиденциальности", + "userNotFoundWithUsername": "Пользователь с таким именем пользователя не найден.", + "verify": "Подтвердить", + "signIn": "Войти", + "forgotPassword": "Забыли пароль?", + "orgSignInTip": "Если вы вошли в систему ранее, вы можете ввести имя пользователя или адрес электронной почты, чтобы войти в систему с поставщиком идентификации вашей организации. Это проще!", + "continueAnyway": "Все равно продолжить", + "dontShowAgain": "Больше не показывать", + "orgSignInNotice": "Знаете ли вы?", + "signupOrgNotice": "Пытаетесь войти?", + "signupOrgTip": "Вы пытаетесь войти через оператора идентификации вашей организации?", + "signupOrgLink": "Войдите или зарегистрируйтесь через вашу организацию", + "verifyEmailLogInWithDifferentAccount": "Использовать другую учетную запись", + "logIn": "Войти", + "deviceInformation": "Информация об устройстве", + "deviceInformationDescription": "Информация о устройстве и агенте", + "deviceSecurity": "Безопасность устройства", + "deviceSecurityDescription": "Информация о позе безопасности устройства", + "platform": "Платформа", + "macosVersion": "Версия macOS", + "windowsVersion": "Версия Windows", + "iosVersion": "Версия iOS", + "androidVersion": "Версия Android", + "osVersion": "Версия ОС", + "kernelVersion": "Версия ядра", + "deviceModel": "Модель устройства", + "serialNumber": "Серийный номер", + "hostname": "Hostname", + "firstSeen": "Первый раз виден", + "lastSeen": "Последнее посещение", + "biometricsEnabled": "Включены биометрические данные", + "diskEncrypted": "Диск зашифрован", + "firewallEnabled": "Брандмауэр включен", + "autoUpdatesEnabled": "Автоматические обновления включены", + "tpmAvailable": "Доступно TPM", + "windowsAntivirusEnabled": "Антивирус включен", + "macosSipEnabled": "Защита целостности системы (SIP)", + "macosGatekeeperEnabled": "Gatekeeper", + "macosFirewallStealthMode": "Стилс-режим брандмауэра", + "linuxAppArmorEnabled": "Броня", + "linuxSELinuxEnabled": "SELinux", + "deviceSettingsDescription": "Просмотр информации и настроек устройства", + "devicePendingApprovalDescription": "Это устройство ожидает одобрения", + "deviceBlockedDescription": "Это устройство заблокировано. Оно не сможет подключаться к ресурсам, если не разблокировано.", + "unblockClient": "Разблокировать клиента", + "unblockClientDescription": "Устройство разблокировано", + "unarchiveClient": "Разархивировать клиента", + "unarchiveClientDescription": "Устройство было разархивировано", + "block": "Блок", + "unblock": "Разблокировать", + "deviceActions": "Действия устройства", + "deviceActionsDescription": "Управление статусом устройства и доступом", + "devicePendingApprovalBannerDescription": "Это устройство ожидает одобрения. Он не сможет подключиться к ресурсам до утверждения.", + "connected": "Подключено", + "disconnected": "Отключено", + "approvalsEmptyStateTitle": "Утверждения устройства не включены", + "approvalsEmptyStateDescription": "Включите одобрение ролей для того, чтобы пользователи могли подключать новые устройства.", + "approvalsEmptyStateStep1Title": "Перейти к ролям", + "approvalsEmptyStateStep1Description": "Перейдите в настройки ролей вашей организации для настройки утверждений устройств.", + "approvalsEmptyStateStep2Title": "Включить утверждения устройства", + "approvalsEmptyStateStep2Description": "Редактировать роль и включить опцию 'Требовать утверждения устройств'. Пользователям с этой ролью потребуется подтверждение администратора для новых устройств.", + "approvalsEmptyStatePreviewDescription": "Предпросмотр: Если включено, ожидающие запросы на устройство появятся здесь для проверки", + "approvalsEmptyStateButtonText": "Управление ролями", + "domainErrorTitle": "У нас возникли проблемы с проверкой вашего домена" } diff --git a/messages/tr-TR.json b/messages/tr-TR.json index 7119808a8..2bfed7fb3 100644 --- a/messages/tr-TR.json +++ b/messages/tr-TR.json @@ -1,5 +1,7 @@ { "setupCreate": "Organizasyonu, siteyi ve kaynakları oluşturun", + "headerAuthCompatibilityInfo": "Kimlik doğrulama belirteci eksik olduğunda 401 Yetkisiz yanıtı zorlamak için bunu etkinleştirin. Bu, sunucu sorunu olmadan kimlik bilgilerini göndermeyen tarayıcılar veya belirli HTTP kütüphaneleri için gereklidir.", + "headerAuthCompatibility": "Genişletilmiş Uyumluluk", "setupNewOrg": "Yeni Organizasyon", "setupCreateOrg": "Organizasyon Oluştur", "setupCreateResources": "Kaynaklar Oluştur", @@ -16,6 +18,8 @@ "componentsMember": "{count, plural, =0 {hiçbir organizasyon} one {bir organizasyon} other {# organizasyon}} üyesisiniz.", "componentsInvalidKey": "Geçersiz veya süresi dolmuş lisans anahtarları tespit edildi. Tüm özellikleri kullanmaya devam etmek için lisans koşullarına uyun.", "dismiss": "Kapat", + "subscriptionViolationMessage": "Geçerli planınız için limitlerinizi aştınız. Planınız dahilinde kalmak için siteleri, kullanıcıları veya diğer kaynakları kaldırarak sorunu düzeltin.", + "subscriptionViolationViewBilling": "Faturalamayı görüntüle", "componentsLicenseViolation": "Lisans İhlali: Bu sunucu, lisanslı sınırı olan {maxSites} sitesini aşarak {usedSites} site kullanmaktadır. Tüm özellikleri kullanmaya devam etmek için lisans koşullarına uyun.", "componentsSupporterMessage": "Pangolin'e {tier} olarak destek olduğunuz için teşekkür ederiz!", "inviteErrorNotValid": "Üzgünüz, ancak erişmeye çalıştığınız davet kabul edilmemiş veya artık geçerli değil gibi görünüyor.", @@ -33,7 +37,7 @@ "password": "Şifre", "confirmPassword": "Şifreyi Onayla", "createAccount": "Hesap Oluştur", - "viewSettings": "Ayarları görüntüle", + "viewSettings": "Ayarları Görüntüle", "delete": "Sil", "name": "Ad", "online": "Çevrimiçi", @@ -51,6 +55,12 @@ "siteQuestionRemove": "Siteyi organizasyondan kaldırmak istediğinizden emin misiniz?", "siteManageSites": "Siteleri Yönet", "siteDescription": "Özel ağlara erişimi etkinleştirmek için siteler oluşturun ve yönetin", + "sitesBannerTitle": "Herhangi Bir Ağa Bağlan", + "sitesBannerDescription": "Bir site, Pangolin'in kullanıcılara, halka açık veya özel kaynaklara, her yerden erişim sağlamak için uzak bir ağa bağlantı sunmasıdır. Site ağı bağlantısını (Newt) çalıştırabileceğiniz her yere kurarak bağlantıyı kurunuz.", + "sitesBannerButtonText": "Site Kur", + "approvalsBannerTitle": "Cihaz Erişimini Onayla veya Reddet", + "approvalsBannerDescription": "Kullanıcılardan gelen cihaz erişim isteklerini gözden geçirin ve onaylayın veya reddedin. Cihaz onaylarının gerekli olduğu durumlarda, kullanıcıların cihazlarının kuruluşunuzun kaynaklarına bağlanabilmesi için yönetici onayı alması gerekecektir.", + "approvalsBannerButtonText": "Daha fazla bilgi", "siteCreate": "Site Oluştur", "siteCreateDescription2": "Yeni bir site oluşturup bağlanmak için aşağıdaki adımları izleyin", "siteCreateDescription": "Kaynaklarınızı bağlamaya başlamak için yeni bir site oluşturun", @@ -100,6 +110,7 @@ "siteTunnelDescription": "Sitenize nasıl bağlanmak istediğinizi belirleyin", "siteNewtCredentials": "Kimlik Bilgileri", "siteNewtCredentialsDescription": "Bu, sitenin sunucu ile kimlik doğrulaması yapacağı yöntemdir", + "remoteNodeCredentialsDescription": "Uzak düğümün sunucu ile kimliği nasıl doğrulayacağı budur", "siteCredentialsSave": "Kimlik Bilgilerinizi Kaydedin", "siteCredentialsSaveDescription": "Yalnızca bir kez görebileceksiniz. Güvenli bir yere kopyaladığınızdan emin olun.", "siteInfo": "Site Bilgilendirmesi", @@ -146,8 +157,12 @@ "shareErrorSelectResource": "Lütfen bir kaynak seçin", "proxyResourceTitle": "Herkese Açık Kaynakları Yönet", "proxyResourceDescription": "Bir web tarayıcısı aracılığıyla kamuya açık kaynaklar oluşturun ve yönetin", + "proxyResourcesBannerTitle": "Web Tabanlı Genel Erişim", + "proxyResourcesBannerDescription": "Genel kaynaklar, web tarayıcısı aracılığıyla herkesin internette erişebileceği HTTPS veya TCP/UDP proxy'leridir. Özel kaynakların aksine, istemci tarafı yazılıma ihtiyaç duymazlar ve kimlik ve bağlam farkındalığı erişim politikalarını içerebilirler.", "clientResourceTitle": "Özel Kaynakları Yönet", "clientResourceDescription": "Sadece bağlı bir istemci aracılığıyla erişilebilen kaynakları oluşturun ve yönetin", + "privateResourcesBannerTitle": "Sıfır Güven Özel Erişim", + "privateResourcesBannerDescription": "Özel kaynaklar sıfır güven güvenliği kullanır, kullanıcılar ve makinelerin yalnızca açıkça izin verdiğiniz kaynaklara erişmesini sağlar. Bu kaynaklara güvenli bir sanal özel ağ üzerinden erişmek için kullanıcı cihazlarını veya makine müşterilerini bağlayın.", "resourcesSearch": "Kaynakları ara...", "resourceAdd": "Kaynak Ekle", "resourceErrorDelte": "Kaynak silinirken hata", @@ -157,9 +172,10 @@ "resourceMessageRemove": "Kaldırıldıktan sonra kaynak artık erişilebilir olmayacaktır. Kaynakla ilişkili tüm hedefler de kaldırılacaktır.", "resourceQuestionRemove": "Kaynağı organizasyondan kaldırmak istediğinizden emin misiniz?", "resourceHTTP": "HTTPS Kaynağı", - "resourceHTTPDescription": "Bir alt alan adı veya temel alan adı kullanarak uygulamanıza HTTPS üzerinden vekil istek gönderin.", + "resourceHTTPDescription": "Tam nitelikli bir etki alanı adı kullanarak HTTPS üzerinden proxy isteklerini yönlendirin.", "resourceRaw": "Ham TCP/UDP Kaynağı", - "resourceRawDescription": "Uygulamanıza TCP/UDP üzerinden port numarası ile vekil istek gönderin.", + "resourceRawDescription": "Port numarası kullanarak ham TCP/UDP üzerinden proxy isteklerini yönlendirin.", + "resourceRawDescriptionCloud": "Proxy isteklerini bir port numarası kullanarak ham TCP/UDP üzerinden yapın. Sitelerin uzak bir düğüme bağlanması gereklidir.", "resourceCreate": "Kaynak Oluştur", "resourceCreateDescription": "Yeni bir kaynak oluşturmak için aşağıdaki adımları izleyin", "resourceSeeAll": "Tüm Kaynakları Gör", @@ -186,6 +202,7 @@ "protocolSelect": "Bir protokol seçin", "resourcePortNumber": "Port Numarası", "resourcePortNumberDescription": "Vekil istekler için harici port numarası.", + "back": "Geri", "cancel": "İptal", "resourceConfig": "Yapılandırma Parçaları", "resourceConfigDescription": "TCP/UDP kaynağınızı kurmak için bu yapılandırma parçalarını kopyalayıp yapıştırın", @@ -231,6 +248,17 @@ "orgErrorDeleteMessage": "Organizasyon silinirken bir hata oluştu.", "orgDeleted": "Organizasyon silindi", "orgDeletedMessage": "Organizasyon ve verileri silindi.", + "deleteAccount": "Hesabı Sil", + "deleteAccountDescription": "Hesabınızı, sahip olduğunuz tüm organizasyonları ve bu organizasyonlardaki tüm verileri kalıcı olarak silin. Bu geri alınamaz.", + "deleteAccountButton": "Hesabı Sil", + "deleteAccountConfirmTitle": "Hesabı Sil", + "deleteAccountConfirmMessage": "Bu işlem, hesabınızı, sahip olduğunuz tüm organizasyonları ve bu organizasyonlardaki tüm verileri kalıcı olarak silecektir. Bu geri alınamaz.", + "deleteAccountConfirmString": "hesabı sil", + "deleteAccountSuccess": "Hesap Silindi", + "deleteAccountSuccessMessage": "Hesabınız silindi.", + "deleteAccountError": "Hesabı silme başarısız oldu", + "deleteAccountPreviewAccount": "Hesabınız", + "deleteAccountPreviewOrgs": "Sahip olduğunuz organizasyonlar (ve tüm verileri)", "orgMissing": "Organizasyon Kimliği Eksik", "orgMissingMessage": "Organizasyon kimliği olmadan daveti yeniden oluşturmanız mümkün değildir.", "accessUsersManage": "Kullanıcıları Yönet", @@ -247,6 +275,8 @@ "accessRolesSearch": "Rolleri ara...", "accessRolesAdd": "Rol Ekle", "accessRoleDelete": "Rolü Sil", + "accessApprovalsManage": "Onayları Yönet", + "accessApprovalsDescription": "Bu kuruluşa erişim için bekleyen onayları görüntüleyin ve yönetin", "description": "Açıklama", "inviteTitle": "Açık Davetiyeler", "inviteDescription": "Organizasyona katılmak için diğer kullanıcılar için davetleri yönetin", @@ -440,6 +470,20 @@ "selectDuration": "Süreyi seçin", "selectResource": "Kaynak Seçin", "filterByResource": "Kaynağa Göre Filtrele", + "selectApprovalState": "Onay Durumunu Seçin", + "filterByApprovalState": "Onay Durumuna Göre Filtrele", + "approvalListEmpty": "Onay yok", + "approvalState": "Onay Durumu", + "approvalLoadMore": "Daha fazla yükle", + "loadingApprovals": "Onaylar Yükleniyor", + "approve": "Onayla", + "approved": "Onaylandı", + "denied": "Reddedildi", + "deniedApproval": "Reddedilen Onay", + "all": "Tümü", + "deny": "Reddet", + "viewDetails": "Ayrıntıları Gör", + "requestingNewDeviceApproval": "yeni bir cihaz talep etti", "resetFilters": "Filtreleri Sıfırla", "totalBlocked": "Pangolin Tarafından Engellenen İstekler", "totalRequests": "Toplam İstekler", @@ -607,6 +651,7 @@ "resourcesErrorUpdate": "Kaynak değiştirilemedi", "resourcesErrorUpdateDescription": "Kaynak güncellenirken bir hata oluştu", "access": "Erişim", + "accessControl": "Erişim Kontrolü", "shareLink": "{resource} Paylaşım Bağlantısı", "resourceSelect": "Kaynak seçin", "shareLinks": "Paylaşım Bağlantıları", @@ -687,7 +732,7 @@ "resourceRoleDescription": "Yöneticiler her zaman bu kaynağa erişebilir.", "resourceUsersRoles": "Erişim Kontrolleri", "resourceUsersRolesDescription": "Bu kaynağı kimlerin ziyaret edebileceği kullanıcıları ve rolleri yapılandırın", - "resourceUsersRolesSubmit": "Kullanıcıları ve Rolleri Kaydet", + "resourceUsersRolesSubmit": "Erişim Kontrollerini Kaydet", "resourceWhitelistSave": "Başarıyla kaydedildi", "resourceWhitelistSaveDescription": "Beyaz liste ayarları kaydedildi", "ssoUse": "Platform SSO'sunu Kullanın", @@ -719,11 +764,23 @@ "countries": "Ülkeler", "accessRoleCreate": "Rol Oluştur", "accessRoleCreateDescription": "Kullanıcıları gruplamak ve izinlerini yönetmek için yeni bir rol oluşturun.", + "accessRoleEdit": "Rol Düzenle", + "accessRoleEditDescription": "Rol bilgilerini düzenleyin.", "accessRoleCreateSubmit": "Rol Oluştur", "accessRoleCreated": "Rol oluşturuldu", "accessRoleCreatedDescription": "Rol başarıyla oluşturuldu.", "accessRoleErrorCreate": "Rol oluşturulamadı", "accessRoleErrorCreateDescription": "Rol oluşturulurken bir hata oluştu.", + "accessRoleUpdateSubmit": "Rolü Güncelle", + "accessRoleUpdated": "Rol güncellendi", + "accessRoleUpdatedDescription": "Rol başarıyla güncellendi.", + "accessApprovalUpdated": "Onay işlendi", + "accessApprovalApprovedDescription": "Onay İsteği kararını onaylandı olarak ayarlayın.", + "accessApprovalDeniedDescription": "Onay İsteği kararını reddedildi olarak ayarlayın.", + "accessRoleErrorUpdate": "Rol güncellenemedi", + "accessRoleErrorUpdateDescription": "Rol güncellenirken bir hata oluştu.", + "accessApprovalErrorUpdate": "Onay işlenemedi", + "accessApprovalErrorUpdateDescription": "Onay işlenirken bir hata oluştu.", "accessRoleErrorNewRequired": "Yeni rol gerekli", "accessRoleErrorRemove": "Rol kaldırılamadı", "accessRoleErrorRemoveDescription": "Rol kaldırılırken bir hata oluştu.", @@ -735,6 +792,7 @@ "accessRoleRemoved": "Rol kaldırıldı", "accessRoleRemovedDescription": "Rol başarıyla kaldırıldı.", "accessRoleRequiredRemove": "Bu rolü silmeden önce, mevcut üyeleri aktarmak için yeni bir rol seçin.", + "network": "Ağ", "manage": "Yönet", "sitesNotFound": "Site bulunamadı.", "pangolinServerAdmin": "Sunucu Yöneticisi - Pangolin", @@ -750,6 +808,9 @@ "sitestCountIncrease": "Site sayısını artır", "idpManage": "Kimlik Sağlayıcılarını Yönet", "idpManageDescription": "Sistem içindeki kimlik sağlayıcıları görün ve yönetin", + "idpGlobalModeBanner": "Bu sunucuda örgüt başına kimlik sağlayıcılar (IdP'ler) devre dışı bırakılmıştır. Tüm örgütler arasında paylaşılan küresel IdP'leri kullanıyor. Küresel IdP'leri yönetici panelinde yönetin. Örgüt başına IdP'leri etkinleştirmek için, sunucu yapılandırmasını düzenleyin ve IdP modunu 'org' olarak ayarlayın. Belgeleri inceleyin . Küresel IdP'leri kullanmaya devam etmek istiyorsanız ve bunun örgüt ayarlarından kaybolmasını istiyorsanız, yapılandırmada modu otomatik olarak 'global' olarak ayarlayın.", + "idpGlobalModeBannerUpgradeRequired": "Bu sunucuda örgüt başına kimlik sağlayıcılar (IdP'ler) devre dışı bırakılmıştır. Tüm örgütler arasında paylaşılan küresel IdP'leri kullanıyor. Küresel IdP'leri yönetici panelinde yönetin. Örgüt başına kimlik sağlayıcılar kullanmak için, Enterprise sürümüne yükseltmeniz gerekmektedir.", + "idpGlobalModeBannerLicenseRequired": "Bu sunucuda örgüt başına kimlik sağlayıcılar (IdP'ler) devre dışı bırakılmıştır. Tüm örgütler arasında paylaşılan küresel IdP'leri kullanıyor. Küresel IdP'leri yönetici panelinde yönetin. Örgüt başına kimlik sağlayıcılar kullanmak için Enterprise lisansı gereklidir.", "idpDeletedDescription": "Kimlik sağlayıcı başarıyla silindi", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "Kimlik sağlayıcısını kalıcı olarak silmek istediğinizden emin misiniz?", @@ -840,6 +901,7 @@ "orgPolicyConfig": "Bir kuruluş için erişimi yapılandırın", "idpUpdatedDescription": "Kimlik sağlayıcı başarıyla güncellendi", "redirectUrl": "Yönlendirme URL'si", + "orgIdpRedirectUrls": "Yönlendirme URL'leri", "redirectUrlAbout": "Yönlendirme URL'si Hakkında", "redirectUrlAboutDescription": "Bu, kimlik doğrulamasından sonra kullanıcıların yönlendirileceği URL'dir. Bu URL'yi kimlik sağlayıcınızın ayarlarında yapılandırmanız gerekir.", "pangolinAuth": "Yetkilendirme - Pangolin", @@ -863,7 +925,7 @@ "inviteAlready": "Davetiye gönderilmiş gibi görünüyor!", "inviteAlreadyDescription": "Daveti kabul etmek için giriş yapmalı veya bir hesap oluşturmalısınız.", "signupQuestion": "Zaten bir hesabınız var mı?", - "login": "Giriş yap", + "login": "Giriş Yap", "resourceNotFound": "No resources found", "resourceNotFoundDescription": "Erişmeye çalıştığınız kaynak mevcut değil.", "pincodeRequirementsLength": "PIN kesinlikle 6 haneli olmalıdır", @@ -945,11 +1007,11 @@ "pincodeAuth": "Kimlik Doğrulama Kodu", "pincodeSubmit2": "Kodu Gönder", "passwordResetSubmit": "Sıfırlama İsteği", - "passwordResetAlreadyHaveCode": "Parola Sıfırlama Kodunu Giriniz", + "passwordResetAlreadyHaveCode": "Kodu Girin", "passwordResetSmtpRequired": "Yönetici ile iletişime geçin", "passwordResetSmtpRequiredDescription": "Parolanızı sıfırlamak için bir parola sıfırlama kodu gereklidir. Yardım için yönetici ile iletişime geçin.", "passwordBack": "Şifreye Geri Dön", - "loginBack": "Girişe geri dön", + "loginBack": "Ana oturum açma sayfasına geri dön", "signup": "Kaydol", "loginStart": "Başlamak için giriş yapın", "idpOidcTokenValidating": "OIDC token'ı doğrulanıyor", @@ -972,12 +1034,12 @@ "pangolinSetup": "Kurulum - Pangolin", "orgNameRequired": "Kuruluş adı gereklidir", "orgIdRequired": "Kuruluş ID gereklidir", + "orgIdMaxLength": "Organizasyon kimliği en fazla 32 karakter olmalıdır", "orgErrorCreate": "Kuruluş oluşturulurken bir hata oluştu", "pageNotFound": "Sayfa Bulunamadı", "pageNotFoundDescription": "Oops! Aradığınız sayfa mevcut değil.", "overview": "Genel Bakış", "home": "Ana Sayfa", - "accessControl": "Erişim Kontrolü", "settings": "Ayarlar", "usersAll": "Tüm Kullanıcılar", "license": "Lisans", @@ -1035,15 +1097,24 @@ "updateOrgUser": "Organizasyon Kullanıcısını Güncelle", "createOrgUser": "Organizasyon Kullanıcısı Oluştur", "actionUpdateOrg": "Kuruluşu Güncelle", + "actionRemoveInvitation": "Daveti Kaldır", "actionUpdateUser": "Kullanıcıyı Güncelle", "actionGetUser": "Kullanıcıyı Getir", "actionGetOrgUser": "Kuruluş Kullanıcısını Al", "actionListOrgDomains": "Kuruluş Alan Adlarını Listele", + "actionGetDomain": "Alan Adını Al", + "actionCreateOrgDomain": "Alan Adı Oluştur", + "actionUpdateOrgDomain": "Alan Adını Güncelle", + "actionDeleteOrgDomain": "Alan Adını Sil", + "actionGetDNSRecords": "DNS Kayıtlarını Al", + "actionRestartOrgDomain": "Alanı Yeniden Başlat", "actionCreateSite": "Site Oluştur", "actionDeleteSite": "Siteyi Sil", "actionGetSite": "Siteyi Al", "actionListSites": "Siteleri Listele", "actionApplyBlueprint": "Planı Uygula", + "actionListBlueprints": "Plan Listesini Görüntüle", + "actionGetBlueprint": "Planı Elde Et", "setupToken": "Kurulum Simgesi", "setupTokenDescription": "Sunucu konsolundan kurulum simgesini girin.", "setupTokenRequired": "Kurulum simgesi gerekli", @@ -1077,6 +1148,7 @@ "actionRemoveUser": "Kullanıcıyı Kaldır", "actionListUsers": "Kullanıcıları Listele", "actionAddUserRole": "Kullanıcı Rolü Ekle", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "Erişim Jetonu Oluştur", "actionDeleteAccessToken": "Erişim Jetonunu Sil", "actionListAccessTokens": "Erişim Jetonlarını Listele", @@ -1104,6 +1176,10 @@ "actionUpdateIdpOrg": "Kimlik Sağlayıcı Organizasyonu Güncelle", "actionCreateClient": "Müşteri Oluştur", "actionDeleteClient": "Müşteri Sil", + "actionArchiveClient": "İstemci Arşivle", + "actionUnarchiveClient": "İstemci Arşivini Kaldır", + "actionBlockClient": "İstemci Engelle", + "actionUnblockClient": "İstemci Engelini Kaldır", "actionUpdateClient": "Müşteri Güncelle", "actionListClients": "Müşterileri Listele", "actionGetClient": "Müşteriyi Al", @@ -1117,17 +1193,18 @@ "actionViewLogs": "Kayıtları Görüntüle", "noneSelected": "Hiçbiri seçili değil", "orgNotFound2": "Hiçbir organizasyon bulunamadı.", - "searchProgress": "Ara...", + "searchPlaceholder": "Ara...", + "emptySearchOptions": "Seçenek bulunamadı", "create": "Oluştur", "orgs": "Organizasyonlar", - "loginError": "Giriş yaparken bir hata oluştu", - "loginRequiredForDevice": "Cihazınızı kimlik doğrulamak için giriş yapılması gereklidir.", + "loginError": "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin.", + "loginRequiredForDevice": "Cihazınız için oturum açmanız gerekiyor.", "passwordForgot": "Şifrenizi mi unuttunuz?", "otpAuth": "İki Faktörlü Kimlik Doğrulama", "otpAuthDescription": "Authenticator uygulamanızdan veya tek kullanımlık yedek kodlarınızdan birini girin.", "otpAuthSubmit": "Kodu Gönder", "idpContinue": "Veya devam et:", - "otpAuthBack": "Girişe Dön", + "otpAuthBack": "Şifreye Geri Dön", "navbar": "Navigasyon Menüsü", "navbarDescription": "Uygulamanın ana navigasyon menüsü", "navbarDocsLink": "Dokümantasyon", @@ -1175,11 +1252,13 @@ "sidebarOverview": "Genel Bakış", "sidebarHome": "Ana Sayfa", "sidebarSites": "Siteler", + "sidebarApprovals": "Onay Talepleri", "sidebarResources": "Kaynaklar", "sidebarProxyResources": "Herkese Açık", "sidebarClientResources": "Özel", "sidebarAccessControl": "Erişim Kontrolü", "sidebarLogsAndAnalytics": "Kayıtlar & Analitik", + "sidebarTeam": "Ekip", "sidebarUsers": "Kullanıcılar", "sidebarAdmin": "Yönetici", "sidebarInvitations": "Davetiye", @@ -1191,13 +1270,15 @@ "sidebarIdentityProviders": "Kimlik Sağlayıcılar", "sidebarLicense": "Lisans", "sidebarClients": "İstemciler", - "sidebarUserDevices": "Kullanıcılar", + "sidebarUserDevices": "Kullanıcı Cihazları", "sidebarMachineClients": "Makineler", "sidebarDomains": "Alan Adları", - "sidebarGeneral": "Genel", + "sidebarGeneral": "Yönet", "sidebarLogAndAnalytics": "Kayıt & Analiz", "sidebarBluePrints": "Planlar", "sidebarOrganization": "Organizasyon", + "sidebarManagement": "Yönetim", + "sidebarBillingAndLicenses": "Faturalandırma & Lisanslar", "sidebarLogsAnalytics": "Analitik", "blueprints": "Planlar", "blueprintsDescription": "Deklaratif yapılandırmaları uygulayın ve önceki çalışmaları görüntüleyin", @@ -1219,7 +1300,6 @@ "parsedContents": "Verilerin Ayrıştırılmış İçeriği (Salt Okunur)", "enableDockerSocket": "Docker Soketini Etkinleştir", "enableDockerSocketDescription": "Plan etiketleri için Docker Socket etiket toplamasını etkinleştirin. Newt'e soket yolu sağlanmalıdır.", - "enableDockerSocketLink": "Daha fazla bilgi", "viewDockerContainers": "Docker Konteynerlerini Görüntüle", "containersIn": "{siteName} içindeki konteynerler", "selectContainerDescription": "Bu hedef için bir ana bilgisayar adı olarak kullanmak üzere herhangi bir konteyner seçin. Bir bağlantı noktası kullanmak için bir bağlantı noktasına tıklayın.", @@ -1263,6 +1343,7 @@ "setupErrorCreateAdmin": "Sunucu yönetici hesabı oluşturulurken bir hata oluştu.", "certificateStatus": "Sertifika Durumu", "loading": "Yükleniyor", + "loadingAnalytics": "Analiz Yükleniyor", "restart": "Yeniden Başlat", "domains": "Alan Adları", "domainsDescription": "Organizasyonda kullanılabilir alan adlarını oluşturun ve yönetin", @@ -1290,6 +1371,7 @@ "refreshError": "Veriler yenilenemedi", "verified": "Doğrulandı", "pending": "Beklemede", + "pendingApproval": "Bekleyen Onay", "sidebarBilling": "Faturalama", "billing": "Faturalama", "orgBillingDescription": "Fatura bilgilerinizi ve aboneliklerinizi yönetin", @@ -1308,8 +1390,11 @@ "accountSetupSuccess": "Hesap kurulumu tamamlandı! Pangolin'e hoş geldiniz!", "documentation": "Dokümantasyon", "saveAllSettings": "Tüm Ayarları Kaydet", + "saveResourceTargets": "Hedefleri Kaydet", + "saveResourceHttp": "Proxy Ayarlarını Kaydet", + "saveProxyProtocol": "Proxy protokol ayarlarını kaydet", "settingsUpdated": "Ayarlar güncellendi", - "settingsUpdatedDescription": "Tüm ayarlar başarıyla güncellendi", + "settingsUpdatedDescription": "Ayarlar başarıyla güncellendi", "settingsErrorUpdate": "Ayarlar güncellenemedi", "settingsErrorUpdateDescription": "Ayarları güncellerken bir hata oluştu", "sidebarCollapse": "Daralt", @@ -1342,6 +1427,7 @@ "domainPickerNamespace": "Ad Alanı: {namespace}", "domainPickerShowMore": "Daha Fazla Göster", "regionSelectorTitle": "Bölge Seç", + "domainPickerRemoteExitNodeWarning": "Belirtilen alan adları, siteler uzak çıkış düğümlerine bağlandığında desteklenmez. Kaynakların uzak düğümlerde kullanılabilir olması için özel bir alan adı kullanın.", "regionSelectorInfo": "Bir bölge seçmek, konumunuz için daha iyi performans sağlamamıza yardımcı olur. Sunucunuzla aynı bölgede olmanıza gerek yoktur.", "regionSelectorPlaceholder": "Bölge Seçin", "regionSelectorComingSoon": "Yakında Geliyor", @@ -1351,10 +1437,11 @@ "billingUsageLimitsOverview": "Kullanım Limitleri Genel Görünümü", "billingMonitorUsage": "Kullanımınızı yapılandırılmış limitlerle karşılaştırın. Limitlerin artırılmasına ihtiyacınız varsa, lütfen support@pangolin.net adresinden bizimle iletişime geçin.", "billingDataUsage": "Veri Kullanımı", - "billingOnlineTime": "Site Çevrimiçi Süresi", - "billingUsers": "Aktif Kullanıcılar", - "billingDomains": "Aktif Alanlar", - "billingRemoteExitNodes": "Aktif Öz-Host Düğümleri", + "billingSites": "Siteler", + "billingUsers": "Kullanıcılar", + "billingDomains": "Alan Adları", + "billingOrganizations": "Organizasyonlar", + "billingRemoteExitNodes": "Uzak Düğümler", "billingNoLimitConfigured": "Hiçbir limit yapılandırılmadı", "billingEstimatedPeriod": "Tahmini Fatura Dönemi", "billingIncludedUsage": "Dahil Kullanım", @@ -1379,15 +1466,24 @@ "billingFailedToGetPortalUrl": "Portal URL'si alınamadı", "billingPortalError": "Portal Hatası", "billingDataUsageInfo": "Buluta bağlandığınızda, güvenli tünellerinizden aktarılan tüm verilerden ücret alınırsınız. Bu, tüm sitelerinizdeki gelen ve giden trafiği içerir. Limitinize ulaştığınızda, planınızı yükseltmeli veya kullanımı azaltmalısınız, aksi takdirde siteleriniz bağlantıyı keser. Düğümler kullanırken verilerden ücret alınmaz.", - "billingOnlineTimeInfo": "Sitelerinizin buluta ne kadar süre bağlı kaldığına göre ücretlendirilirsiniz. Örneğin, 44,640 dakika, bir sitenin 24/7 boyunca tam bir ay boyunca çalışması anlamına gelir. Limitinize ulaştığınızda, planınızı yükseltmeyip kullanımı azaltmazsanız siteleriniz bağlantıyı keser. Düğümler kullanırken zamandan ücret alınmaz.", - "billingUsersInfo": "Kuruluşunuzdaki her kullanıcı için ücretlendirilirsiniz. Faturalandırma, organizasyonunuza kayıtlı aktif kullanıcı hesaplarının sayısına göre günlük olarak hesaplanır.", - "billingDomainInfo": "Kuruluşunuzdaki her alan adı için ücretlendirilirsiniz. Faturalandırma, organizasyonunuza kayıtlı aktif alan adları hesaplarının sayısına göre günlük olarak hesaplanır.", - "billingRemoteExitNodesInfo": "Kuruluşunuzdaki her yönetilen Düğüm için ücretlendirilirsiniz. Faturalandırma, organizasyonunuza kayıtlı aktif yönetilen Düğümler sayısına göre günlük olarak hesaplanır.", + "billingSInfo": "Kaç tane site kullanabileceğiniz", + "billingUsersInfo": "Kaç tane kullanıcı kullanabileceğiniz", + "billingDomainInfo": "Kaç tane alan adı kullanabileceğiniz", + "billingRemoteExitNodesInfo": "Kaç tane uzaktan düğüm kullanabileceğiniz", + "billingLicenseKeys": "Lisans Anahtarları", + "billingLicenseKeysDescription": "Lisans anahtarı aboneliklerinizi yönetin", + "billingLicenseSubscription": "Lisans Aboneliği", + "billingInactive": "Pasif", + "billingLicenseItem": "Lisans Öğesi", + "billingQuantity": "Miktar", + "billingTotal": "toplam", + "billingModifyLicenses": "Lisans Aboneliğini Düzenle", "domainNotFound": "Alan Adı Bulunamadı", "domainNotFoundDescription": "Bu kaynak devre dışıdır çünkü alan adı sistemimizde artık mevcut değil. Bu kaynak için yeni bir alan adı belirleyin.", "failed": "Başarısız", "createNewOrgDescription": "Yeni bir organizasyon oluşturun", "organization": "Kuruluş", + "primary": "Birincil", "port": "Bağlantı Noktası", "securityKeyManage": "Güvenlik Anahtarlarını Yönet", "securityKeyDescription": "Şifresiz kimlik doğrulama için güvenlik anahtarları ekleyin veya kaldırın", @@ -1403,7 +1499,7 @@ "securityKeyRemoveSuccess": "Güvenlik anahtarı başarıyla kaldırıldı", "securityKeyRemoveError": "Güvenlik anahtarı kaldırılırken hata oluştu", "securityKeyLoadError": "Güvenlik anahtarları yüklenirken hata oluştu", - "securityKeyLogin": "Güvenlik anahtarı ile devam edin", + "securityKeyLogin": "Güvenlik Anahtarı Kullan", "securityKeyAuthError": "Güvenlik anahtarı ile kimlik doğrulama başarısız oldu", "securityKeyRecommendation": "Hesabınızdan kilitlenmediğinizden emin olmak için farklı bir cihazda başka bir güvenlik anahtarı kaydetmeyi düşünün.", "registering": "Kaydediliyor...", @@ -1459,11 +1555,47 @@ "resourcePortRequired": "HTTP dışı kaynaklar için bağlantı noktası numarası gereklidir", "resourcePortNotAllowed": "HTTP kaynakları için bağlantı noktası numarası ayarlanmamalı", "billingPricingCalculatorLink": "Fiyat Hesaplayıcı", + "billingYourPlan": "Planınız", + "billingViewOrModifyPlan": "Mevcut planınızı görüntüleyin veya düzenleyin", + "billingViewPlanDetails": "Plan Detaylarını Görüntüle", + "billingUsageAndLimits": "Kullanım ve Sınırlar", + "billingViewUsageAndLimits": "Planınızın limitlerini ve mevcut kullanım durumunu görüntüleyin", + "billingCurrentUsage": "Mevcut Kullanım", + "billingMaximumLimits": "Maksimum Sınırlar", + "billingRemoteNodes": "Uzak Düğümler", + "billingUnlimited": "Sınırsız", + "billingPaidLicenseKeys": "Ücretli Lisans Anahtarları", + "billingManageLicenseSubscription": "Kendi barındırdığınız ücretli lisans anahtarları için aboneliğinizi yönetin", + "billingCurrentKeys": "Mevcut Anahtarlar", + "billingModifyCurrentPlan": "Mevcut Planı Düzenle", + "billingConfirmUpgrade": "Yükseltmeyi Onayla", + "billingConfirmDowngrade": "Düşürmeyi Onayla", + "billingConfirmUpgradeDescription": "Planınızı yükseltmek üzeresiniz. Yeni limitleri ve fiyatları aşağıda inceleyin.", + "billingConfirmDowngradeDescription": "Planınızı düşürmek üzeresiniz. Yeni limitleri ve fiyatları aşağıda inceleyin.", + "billingPlanIncludes": "Plan İçerikleri", + "billingProcessing": "İşleniyor...", + "billingConfirmUpgradeButton": "Yükseltmeyi Onayla", + "billingConfirmDowngradeButton": "Düşürmeyi Onayla", + "billingLimitViolationWarning": "Kullanım Yeni Plan Sınırlarını Aşıyor", + "billingLimitViolationDescription": "Mevcut kullanımınız bu planın sınırlarını aşıyor. Düzeltmelerden sonra, yeni sınırlar içinde kalana kadar tüm işlemler devre dışı bırakılacak. Lütfen şu anda limitlerin üzerinde olan özellikleri inceleyin. İhlal edilen sınırlar:", + "billingFeatureLossWarning": "Özellik Kullanılabilirlik Bildirimi", + "billingFeatureLossDescription": "Plan düşürüldüğünde, yeni planda mevcut olmayan özellikler otomatik olarak devre dışı bırakılacaktır. Bazı ayarlar ve yapılar kaybolabilir. Hangi özelliklerin artık mevcut olmayacağını anlamak için fiyat tablosunu inceleyiniz.", + "billingUsageExceedsLimit": "Mevcut kullanım ({current}) limitleri ({limit}) aşıyor", + "billingPastDueTitle": "Ödeme Geçmiş", + "billingPastDueDescription": "Ödemenizın vadesi geçti. Mevcut plan özelliklerinizi kullanmaya devam etmek için lütfen ödeme yöntemini güncelleyin. Sorun çözülmezse aboneliğiniz iptal edilecek ve ücretsiz seviyeye dönüleceksiniz.", + "billingUnpaidTitle": "Ödenmemiş Abonelik", + "billingUnpaidDescription": "Aboneliğiniz ödenmedi ve ücretsiz seviyeye geri döndünüz. Aboneliğinizi geri yüklemek için lütfen ödeme yöntemini güncelleyin.", + "billingIncompleteTitle": "Eksik Ödeme", + "billingIncompleteDescription": "Ödemeniz eksik. Aboneliğinizi etkinleştirmek için lütfen ödeme sürecini tamamlayın.", + "billingIncompleteExpiredTitle": "Ödeme Süresi Doldu", + "billingIncompleteExpiredDescription": "Ödemeniz hiç tamamlanmadı ve süresi doldu. Ücretsiz seviyeye geri döndünüz. Ücretli özelliklere erişimi yeniden sağlamak için lütfen yeniden abone olun.", + "billingManageSubscription": "Aboneliğinizi Yönetin", + "billingResolvePaymentIssue": "Yükseltmeden veya düşürmeden önce ödeme sorunuzu çözün", "signUpTerms": { "IAgreeToThe": "Kabul ediyorum", "termsOfService": "hizmet şartları", "and": "ve", - "privacyPolicy": "gizlilik politikası" + "privacyPolicy": "gizlilik politikası." }, "signUpMarketing": { "keepMeInTheLoop": "Bana e-posta yoluyla haberler, güncellemeler ve yeni özellikler hakkında bilgi verin." @@ -1508,6 +1640,7 @@ "addNewTarget": "Yeni Hedef Ekle", "targetsList": "Hedefler Listesi", "advancedMode": "Gelişmiş Mod", + "advancedSettings": "Gelişmiş Ayarlar", "targetErrorDuplicateTargetFound": "Yinelenen hedef bulundu", "healthCheckHealthy": "Sağlıklı", "healthCheckUnhealthy": "Sağlıksız", @@ -1529,6 +1662,26 @@ "IntervalSeconds": "Sağlıklı Aralık", "timeoutSeconds": "Zaman Aşımı (saniye)", "timeIsInSeconds": "Zaman saniye cinsindendir", + "requireDeviceApproval": "Cihaz Onaylarını Gerektir", + "requireDeviceApprovalDescription": "Bu role sahip kullanıcıların yeni cihazlarının bağlanabilmesi ve kaynaklara erişebilmesi için bir yönetici tarafından onaylanması gerekiyor.", + "sshAccess": "SSH Erişimi", + "roleAllowSsh": "SSH'a İzin Ver", + "roleAllowSshAllow": "İzin Ver", + "roleAllowSshDisallow": "İzin Verme", + "roleAllowSshDescription": "Bu role sahip kullanıcıların SSH aracılığıyla kaynaklara bağlanmasına izin verin. Devre dışı bırakıldığında, rol SSH erişimini kullanamaz.", + "sshSudoMode": "Sudo Erişimi", + "sshSudoModeNone": "Hiçbiri", + "sshSudoModeNoneDescription": "Kullanıcı, sudo komutunu kullanarak komut çalıştıramaz.", + "sshSudoModeFull": "Tam Sudo", + "sshSudoModeFullDescription": "Kullanıcı, sudo komutuyla her türlü komutu çalıştırabilir.", + "sshSudoModeCommands": "Komutlar", + "sshSudoModeCommandsDescription": "Kullanıcı sadece belirtilen komutları sudo ile çalıştırabilir.", + "sshSudo": "Sudo'ya izin ver", + "sshSudoCommands": "Sudo Komutları", + "sshSudoCommandsDescription": "Kullanıcının sudo ile çalıştırmasına izin verilen komutların virgülle ayrılmış listesi.", + "sshCreateHomeDir": "Ev Dizini Oluştur", + "sshUnixGroups": "Unix Grupları", + "sshUnixGroupsDescription": "Hedef konakta kullanıcıya eklenecek Unix gruplarının virgülle ayrılmış listesi.", "retryAttempts": "Tekrar Deneme Girişimleri", "expectedResponseCodes": "Beklenen Yanıt Kodları", "expectedResponseCodesDescription": "Sağlıklı durumu gösteren HTTP durum kodu. Boş bırakılırsa, 200-300 arası sağlıklı kabul edilir.", @@ -1569,6 +1722,8 @@ "resourcesTableNoInternalResourcesFound": "Hiçbir dahili kaynak bulunamadı.", "resourcesTableDestination": "Hedef", "resourcesTableAlias": "Takma Ad", + "resourcesTableAliasAddress": "Alias Adresi", + "resourcesTableAliasAddressInfo": "Bu adres, kuruluşun yardımcı ağ alt bantının bir parçasıdır. Alias kayıtlarını çözümlemek için dahili DNS çözümlemesi kullanılır.", "resourcesTableClients": "İstemciler", "resourcesTableAndOnlyAccessibleInternally": "veyalnızca bir istemci ile bağlandığında dahili olarak erişilebilir.", "resourcesTableNoTargets": "Hedef yok", @@ -1616,9 +1771,8 @@ "createInternalResourceDialogResourceProperties": "Kaynak Özellikleri", "createInternalResourceDialogName": "Ad", "createInternalResourceDialogSite": "Site", - "createInternalResourceDialogSelectSite": "Site seç...", - "createInternalResourceDialogSearchSites": "Siteleri ara...", - "createInternalResourceDialogNoSitesFound": "Site bulunamadı.", + "selectSite": "Site seç...", + "noSitesFound": "Site bulunamadı.", "createInternalResourceDialogProtocol": "Protokol", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", @@ -1658,7 +1812,7 @@ "siteAddressDescription": "Site için dahili adres. Organizasyon alt ağı içinde olmalıdır.", "siteNameDescription": "Sonradan değiştirilebilecek sitenin görünen adı.", "autoLoginExternalIdp": "Harici IDP ile Otomatik Giriş", - "autoLoginExternalIdpDescription": "Kullanıcıyı kimlik doğrulama için otomatik olarak harici IDP'ye yönlendirin.", + "autoLoginExternalIdpDescription": "Kullanıcıyı kimlik doğrulama için hemen harici kimlik sağlayıcısına yönlendirin.", "selectIdp": "IDP Seç", "selectIdpPlaceholder": "IDP seçin...", "selectIdpRequired": "Otomatik giriş etkinleştirildiğinde lütfen bir IDP seçin.", @@ -1670,7 +1824,7 @@ "autoLoginErrorNoRedirectUrl": "Kimlik sağlayıcıdan yönlendirme URL'si alınamadı.", "autoLoginErrorGeneratingUrl": "Kimlik doğrulama URL'si oluşturulamadı.", "remoteExitNodeManageRemoteExitNodes": "Uzak Düğümler", - "remoteExitNodeDescription": "Ağ bağlantınızı genişletmek ve buluta bağlı kalmayı azaltmak için bir veya daha fazla uzak düğüm barındırın", + "remoteExitNodeDescription": "Kendi uzaktan yönetilen ileti ve ara sunucu düğümlerinizi barındırın", "remoteExitNodes": "Düğümler", "searchRemoteExitNodes": "Düğüm ara...", "remoteExitNodeAdd": "Düğüm Ekle", @@ -1680,20 +1834,22 @@ "remoteExitNodeConfirmDelete": "Düğüm Silmeyi Onayla", "remoteExitNodeDelete": "Düğümü Sil", "sidebarRemoteExitNodes": "Uzak Düğümler", + "remoteExitNodeId": "Kimlik", + "remoteExitNodeSecretKey": "Gizli", "remoteExitNodeCreate": { - "title": "Düğüm Oluştur", - "description": "Ağ bağlantınızı genişletmek için yeni bir düğüm oluşturun", + "title": "Uzak Düğüm Oluştur", + "description": "Yeni bir kendine misafir uzaktan ileti ve ara sunucu düğümü oluşturun", "viewAllButton": "Tüm Düğümleri Gör", "strategy": { "title": "Oluşturma Stratejisi", - "description": "Düğümünüzü manuel olarak yapılandırmak veya yeni kimlik bilgileri oluşturmak için bunu seçin.", + "description": "Uzak düğümü nasıl oluşturmak istediğinizi seçin", "adopt": { "title": "Düğüm Benimse", "description": "Zaten düğüm için kimlik bilgilerine sahipseniz bunu seçin." }, "generate": { "title": "Anahtarları Oluştur", - "description": "Düğüm için yeni anahtarlar oluşturmak istiyorsanız bunu seçin" + "description": "Düğüm için yeni anahtarlar oluşturmak istiyorsanız bunu seçin." } }, "adopt": { @@ -1806,9 +1962,33 @@ "idpAzureDescription": "Microsoft Azure OAuth2/OIDC sağlayıcısı", "subnet": "Alt ağ", "subnetDescription": "Bu organizasyonun ağ yapılandırması için alt ağ.", - "authPage": "Yetkilendirme Sayfası", - "authPageDescription": "Kuruluşunuz için yetkilendirme sayfasını yapılandırın", + "customDomain": "Özel Alan", + "authPage": "Kimlik Sayfaları", + "authPageDescription": "Kuruluşun kimlik doğrulama sayfaları için özel bir alan belirleyin", "authPageDomain": "Yetkilendirme Sayfası Alanı", + "authPageBranding": "Özel Marka", + "authPageBrandingDescription": "Bu kuruluşa ait kimlik doğrulama sayfalarında görünecek markayı yapılandırın", + "authPageBrandingUpdated": "Kimlik doğrulama sayfası marka güncellemesi başarıyla tamamlandı", + "authPageBrandingRemoved": "Kimlik doğrulama sayfası marka kaldırıldı", + "authPageBrandingRemoveTitle": "Kimlik Doğrulama Sayfası Markası Kaldır", + "authPageBrandingQuestionRemove": "Kimlik Sayfaları için markayı kaldırmak istediğinizden emin misiniz?", + "authPageBrandingDeleteConfirm": "Markayı Silmeyi Onayla", + "brandingLogoURL": "Logo URL", + "brandingLogoURLOrPath": "Logo URL veya Yol", + "brandingLogoPathDescription": "Bir URL veya yerel bir yol girin.", + "brandingLogoURLDescription": "Logo resminiz için genel olarak erişilebilir bir URL girin.", + "brandingPrimaryColor": "Ana Renk", + "brandingLogoWidth": "Genişlik (px)", + "brandingLogoHeight": "Yükseklik (px)", + "brandingOrgTitle": "Kuruluş Kimlik Sayfası için Başlık", + "brandingOrgDescription": "{orgName} kuruluş adı ile değiştirilecek", + "brandingOrgSubtitle": "Kuruluş Kimlik Sayfası için Alt Başlık", + "brandingResourceTitle": "Kaynak Yetkilendirme Sayfası için Başlık", + "brandingResourceSubtitle": "Kaynak Yetkilendirme Sayfası için Alt Başlık", + "brandingResourceDescription": "{resourceName} organizasyon adıyla değiştirilecek", + "saveAuthPageDomain": "Alan Adını Kaydet", + "saveAuthPageBranding": "Markayı Kaydet", + "removeAuthPageBranding": "Markayı Kaldır", "noDomainSet": "Alan belirlenmedi", "changeDomain": "Alanı Değiştir", "selectDomain": "Alan Seçin", @@ -1817,7 +1997,7 @@ "setAuthPageDomain": "Yetkilendirme Sayfası Alanını Ayarla", "failedToFetchCertificate": "Sertifika getirilemedi", "failedToRestartCertificate": "Sertifika yeniden başlatılamadı", - "addDomainToEnableCustomAuthPages": "Kuruluşunuz için özel kimlik doğrulama sayfalarını etkinleştirmek için bir alan ekleyin", + "addDomainToEnableCustomAuthPages": "Kullanıcılar, bu alanı kullanarak kuruluşun giriş sayfasına erişebilir ve kaynak kimlik doğrulamasını tamamlayabilir.", "selectDomainForOrgAuthPage": "Kuruluşun kimlik doğrulama sayfası için bir alan seçin", "domainPickerProvidedDomain": "Sağlanan Alan Adı", "domainPickerFreeProvidedDomain": "Ücretsiz Sağlanan Alan Adı", @@ -1832,11 +2012,27 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" {domain} için geçerli yapılamadı.", "domainPickerSubdomainSanitized": "Alt alan adı temizlendi", "domainPickerSubdomainCorrected": "\"{sub}\" \"{sanitized}\" olarak düzeltildi", - "orgAuthSignInTitle": "Kuruluşunuza giriş yapın", + "orgAuthSignInTitle": "Kuruluş Giriş", "orgAuthChooseIdpDescription": "Devam etmek için kimlik sağlayıcınızı seçin", "orgAuthNoIdpConfigured": "Bu kuruluşta yapılandırılmış kimlik sağlayıcı yok. Bunun yerine Pangolin kimliğinizle giriş yapabilirsiniz.", "orgAuthSignInWithPangolin": "Pangolin ile Giriş Yap", + "orgAuthSignInToOrg": "Bir kuruluşa giriş yapın", + "orgAuthSelectOrgTitle": "Kuruluş Giriş", + "orgAuthSelectOrgDescription": "Devam etmek için kuruluş kimliğinizi girin", + "orgAuthOrgIdPlaceholder": "kuruluşunuz", + "orgAuthOrgIdHelp": "Kuruluşunuzun benzersiz tanımlayıcısını girin", + "orgAuthSelectOrgHelp": "Kuruluş kimliğinizi girdikten sonra, SSO veya kuruluş kimlik bilgilerinizi kullanabileceğiniz kuruluş giriş sayfanıza yönlendirileceksiniz.", + "orgAuthRememberOrgId": "Bu kuruluş kimliğini hatırla", + "orgAuthBackToSignIn": "Standart girişe geri dön", + "orgAuthNoAccount": "Hesabınız yok mu?", "subscriptionRequiredToUse": "Bu özelliği kullanmak için abonelik gerekmektedir.", + "mustUpgradeToUse": "Bu özelliği kullanmak için aboneliğinizi yükseltmelisiniz.", + "subscriptionRequiredTierToUse": "Bu özellik {tier} veya daha üstünü gerektirir.", + "upgradeToTierToUse": "Bu özelliği kullanmak için {tier} veya daha üst bir seviyeye yükseltin.", + "subscriptionTierTier1": "Ana Sayfa", + "subscriptionTierTier2": "Takım", + "subscriptionTierTier3": "İşletme", + "subscriptionTierEnterprise": "Kurumsal", "idpDisabled": "Kimlik sağlayıcılar devre dışı bırakılmıştır.", "orgAuthPageDisabled": "Kuruluş kimlik doğrulama sayfası devre dışı bırakılmıştır.", "domainRestartedDescription": "Alan doğrulaması başarıyla yeniden başlatıldı", @@ -1850,6 +2046,8 @@ "enableTwoFactorAuthentication": "İki faktörlü kimlik doğrulamayı etkinleştir", "completeSecuritySteps": "Güvenlik Adımlarını Tamamla", "securitySettings": "Güvenlik Ayarları", + "dangerSection": "Tehlike Alanı", + "dangerSectionDescription": "Bu organizasyonla ilişkili tüm verileri kalıcı olarak silin", "securitySettingsDescription": "Kuruluşunuz için güvenlik politikalarını yapılandırın", "requireTwoFactorForAllUsers": "Tüm Kullanıcılar için İki Faktörlü Kimlik Doğrulama Gerektir", "requireTwoFactorDescription": "Etkinleştirildiğinde, bu kuruluştaki tüm dahili kullanıcıların, kuruluşa erişmek için iki faktörlü kimlik doğrulama etkinleştirilmiş olmalıdır.", @@ -1887,7 +2085,7 @@ "securityPolicyChangeWarningText": "Bu, organizasyondaki tüm kullanıcıları etkileyecektir", "authPageErrorUpdateMessage": "Kimlik doğrulama sayfası ayarları güncellenirken bir hata oluştu.", "authPageErrorUpdate": "Kimlik doğrulama sayfası güncellenemedi", - "authPageUpdated": "Kimlik doğrulama sayfası başarıyla güncellendi", + "authPageDomainUpdated": "Kimlik doğrulama sayfası alanı başarıyla güncellendi", "healthCheckNotAvailable": "Yerel", "rewritePath": "Yolu Yeniden Yaz", "rewritePathDescription": "Seçenek olarak hedefe iletmeden önce yolu yeniden yazın.", @@ -1915,8 +2113,15 @@ "beta": "Beta", "manageUserDevices": "Kullanıcı Cihazları", "manageUserDevicesDescription": "Kullanıcıların kaynaklara özel olarak bağlanmak için kullandığı cihazları görüntüleyin ve yönetin", + "downloadClientBannerTitle": "Pangolin İstemcisini İndir", + "downloadClientBannerDescription": "Sisteminize Pangolin istemcisini indirerek Pangolin ağına bağlanın ve kaynaklara özel olarak erişim sağlayın.", "manageMachineClients": "Makine İstemcilerini Yönetin", "manageMachineClientsDescription": "Sunucuların ve sistemlerin kaynaklara özel olarak bağlanmak için kullandığı istemcileri oluşturun ve yönetin", + "machineClientsBannerTitle": "Sunucular ve Otomatik Sistemler", + "machineClientsBannerDescription": "Makine müşterileri, belirli bir kullanıcı ile ilişkilendirilmemiş sunucular ve otomatik sistemler içindir. Kimlik ve şifreyle doğrulama yaparlar ve Pangolin CLI, Olm CLI veya Olm'yi bir konteyner olarak çalıştırabilirler.", + "machineClientsBannerPangolinCLI": "Pangolin CLI", + "machineClientsBannerOlmCLI": "Olm CLI", + "machineClientsBannerOlmContainer": "Olm Konteyner", "clientsTableUserClients": "Kullanıcı", "clientsTableMachineClients": "Makine", "licenseTableValidUntil": "Geçerli İki Tarih Kadar", @@ -2015,6 +2220,32 @@ } } }, + "newPricingLicenseForm": { + "title": "Bir lisans alın", + "description": "Bir plan seçin ve Pangolin'i nasıl kullanmayı planladığınızı anlatın.", + "chooseTier": "Planınızı seçin", + "viewPricingLink": "Fiyatları, özellikleri ve limitleri görüntüleyin", + "tiers": { + "starter": { + "title": "Başlangıç", + "description": "Kurumsal özellikler, 25 kullanıcı, 25 site ve topluluk desteği." + }, + "scale": { + "title": "Ölçek", + "description": "Kurumsal özellikler, 50 kullanıcı, 50 site ve öncelikli destek." + } + }, + "personalUseOnly": "Yalnızca kişisel kullanım (ücretsiz lisans — ödeme yapılmaz)", + "buttons": { + "continueToCheckout": "Ödemeye Devam Et" + }, + "toasts": { + "checkoutError": { + "title": "Ödeme Hatası", + "description": "Ödeme işlemi başlatılamadı. Lütfen tekrar deneyin." + } + } + }, "priority": "Öncelik", "priorityDescription": "Daha yüksek öncelikli rotalar önce değerlendirilir. Öncelik = 100, otomatik sıralama anlamına gelir (sistem karar verir). Manuel öncelik uygulamak için başka bir numara kullanın.", "instanceName": "Örnek İsmi", @@ -2060,13 +2291,15 @@ "request": "İstek", "requests": "İstekler", "logs": "Günlükler", - "logsSettingsDescription": "Bu organizasyondan toplanan günlükleri izleyin", + "logsSettingsDescription": "Bu kuruluştan toplanan günlükleri izleyin", "searchLogs": "Günlüklerde ara...", "action": "Eylem", "actor": "Aktör", "timestamp": "Zaman damgası", "accessLogs": "Erişim Günlükleri", "exportCsv": "CSV Dışa Aktar", + "exportError": "CSV dışa aktarılırken bilinmeyen hata", + "exportCsvTooltip": "Zaman Aralığında", "actorId": "Aktör Kimliği", "allowedByRule": "Kurallara Göre İzin Verildi", "allowedNoAuth": "Kimlik Doğrulama Yok İzin Verildi", @@ -2111,7 +2344,8 @@ "logRetentionEndOfFollowingYear": "Bir sonraki yılın sonu", "actionLogsDescription": "Bu organizasyondaki eylemler geçmişini görüntüleyin", "accessLogsDescription": "Bu organizasyondaki kaynaklar için erişim kimlik doğrulama isteklerini görüntüleyin", - "licenseRequiredToUse": "Bu özelliği kullanmak için bir kurumsal lisans gereklidir.", + "licenseRequiredToUse": "Bu özelliği kullanmak için bir Enterprise Edition lisansı veya Pangolin Cloud gereklidir. Tanıtım veya POC denemesi ayarlayın.", + "ossEnterpriseEditionRequired": "Bu özelliği kullanmak için Enterprise Edition gereklidir. Bu özellik ayrıca Pangolin Cloud’da da mevcuttur. Tanıtım veya POC denemesi ayarlayın.", "certResolver": "Sertifika Çözücü", "certResolverDescription": "Bu kaynak için kullanılacak sertifika çözücüsünü seçin.", "selectCertResolver": "Sertifika Çözücü Seçin", @@ -2120,7 +2354,7 @@ "unverified": "Doğrulanmadı", "domainSetting": "Alan Adı Ayarları", "domainSettingDescription": "Alan adı için ayarları yapılandırın", - "preferWildcardCertDescription": "Joker sertifika üretmeye çalışın (doğru yapılandırılmış bir sertifika çözücü gereklidir).", + "preferWildcardCertDescription": "Joker karakter sertifikası oluşturmayı deneyin (doğru yapılandırılmış bir sertifika çözümleyici gerektirir).", "recordName": "Kayıt Adı", "auto": "Otomatik", "TTL": "TTL", @@ -2172,6 +2406,8 @@ "deviceCodeInvalidFormat": "Kod 9 karakter olmalı (ör. A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "Geçersiz veya süresi dolmuş kod", "deviceCodeVerifyFailed": "Cihaz kodu doğrulanamadı", + "deviceCodeValidating": "Cihaz kodu doğrulanıyor...", + "deviceCodeVerifying": "Cihaz yetkilendirme doğrulanıyor...", "signedInAs": "Olarak giriş yapıldı", "deviceCodeEnterPrompt": "Cihazda gösterilen kodu girin", "continue": "Devam Et", @@ -2184,7 +2420,7 @@ "deviceOrganizationsAccess": "Hesabınızın erişim hakkına sahip olduğu tüm organizasyonlara erişim", "deviceAuthorize": "{uygulamaAdi} yetkilendir", "deviceConnected": "Cihaz Bağlandı!", - "deviceAuthorizedMessage": "Cihazınız, hesabınıza erişim izni almıştır.", + "deviceAuthorizedMessage": "Cihaz hesabınıza erişim yetkisine sahiptir. Lütfen istemci uygulamasına geri dönün.", "pangolinCloud": "Pangolin Cloud", "viewDevices": "Cihazları Görüntüle", "viewDevicesDescription": "Bağlantılı cihazlarınızı yönetin", @@ -2246,6 +2482,7 @@ "identifier": "Tanımlayıcı", "deviceLoginUseDifferentAccount": "Siz değil misiniz? Farklı bir hesap kullanın.", "deviceLoginDeviceRequestingAccessToAccount": "Bir cihaz bu hesaba erişim talep ediyor.", + "loginSelectAuthenticationMethod": "Devam etmek için bir kimlik doğrulama yöntemi seçin.", "noData": "Veri Yok", "machineClients": "Makine İstemcileri", "install": "Yükle", @@ -2255,6 +2492,8 @@ "setupFailedToFetchSubnet": "Varsayılan alt ağ alınamadı", "setupSubnetAdvanced": "Alt Ağ (Gelişmiş)", "setupSubnetDescription": "Bu organizasyonun dahili ağı için alt ağ.", + "setupUtilitySubnet": "Yardımcı Alt Ağ (Gelişmiş)", + "setupUtilitySubnetDescription": "Bu kuruluşun alias adresleri ve DNS sunucusu için alt ağ.", "siteRegenerateAndDisconnect": "Yeniden Oluştur ve Bağlantıyı Kes", "siteRegenerateAndDisconnectConfirmation": "Kimlik bilgilerini yeniden oluşturmak ve bu sitenin bağlantısını kesmek istediğinizden emin misiniz?", "siteRegenerateAndDisconnectWarning": "Bu, kimlik bilgilerini yeniden oluşturacak ve sitenin bağlantısını anında kesecektir. Site yeni kimlik bilgilerle yeniden başlatılmalıdır.", @@ -2270,5 +2509,179 @@ "remoteExitNodeRegenerateAndDisconnectWarning": "Bu, kimlik bilgilerini yeniden oluşturacak ve hemen uzak çıkış düğümünün bağlantısını kesecek. Uzak çıkış düğümü, yeni kimlik bilgileri ile yeniden başlatılmalıdır.", "remoteExitNodeRegenerateCredentialsConfirmation": "Bu uzak çıkış düğümü için kimlik bilgilerini yeniden oluşturmak istediğinizden emin misiniz?", "remoteExitNodeRegenerateCredentialsWarning": "Bu, kimlik bilgilerini yeniden oluşturacak. Uzak çıkış düğümü, manuel olarak yeniden başlatılana ve yeni kimlik bilgiler kullanılana kadar bağlı kalacak.", - "agent": "Aracı" + "agent": "Aracı", + "personalUseOnly": "Sadece Kişisel Kullanım", + "loginPageLicenseWatermark": "Bu örnek yalnızca kişisel kullanım için lisanslıdır.", + "instanceIsUnlicensed": "Bu örnek lisanssızdır.", + "portRestrictions": "Port Kısıtlamaları", + "allPorts": "Tümü", + "custom": "Özel", + "allPortsAllowed": "Tüm Portlar İzinli", + "allPortsBlocked": "Tüm Portlar Engelli", + "tcpPortsDescription": "Bu kaynak için izin verilen TCP portlarını belirtin. Tüm portlar için '*' kullanın, hepsini engellemek için boş bırakın veya virgülle ayrılmış port ve aralık listesi girin (ör. 80,443,8000-9000).", + "udpPortsDescription": "Bu kaynak için izin verilen UDP portlarını belirtin. Tüm portlar için '*' kullanın, hepsini engellemek için boş bırakın veya virgülle ayrılmış port ve aralık listesi girin (ör. 53,123,500-600).", + "organizationLoginPageTitle": "Kuruluş Giriş Sayfası", + "organizationLoginPageDescription": "Bu kuruluş için giriş sayfasını özelleştirin", + "resourceLoginPageTitle": "Kaynak Giriş Sayfası", + "resourceLoginPageDescription": "Bağımsız kaynaklar için giriş sayfasını özelleştirin", + "enterConfirmation": "Onayı girin", + "blueprintViewDetails": "Detaylar", + "defaultIdentityProvider": "Varsayılan Kimlik Sağlayıcı", + "defaultIdentityProviderDescription": "Varsayılan bir kimlik sağlayıcı seçildiğinde, kullanıcı kimlik doğrulaması için otomatik olarak sağlayıcıya yönlendirilecektir.", + "editInternalResourceDialogNetworkSettings": "Ağ Ayarları", + "editInternalResourceDialogAccessPolicy": "Erişim Politikası", + "editInternalResourceDialogAddRoles": "Roller Ekle", + "editInternalResourceDialogAddUsers": "Kullanıcılar Ekle", + "editInternalResourceDialogAddClients": "Müşteriler Ekle", + "editInternalResourceDialogDestinationLabel": "Hedef", + "editInternalResourceDialogDestinationDescription": "Dahili kaynak için hedef adresi belirtin. Seçilen moda bağlı olarak bu bir ana bilgisayar adı, IP adresi veya CIDR aralığı olabilir. Daha kolay tanımlama için isteğe bağlı olarak dahili bir DNS takma adı ayarlayın.", + "editInternalResourceDialogPortRestrictionsDescription": "Belirtilen TCP/UDP portlarına erişimi kısıtlayın veya tüm portlara izin/engelleme verin.", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "Erişim Kontrolü", + "editInternalResourceDialogAccessControlDescription": "Bağlandığında bu kaynağa erişimi olan roller, kullanıcılar ve makine müşterilerini kontrol edin. Yöneticiler her zaman erişime sahiptir.", + "editInternalResourceDialogPortRangeValidationError": "Port aralığı, tüm portlar için \"*\" veya virgülle ayrılmış bir port ve aralık listesi olmalıdır (ör. \"80,443,8000-9000\"). Portlar 1 ile 65535 arasında olmalıdır.", + "internalResourceAuthDaemonStrategy": "SSH Kimlik Doğrulama Daemon Yeri", + "internalResourceAuthDaemonStrategyDescription": "SSH kimlik doğrulama sunucusunun nerede çalışacağını seçin: sitede (Newt) veya uzak bir ana bilgisayarda.", + "internalResourceAuthDaemonDescription": "SSH kimlik doğrulama sunucusu, bu kaynak için SSH anahtar imzalama ve PAM kimlik doğrulamasını yapar. Sitede (Newt) veya ayrı bir uzak ana bilgisayarda çalışıp çalışmayacağını seçin. Daha fazla bilgi için belgeleri görün.", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "Strateji Seçin", + "internalResourceAuthDaemonStrategyLabel": "Konum", + "internalResourceAuthDaemonSite": "Sitede", + "internalResourceAuthDaemonSiteDescription": "Kimlik doğrulama sunucusu sitede (Newt) çalışır.", + "internalResourceAuthDaemonRemote": "Uzak Ana Bilgisayar", + "internalResourceAuthDaemonRemoteDescription": "Kimlik doğrulama sunucusu, site olmayan bir ana bilgisayarda çalışır.", + "internalResourceAuthDaemonPort": "Daemon Portu (isteğe bağlı)", + "orgAuthWhatsThis": "Kuruluş kimliğimi nerede bulabilirim?", + "learnMore": "Daha fazla bilgi", + "backToHome": "Ana sayfaya geri dön", + "needToSignInToOrg": "Kuruluşunuzun kimlik sağlayıcısını kullanmanız mı gerekiyor?", + "maintenanceMode": "Bakım Modu", + "maintenanceModeDescription": "Ziyaretçilere bir bakım sayfası gösterin", + "maintenanceModeType": "Bakım Modu Türü", + "showMaintenancePage": "Ziyaretçilere bir bakım sayfası gösterin", + "enableMaintenanceMode": "Bakım Modunu Etkinleştir", + "automatic": "Otomatik", + "automaticModeDescription": "Tüm arka uç hedefleri kapalı veya sağlıksız olduğunda yalnızca bakım sayfasını gösterin. Sağlıklı en az bir hedef olduğu sürece kaynağınız normal şekilde çalışmaya devam eder.", + "forced": "Zorunlu", + "forcedModeDescription": "Arka plan sağlığına bakılmaksızın her zaman bakım sayfasını gösterin. Tüm erişimi engellemek istediğiniz planlı bakım için bunu kullanın.", + "warning:": "Uyarı:", + "forcedeModeWarning": "Tüm trafik bakım sayfasına yönlendirilecek. Arka plan kaynaklarınız herhangi bir isteği almayacaktır.", + "pageTitle": "Sayfa Başlığı", + "pageTitleDescription": "Bakım sayfasında gösterilen ana başlık", + "maintenancePageMessage": "Bakım Mesajı", + "maintenancePageMessagePlaceholder": "Yakında geri döneceğiz! Sitemiz şu anda planlı bakım altındadır.", + "maintenancePageMessageDescription": "Bakımın detaylarını açıklayan mesaj", + "maintenancePageTimeTitle": "Tahmini Tamamlanma Süresi (İsteğe Bağlı)", + "maintenanceTime": "ör. 2 saat, 1 Kasım saat 17:00", + "maintenanceEstimatedTimeDescription": "Bakımın ne zaman tamamlanmasını bekliyorsunuz", + "editDomain": "Alan Adını Düzenle", + "editDomainDescription": "Kaynak için bir alan adı seçin", + "maintenanceModeDisabledTooltip": "Bu özelliği etkinleştirmek için geçerli bir lisans gereklidir.", + "maintenanceScreenTitle": "Servis Geçici Olarak Kullanılamıyor", + "maintenanceScreenMessage": "Şu anda teknik zorluklar yaşıyoruz. Lütfen yakında tekrar kontrol edin.", + "maintenanceScreenEstimatedCompletion": "Tahmini Tamamlama:", + "createInternalResourceDialogDestinationRequired": "Hedef gereklidir", + "available": "Mevcut", + "archived": "Arşivlenmiş", + "noArchivedDevices": "Arşivlenmiş cihaz bulunamadı", + "deviceArchived": "Cihaz arşivlendi", + "deviceArchivedDescription": "Cihaz başarıyla arşivlendi.", + "errorArchivingDevice": "Cihaz arşivleme hatası", + "failedToArchiveDevice": "Cihaz arşivlenemedi", + "deviceQuestionArchive": "Bu cihazı arşivlemek istediğinizden emin misiniz?", + "deviceMessageArchive": "Cihaz arşivlenecek ve aktif cihazlar listenizden kaldırılacak.", + "deviceArchiveConfirm": "Cihaz Arşivle", + "archiveDevice": "Cihaz Arşivle", + "archive": "Arşivle", + "deviceUnarchived": "Cihaz arşivden çıkarıldı", + "deviceUnarchivedDescription": "Cihaz başarıyla arşivden çıkarıldı.", + "errorUnarchivingDevice": "Cihaz arşivden çıkartılamadı", + "failedToUnarchiveDevice": "Cihaz arşivden çıkarılamadı", + "unarchive": "Arşivden Çıkart", + "archiveClient": "İstemci Arşivle", + "archiveClientQuestion": "Bu istemciyi arşivlemek istediğinizden emin misiniz?", + "archiveClientMessage": "İstemci arşivlenecek ve aktif istemciler listenizden çıkarılacak.", + "archiveClientConfirm": "İstemci Arşivle", + "blockClient": "İstemci Engelle", + "blockClientQuestion": "Bu istemciyi engellemek istediğinizden emin misiniz?", + "blockClientMessage": "Cihaz şu anda bağlıysa bağlantısı kesilecek. Cihazı daha sonra engelini kaldırabilirsiniz.", + "blockClientConfirm": "İstemci Engelle", + "active": "Aktif", + "usernameOrEmail": "Kullanıcı adı veya E-posta", + "selectYourOrganization": "Kuruluşunuzu seçin", + "signInTo": "Giriş yapın", + "signInWithPassword": "Şifre ile Devam Et", + "noAuthMethodsAvailable": "Bu kuruluş için kullanılabilir kimlik doğrulama yöntemleri yok.", + "enterPassword": "Şifrenizi girin", + "enterMfaCode": "Authenticator uygulamanızdan kodu girin", + "securityKeyRequired": "Giriş yapmak için güvenlik anahtarınızı kullanın.", + "needToUseAnotherAccount": "Farklı bir hesap kullanmanız mı gerekiyor?", + "loginLegalDisclaimer": "Aşağıdaki butonlara tıklayarak, Hizmet Şartları ve Gizlilik Politikası metinlerini okuduğunuzu ve anladığınızı kabul etmektesiniz.", + "termsOfService": "Hizmet Şartları", + "privacyPolicy": "Gizlilik Politikası", + "userNotFoundWithUsername": "Bu kullanıcı adıyla eşleşen kullanıcı bulunamadı.", + "verify": "Doğrula", + "signIn": "Giriş Yap", + "forgotPassword": "Şifreni mi unuttun?", + "orgSignInTip": "Daha önce giriş yaptıysanız, yukarıda kullanıcı adınızı veya e-posta adresinizi girerek kuruluşunuzun kimlik sağlayıcısıyla kimlik doğrulaması yapabilirsiniz. Daha kolay!", + "continueAnyway": "Yine de devam et", + "dontShowAgain": "Tekrar gösterme", + "orgSignInNotice": "Biliyor muydunuz?", + "signupOrgNotice": "Giriş yapmaya mı çalışıyorsunuz?", + "signupOrgTip": "Kuruluşunuzun kimlik sağlayıcısı aracılığıyla giriş yapmaya mı çalışıyorsunuz?", + "signupOrgLink": "Bunun yerine kuruluşunuzla giriş yapın veya kaydolun", + "verifyEmailLogInWithDifferentAccount": "Farklı Bir Hesap Kullan", + "logIn": "Giriş Yap", + "deviceInformation": "Cihaz Bilgisi", + "deviceInformationDescription": "Cihaz ve temsilci hakkında bilgi", + "deviceSecurity": "Cihaz Güvenliği", + "deviceSecurityDescription": "Cihaz güvenliği durumu bilgisi", + "platform": "Platform", + "macosVersion": "macOS Sürümü", + "windowsVersion": "Windows Sürümü", + "iosVersion": "iOS Sürümü", + "androidVersion": "Android Sürümü", + "osVersion": "İşletim Sistemi Sürümü", + "kernelVersion": "Çekirdek Sürümü", + "deviceModel": "Cihaz Modeli", + "serialNumber": "Seri Numarası", + "hostname": "Ana Makine Adı", + "firstSeen": "İlk Görüldü", + "lastSeen": "Son Görüldü", + "biometricsEnabled": "Biyometri Etkin", + "diskEncrypted": "Disk Şifrelenmiş", + "firewallEnabled": "Güvenlik Duvarı Etkin", + "autoUpdatesEnabled": "Otomatik Güncellemeler Etkin", + "tpmAvailable": "TPM Mevcut", + "windowsAntivirusEnabled": "Antivirüs Etkinleştirildi", + "macosSipEnabled": "Sistem Bütünlüğü Koruması (SIP)", + "macosGatekeeperEnabled": "Gatekeeper", + "macosFirewallStealthMode": "Güvenlik Duvarı Gizlilik Modu", + "linuxAppArmorEnabled": "AppArmor", + "linuxSELinuxEnabled": "SELinux", + "deviceSettingsDescription": "Cihaz bilgilerini ve ayarlarını görüntüleyin", + "devicePendingApprovalDescription": "Bu cihaz onay bekliyor", + "deviceBlockedDescription": "Bu cihaz şu anda engellidir. Engeli kaldırılmadığı sürece hiçbir kaynağa bağlanamayacaktır.", + "unblockClient": "İstemci Engeli Kaldır", + "unblockClientDescription": "Cihazın engeli kaldırıldı", + "unarchiveClient": "İstemci Arşivini Kaldır", + "unarchiveClientDescription": "Cihaz arşivden çıkarıldı", + "block": "Engelle", + "unblock": "Engelini Kaldır", + "deviceActions": "Cihaz İşlemleri", + "deviceActionsDescription": "Cihaz durumu ve erişimini yönetin", + "devicePendingApprovalBannerDescription": "Bu cihaz onay bekliyor. Onaylanana kadar kaynaklara bağlanamayacak.", + "connected": "Bağlandı", + "disconnected": "Bağlantı Kesildi", + "approvalsEmptyStateTitle": "Cihaz Onayları Etkin Değil", + "approvalsEmptyStateDescription": "Kullanıcıların yeni cihazlara bağlanabilmeleri için yönetici onayı gerektiren rol cihaz onaylarını etkinleştirin.", + "approvalsEmptyStateStep1Title": "Rollere Git", + "approvalsEmptyStateStep1Description": "Cihaz onaylarını yapılandırmak için kuruluşunuzun rol ayarlarına gidin.", + "approvalsEmptyStateStep2Title": "Cihaz Onaylarını Etkinleştir", + "approvalsEmptyStateStep2Description": "Bir rolü düzenleyin ve 'Cihaz Onaylarını Gerektir' seçeneğini etkinleştirin. Bu role sahip kullanıcıların yeni cihazlar için yönetici onayına ihtiyacı olacaktır.", + "approvalsEmptyStatePreviewDescription": "Önizleme: Etkinleştirildiğinde, bekleyen cihaz talepleri incelenmek üzere burada görünecektir.", + "approvalsEmptyStateButtonText": "Rolleri Yönet", + "domainErrorTitle": "Alan adınızı doğrulamada sorun yaşıyoruz" } diff --git a/messages/zh-CN.json b/messages/zh-CN.json index b8e1f2b11..f297d1ea9 100644 --- a/messages/zh-CN.json +++ b/messages/zh-CN.json @@ -1,5 +1,7 @@ { "setupCreate": "创建组织、站点和资源", + "headerAuthCompatibilityInfo": "启用此功能以在身份验证令牌缺失时强制返回401未授权响应。对于不在没有服务器挑战的情况下不发送凭证的浏览器或特定HTTP库,这是必需的。", + "headerAuthCompatibility": "扩展兼容性", "setupNewOrg": "新建组织", "setupCreateOrg": "创建组织", "setupCreateResources": "创建资源", @@ -16,6 +18,8 @@ "componentsMember": "您属于{count, plural, =0 {没有组织} one {一个组织} other {# 个组织}}。", "componentsInvalidKey": "检测到无效或过期的许可证密钥。按照许可证条款操作以继续使用所有功能。", "dismiss": "忽略", + "subscriptionViolationMessage": "您的当前计划超出了您的限制。通过移除站点、用户或其他资源以保持在您的计划范围内来纠正问题。", + "subscriptionViolationViewBilling": "查看计费", "componentsLicenseViolation": "许可证超限:该服务器使用了 {usedSites} 个站点,已超过授权的 {maxSites} 个。请遵守许可证条款以继续使用全部功能。", "componentsSupporterMessage": "感谢您的支持!您现在是 Pangolin 的 {tier} 用户。", "inviteErrorNotValid": "很抱歉,但看起来你试图访问的邀请尚未被接受或不再有效。", @@ -51,6 +55,12 @@ "siteQuestionRemove": "您确定要从组织中删除该站点吗?", "siteManageSites": "管理站点", "siteDescription": "创建和管理站点,启用与私人网络的连接", + "sitesBannerTitle": "连接任何网络", + "sitesBannerDescription": "站点是连接到远程网络的链接,允许Pangolin为用户提供资源访问,无论是公共还是私人。可以在任何可以运行二进制文件或容器的地方安装站点网络连接器(Newt)以建立连接。", + "sitesBannerButtonText": "安装站点", + "approvalsBannerTitle": "批准或拒绝设备访问", + "approvalsBannerDescription": "审核、批准或拒绝用户的设备访问请求。 当需要设备批准时,用户必须先获得管理员批准,然后他们的设备才能连接到您的组织资源。", + "approvalsBannerButtonText": "了解更多", "siteCreate": "创建站点", "siteCreateDescription2": "按照下面的步骤创建和连接一个新站点", "siteCreateDescription": "创建一个新站点开始连接资源", @@ -100,6 +110,7 @@ "siteTunnelDescription": "确定如何连接到站点", "siteNewtCredentials": "全权证书", "siteNewtCredentialsDescription": "站点如何通过服务器进行身份验证", + "remoteNodeCredentialsDescription": "这是远程节点如何与服务器进行身份验证", "siteCredentialsSave": "保存证书", "siteCredentialsSaveDescription": "您只能看到一次。请确保将其复制并保存到一个安全的地方。", "siteInfo": "站点信息", @@ -146,8 +157,12 @@ "shareErrorSelectResource": "请选择一个资源", "proxyResourceTitle": "管理公共资源", "proxyResourceDescription": "创建和管理可通过 Web 浏览器公开访问的资源", + "proxyResourcesBannerTitle": "基于Web的公共访问", + "proxyResourcesBannerDescription": "公共资源是可以通过网络浏览器在互联网上任何人访问的HTTPS或TCP/UDP代理。与私人资源不同,它们不需要客户端软件,并且可以包含身份和上下文感知访问策略。", "clientResourceTitle": "管理私有资源", "clientResourceDescription": "创建和管理只能通过连接客户端访问的资源", + "privateResourcesBannerTitle": "零信任的私人访问", + "privateResourcesBannerDescription": "私人资源使用零信任安全性,确保只允许明确授予的用户和机器访问资源。可以连接用户设备或机器客户端,通过安全的虚拟专用网络访问这些资源。", "resourcesSearch": "搜索资源...", "resourceAdd": "添加资源", "resourceErrorDelte": "删除资源时出错", @@ -157,9 +172,10 @@ "resourceMessageRemove": "一旦删除,资源将不再可访问。与该资源相关的所有目标也将被删除。", "resourceQuestionRemove": "您确定要从组织中删除资源吗?", "resourceHTTP": "HTTPS 资源", - "resourceHTTPDescription": "使用子域或基础域通过 HTTPS 请求此应用的代理请求。", + "resourceHTTPDescription": "通过使用完全限定的域名的HTTPS代理请求。", "resourceRaw": "TCP/UDP 资源", - "resourceRawDescription": "通过 TCP/UDP 使用端口号对应用程序的代理请求。只有当站点连接到节点时才能生效。", + "resourceRawDescription": "通过使用端口号的原始TCP/UDP代理请求。", + "resourceRawDescriptionCloud": "正在使用端口号使用 TCP/UDP 代理请求。需要站点连接到远程节点。", "resourceCreate": "创建资源", "resourceCreateDescription": "按照下面的步骤创建新资源", "resourceSeeAll": "查看所有资源", @@ -186,6 +202,7 @@ "protocolSelect": "选择协议", "resourcePortNumber": "端口号", "resourcePortNumberDescription": "代理请求的外部端口号。", + "back": "后退", "cancel": "取消", "resourceConfig": "配置片段", "resourceConfigDescription": "复制并粘贴这些配置片段以设置 TCP/UDP 资源", @@ -231,6 +248,17 @@ "orgErrorDeleteMessage": "删除组织时出错。", "orgDeleted": "组织已删除", "orgDeletedMessage": "组织及其数据已被删除。", + "deleteAccount": "删除帐户", + "deleteAccountDescription": "永久删除您的帐户、您拥有的所有组织以及这些组织中的所有数据。此操作无法撤消。", + "deleteAccountButton": "删除帐户", + "deleteAccountConfirmTitle": "删除帐户", + "deleteAccountConfirmMessage": "这将永久擦除您的帐户、您拥有的所有组织以及这些组织中的所有数据。这不能撤消。", + "deleteAccountConfirmString": "删除帐户", + "deleteAccountSuccess": "账户已删除", + "deleteAccountSuccessMessage": "您的帐户已被删除。", + "deleteAccountError": "删除帐户失败", + "deleteAccountPreviewAccount": "您的帐户", + "deleteAccountPreviewOrgs": "您拥有的组织 (和所有数据)", "orgMissing": "缺少组织 ID", "orgMissingMessage": "没有组织ID,无法重新生成邀请。", "accessUsersManage": "管理用户", @@ -247,6 +275,8 @@ "accessRolesSearch": "搜索角色...", "accessRolesAdd": "添加角色", "accessRoleDelete": "删除角色", + "accessApprovalsManage": "管理批准", + "accessApprovalsDescription": "查看和管理待审批的组织访问权限", "description": "描述", "inviteTitle": "打开邀请", "inviteDescription": "管理其他用户加入机构的邀请", @@ -440,6 +470,20 @@ "selectDuration": "选择持续时间", "selectResource": "选择资源", "filterByResource": "按资源过滤", + "selectApprovalState": "选择审批状态", + "filterByApprovalState": "按批准状态过滤", + "approvalListEmpty": "无批准", + "approvalState": "审批状态", + "approvalLoadMore": "加载更多", + "loadingApprovals": "正在加载批准", + "approve": "批准", + "approved": "已批准", + "denied": "被拒绝", + "deniedApproval": "拒绝批准", + "all": "所有", + "deny": "拒绝", + "viewDetails": "查看详情", + "requestingNewDeviceApproval": "请求了一个新设备", "resetFilters": "重置过滤器", "totalBlocked": "被Pangolin阻止的请求", "totalRequests": "总请求", @@ -607,6 +651,7 @@ "resourcesErrorUpdate": "切换资源失败", "resourcesErrorUpdateDescription": "更新资源时出错", "access": "访问权限", + "accessControl": "访问控制", "shareLink": "{resource} 的分享链接", "resourceSelect": "选择资源", "shareLinks": "分享链接", @@ -687,7 +732,7 @@ "resourceRoleDescription": "管理员总是可以访问此资源。", "resourceUsersRoles": "访问控制", "resourceUsersRolesDescription": "配置用户和角色可以访问此资源", - "resourceUsersRolesSubmit": "保存用户和角色", + "resourceUsersRolesSubmit": "保存访问控制", "resourceWhitelistSave": "保存成功", "resourceWhitelistSaveDescription": "白名单设置已保存", "ssoUse": "使用平台 SSO", @@ -719,22 +764,35 @@ "countries": "国家", "accessRoleCreate": "创建角色", "accessRoleCreateDescription": "创建一个新角色来分组用户并管理他们的权限。", + "accessRoleEdit": "编辑角色", + "accessRoleEditDescription": "编辑角色信息。", "accessRoleCreateSubmit": "创建角色", "accessRoleCreated": "角色已创建", "accessRoleCreatedDescription": "角色已成功创建。", "accessRoleErrorCreate": "创建角色失败", "accessRoleErrorCreateDescription": "创建角色时出错。", + "accessRoleUpdateSubmit": "更新角色", + "accessRoleUpdated": "角色已更新", + "accessRoleUpdatedDescription": "角色已成功更新。", + "accessApprovalUpdated": "审批已处理", + "accessApprovalApprovedDescription": "将审批请求决定设置为已批准。", + "accessApprovalDeniedDescription": "设置审批请求决定被拒绝。", + "accessRoleErrorUpdate": "更新角色失败", + "accessRoleErrorUpdateDescription": "更新角色时出错。", + "accessApprovalErrorUpdate": "处理审核失败", + "accessApprovalErrorUpdateDescription": "处理批准时出错。", "accessRoleErrorNewRequired": "需要新角色", "accessRoleErrorRemove": "删除角色失败", "accessRoleErrorRemoveDescription": "删除角色时出错。", "accessRoleName": "角色名称", - "accessRoleQuestionRemove": "您即将删除 {name} 角色。 此操作无法撤销。", + "accessRoleQuestionRemove": "您即将删除 `{name}` 角色。此操作无法撤销。", "accessRoleRemove": "删除角色", "accessRoleRemoveDescription": "从组织中删除角色", "accessRoleRemoveSubmit": "删除角色", "accessRoleRemoved": "角色已删除", "accessRoleRemovedDescription": "角色已成功删除。", "accessRoleRequiredRemove": "删除此角色之前,请选择一个新角色来转移现有成员。", + "network": "网络", "manage": "管理", "sitesNotFound": "未找到站点。", "pangolinServerAdmin": "服务器管理员 - Pangolin", @@ -750,6 +808,9 @@ "sitestCountIncrease": "增加站点数量", "idpManage": "管理身份提供商", "idpManageDescription": "查看和管理系统中的身份提供商", + "idpGlobalModeBanner": "此服务器上禁用了每个组织的身份提供商(Idps)。 它正在使用全局IdP(所有组织共享)。在 管理面板中管理全局IdP。 要启用每个组织的 IdP,请编辑服务器配置并将 IdP 模式设置为 org。 请参阅文档。 如果您想要继续使用全局IdP并使其从组织设置中消失,请在配置中将模式设置为全局模式。", + "idpGlobalModeBannerUpgradeRequired": "此服务器上禁用了每个组织的身份提供商(Idps)。它正在使用全局身份提供商(所有组织共享)。 在 管理面板管理全局身份。要使用每个组织的身份提供者,您必须升级到企业版本。", + "idpGlobalModeBannerLicenseRequired": "此服务器上禁用了每个组织的身份提供商(Idps)。它正在使用全局身份提供商(所有组织共享)。 在 管理面板管理全局身份。要使用每个组织的身份提供者,需要企业许可证。", "idpDeletedDescription": "身份提供商删除成功", "idpOidc": "OAuth2/OIDC", "idpQuestionRemove": "您确定要永久删除身份提供者吗?", @@ -840,6 +901,7 @@ "orgPolicyConfig": "配置组织访问权限", "idpUpdatedDescription": "身份提供商更新成功", "redirectUrl": "重定向网址", + "orgIdpRedirectUrls": "重定向URL", "redirectUrlAbout": "关于重定向网址", "redirectUrlAboutDescription": "这是用户在验证后将被重定向到的URL。您需要在身份提供者的设置中配置此URL。", "pangolinAuth": "认证 - Pangolin", @@ -945,11 +1007,11 @@ "pincodeAuth": "验证器代码", "pincodeSubmit2": "提交代码", "passwordResetSubmit": "请求重置", - "passwordResetAlreadyHaveCode": "输入密码重置码", + "passwordResetAlreadyHaveCode": "输入代码", "passwordResetSmtpRequired": "请联系您的管理员", "passwordResetSmtpRequiredDescription": "需要密码重置密码。请联系您的管理员寻求帮助。", "passwordBack": "回到密码", - "loginBack": "返回登录", + "loginBack": "返回主登录页面", "signup": "注册", "loginStart": "登录以开始", "idpOidcTokenValidating": "正在验证 OIDC 令牌", @@ -972,12 +1034,12 @@ "pangolinSetup": "认证 - Pangolin", "orgNameRequired": "组织名称是必需的", "orgIdRequired": "组织ID是必需的", + "orgIdMaxLength": "组织 ID 必须至少 32 个字符", "orgErrorCreate": "创建组织时出错", "pageNotFound": "找不到页面", "pageNotFoundDescription": "哎呀!您正在查找的页面不存在。", "overview": "概览", "home": "首页", - "accessControl": "访问控制", "settings": "设置", "usersAll": "所有用户", "license": "许可协议", @@ -1035,15 +1097,24 @@ "updateOrgUser": "更新组织用户", "createOrgUser": "创建组织用户", "actionUpdateOrg": "更新组织", + "actionRemoveInvitation": "移除邀请", "actionUpdateUser": "更新用户", "actionGetUser": "获取用户", "actionGetOrgUser": "获取组织用户", "actionListOrgDomains": "列出组织域", + "actionGetDomain": "获取域", + "actionCreateOrgDomain": "创建域", + "actionUpdateOrgDomain": "更新域", + "actionDeleteOrgDomain": "删除域", + "actionGetDNSRecords": "获取 DNS 记录", + "actionRestartOrgDomain": "重新启动域", "actionCreateSite": "创建站点", "actionDeleteSite": "删除站点", "actionGetSite": "获取站点", "actionListSites": "站点列表", "actionApplyBlueprint": "应用蓝图", + "actionListBlueprints": "列表蓝图", + "actionGetBlueprint": "获取蓝图", "setupToken": "设置令牌", "setupTokenDescription": "从服务器控制台输入设置令牌。", "setupTokenRequired": "需要设置令牌", @@ -1077,6 +1148,7 @@ "actionRemoveUser": "删除用户", "actionListUsers": "列出用户", "actionAddUserRole": "添加用户角色", + "actionSetUserOrgRoles": "Set User Roles", "actionGenerateAccessToken": "生成访问令牌", "actionDeleteAccessToken": "删除访问令牌", "actionListAccessTokens": "访问令牌", @@ -1104,6 +1176,10 @@ "actionUpdateIdpOrg": "更新 IDP组织", "actionCreateClient": "创建客户端", "actionDeleteClient": "删除客户端", + "actionArchiveClient": "归档客户端", + "actionUnarchiveClient": "取消归档客户端", + "actionBlockClient": "屏蔽客户端", + "actionUnblockClient": "解除屏蔽客户端", "actionUpdateClient": "更新客户端", "actionListClients": "列出客户端", "actionGetClient": "获取客户端", @@ -1117,17 +1193,18 @@ "actionViewLogs": "查看日志", "noneSelected": "未选择", "orgNotFound2": "未找到组织。", - "searchProgress": "搜索中...", + "searchPlaceholder": "搜索...", + "emptySearchOptions": "未找到选项", "create": "创建", "orgs": "组织", - "loginError": "登录时出错", - "loginRequiredForDevice": "需要登录才能验证您的设备。", + "loginError": "发生意外错误。请重试。", + "loginRequiredForDevice": "您的设备需要登录。", "passwordForgot": "忘记密码?", "otpAuth": "两步验证", "otpAuthDescription": "从您的身份验证程序中输入代码或您的单次备份代码。", "otpAuthSubmit": "提交代码", "idpContinue": "或者继续", - "otpAuthBack": "返回登录", + "otpAuthBack": "回到密码", "navbar": "导航菜单", "navbarDescription": "应用程序的主导航菜单", "navbarDocsLink": "文件", @@ -1175,11 +1252,13 @@ "sidebarOverview": "概览", "sidebarHome": "首页", "sidebarSites": "站点", + "sidebarApprovals": "审批请求", "sidebarResources": "资源", "sidebarProxyResources": "公开的", "sidebarClientResources": "非公开的", "sidebarAccessControl": "访问控制", "sidebarLogsAndAnalytics": "日志与分析", + "sidebarTeam": "团队", "sidebarUsers": "用户", "sidebarAdmin": "管理员", "sidebarInvitations": "邀请", @@ -1191,13 +1270,15 @@ "sidebarIdentityProviders": "身份提供商", "sidebarLicense": "证书", "sidebarClients": "客户端", - "sidebarUserDevices": "用户", + "sidebarUserDevices": "用户设备", "sidebarMachineClients": "机", "sidebarDomains": "域", - "sidebarGeneral": "概览", + "sidebarGeneral": "管理", "sidebarLogAndAnalytics": "日志与分析", "sidebarBluePrints": "蓝图", "sidebarOrganization": "组织", + "sidebarManagement": "管理", + "sidebarBillingAndLicenses": "帐单和许可证", "sidebarLogsAnalytics": "分析", "blueprints": "蓝图", "blueprintsDescription": "应用声明配置并查看先前运行的", @@ -1219,7 +1300,6 @@ "parsedContents": "解析内容 (只读)", "enableDockerSocket": "启用 Docker 蓝图", "enableDockerSocketDescription": "启用 Docker Socket 标签擦除蓝图标签。套接字路径必须提供给新的。", - "enableDockerSocketLink": "了解更多", "viewDockerContainers": "查看停靠容器", "containersIn": "{siteName} 中的容器", "selectContainerDescription": "选择任何容器作为目标的主机名。点击端口使用端口。", @@ -1263,6 +1343,7 @@ "setupErrorCreateAdmin": "创建服务器管理员账户时发生错误。", "certificateStatus": "证书状态", "loading": "加载中", + "loadingAnalytics": "加载分析", "restart": "重启", "domains": "域", "domainsDescription": "创建和管理组织中可用的域", @@ -1290,6 +1371,7 @@ "refreshError": "刷新数据失败", "verified": "已验证", "pending": "待定", + "pendingApproval": "等待批准", "sidebarBilling": "计费", "billing": "计费", "orgBillingDescription": "管理账单信息和订阅", @@ -1308,8 +1390,11 @@ "accountSetupSuccess": "账号设置完成!欢迎来到 Pangolin!", "documentation": "文档", "saveAllSettings": "保存所有设置", + "saveResourceTargets": "保存目标", + "saveResourceHttp": "保存代理设置", + "saveProxyProtocol": "保存代理协议设置", "settingsUpdated": "设置已更新", - "settingsUpdatedDescription": "所有设置已成功更新", + "settingsUpdatedDescription": "设置更新成功", "settingsErrorUpdate": "设置更新失败", "settingsErrorUpdateDescription": "更新设置时发生错误", "sidebarCollapse": "折叠", @@ -1342,6 +1427,7 @@ "domainPickerNamespace": "命名空间:{namespace}", "domainPickerShowMore": "显示更多", "regionSelectorTitle": "选择区域", + "domainPickerRemoteExitNodeWarning": "当站点连接到远程退出节点时不支持所提供的域。为了资源可在远程节点上使用,请使用自定义域名。", "regionSelectorInfo": "选择区域以帮助提升您所在地的性能。您不必与服务器在相同的区域。", "regionSelectorPlaceholder": "选择一个区域", "regionSelectorComingSoon": "即将推出", @@ -1351,10 +1437,11 @@ "billingUsageLimitsOverview": "使用限制概览", "billingMonitorUsage": "监控您的使用情况以对比已配置的限制。如需提高限制请联系我们 support@pangolin.net。", "billingDataUsage": "数据使用情况", - "billingOnlineTime": "站点在线时间", - "billingUsers": "活跃用户", - "billingDomains": "活跃域", - "billingRemoteExitNodes": "活跃自托管节点", + "billingSites": "站点", + "billingUsers": "用户", + "billingDomains": "域", + "billingOrganizations": "球队", + "billingRemoteExitNodes": "远程节点", "billingNoLimitConfigured": "未配置限制", "billingEstimatedPeriod": "估计结算周期", "billingIncludedUsage": "包含的使用量", @@ -1379,15 +1466,24 @@ "billingFailedToGetPortalUrl": "无法获取门户网址", "billingPortalError": "门户错误", "billingDataUsageInfo": "当连接到云端时,您将为通过安全隧道传输的所有数据收取费用。 这包括您所有站点的进出流量。 当您达到上限时,您的站点将断开连接,直到您升级计划或减少使用。使用节点时不收取数据。", - "billingOnlineTimeInfo": "您要根据您的网站连接到云端的时间长短收取费用。 例如,44,640分钟等于一个24/7全月运行的网站。 当您达到上限时,您的站点将断开连接,直到您升级计划或减少使用。使用节点时不收取费用。", - "billingUsersInfo": "您为组织中的每个用户收取费用。每日计费是根据您组织中活跃用户帐户的数量计算的。", - "billingDomainInfo": "您在组织中的每个域都要收取费用。每日计费是根据您组织中的活动域帐户数计算的。", - "billingRemoteExitNodesInfo": "您为组织中的每个管理节点收取费用。计费是每日根据您组织中活跃的管理节点数计算的。", + "billingSInfo": "您可以使用多少站点", + "billingUsersInfo": "您可以使用多少用户", + "billingDomainInfo": "您可以使用多少域", + "billingRemoteExitNodesInfo": "您可以使用多少远程节点", + "billingLicenseKeys": "许可证密钥", + "billingLicenseKeysDescription": "管理您的许可证密钥订阅", + "billingLicenseSubscription": "许可订阅", + "billingInactive": "未激活", + "billingLicenseItem": "许可证项目", + "billingQuantity": "数量", + "billingTotal": "总计", + "billingModifyLicenses": "修改许可订阅", "domainNotFound": "域未找到", "domainNotFoundDescription": "此资源已禁用,因为该域不再在我们的系统中存在。请为此资源设置一个新域。", "failed": "失败", "createNewOrgDescription": "创建一个新组织", "organization": "组织", + "primary": "主要的", "port": "端口", "securityKeyManage": "管理安全密钥", "securityKeyDescription": "添加或删除用于无密码认证的安全密钥", @@ -1403,7 +1499,7 @@ "securityKeyRemoveSuccess": "安全密钥删除成功", "securityKeyRemoveError": "删除安全密钥失败", "securityKeyLoadError": "加载安全密钥失败", - "securityKeyLogin": "使用安全密钥继续", + "securityKeyLogin": "使用安全密钥", "securityKeyAuthError": "使用安全密钥认证失败", "securityKeyRecommendation": "考虑在其他设备上注册另一个安全密钥,以确保不会被锁定在您的账户之外。", "registering": "注册中...", @@ -1459,11 +1555,47 @@ "resourcePortRequired": "非 HTTP 资源必须输入端口号", "resourcePortNotAllowed": "HTTP 资源不应设置端口号", "billingPricingCalculatorLink": "价格计算器", + "billingYourPlan": "您的计划", + "billingViewOrModifyPlan": "查看或修改您当前的计划", + "billingViewPlanDetails": "查看计划详细信息", + "billingUsageAndLimits": "用法和限制", + "billingViewUsageAndLimits": "查看您的计划限制和当前使用情况", + "billingCurrentUsage": "当前使用情况", + "billingMaximumLimits": "最大限制", + "billingRemoteNodes": "远程节点", + "billingUnlimited": "无限制", + "billingPaidLicenseKeys": "付费许可证密钥", + "billingManageLicenseSubscription": "管理您对付费的自托管许可证密钥的订阅", + "billingCurrentKeys": "当前密钥", + "billingModifyCurrentPlan": "修改当前计划", + "billingConfirmUpgrade": "确认升级", + "billingConfirmDowngrade": "确认降级", + "billingConfirmUpgradeDescription": "您即将升级您的计划。请检查下面的新限额和定价。", + "billingConfirmDowngradeDescription": "您即将降级计划。请检查下面的新限额和定价。", + "billingPlanIncludes": "计划包含", + "billingProcessing": "正在处理...", + "billingConfirmUpgradeButton": "确认升级", + "billingConfirmDowngradeButton": "确认降级", + "billingLimitViolationWarning": "超出新计划限制", + "billingLimitViolationDescription": "您当前的使用量超过了此计划的限制。降级后,所有操作都将被禁用,直到您在新的限制范围内减少使用量。 请查看以下当前超出限制的特性:", + "billingFeatureLossWarning": "功能可用通知", + "billingFeatureLossDescription": "如果降级,新计划中不可用的功能将被自动禁用。一些设置和配置可能会丢失。 请查看定价矩阵以了解哪些功能将不再可用。", + "billingUsageExceedsLimit": "当前使用量 ({current}) 超出限制 ({limit})", + "billingPastDueTitle": "过去到期的付款", + "billingPastDueDescription": "您的付款已过期。请更新您的付款方法以继续使用您当前的计划功能。 如果不解决,您的订阅将被取消,您将被恢复到免费等级。", + "billingUnpaidTitle": "订阅未付款", + "billingUnpaidDescription": "您的订阅未付,您已恢复到免费等级。请更新您的付款方法以恢复您的订阅。", + "billingIncompleteTitle": "付款不完成", + "billingIncompleteDescription": "您的付款不完整。请完成付款过程以激活您的订阅。", + "billingIncompleteExpiredTitle": "付款已过期", + "billingIncompleteExpiredDescription": "您的付款尚未完成且已过期。您已恢复到免费级别。请再次订阅以恢复对已支付功能的访问。", + "billingManageSubscription": "管理您的订阅", + "billingResolvePaymentIssue": "请在升级或降级之前解决您的付款问题", "signUpTerms": { "IAgreeToThe": "我同意", "termsOfService": "服务条款", "and": "和", - "privacyPolicy": "隐私政策" + "privacyPolicy": "隐私政策。" }, "signUpMarketing": { "keepMeInTheLoop": "通过电子邮件让我在循环中保持新闻、更新和新功能。" @@ -1508,6 +1640,7 @@ "addNewTarget": "添加新目标", "targetsList": "目标列表", "advancedMode": "高级模式", + "advancedSettings": "高级设置", "targetErrorDuplicateTargetFound": "找到重复的目标", "healthCheckHealthy": "正常", "healthCheckUnhealthy": "不正常", @@ -1529,6 +1662,26 @@ "IntervalSeconds": "正常间隔", "timeoutSeconds": "超时(秒)", "timeIsInSeconds": "时间以秒为单位", + "requireDeviceApproval": "需要设备批准", + "requireDeviceApprovalDescription": "具有此角色的用户需要管理员批准的新设备才能连接和访问资源。", + "sshAccess": "SSH 访问", + "roleAllowSsh": "允许 SSH", + "roleAllowSshAllow": "允许", + "roleAllowSshDisallow": "不允许", + "roleAllowSshDescription": "允许具有此角色的用户通过 SSH 连接到资源。禁用时,角色不能使用 SSH 访问。", + "sshSudoMode": "Sudo 访问", + "sshSudoModeNone": "无", + "sshSudoModeNoneDescription": "用户不能用sudo运行命令。", + "sshSudoModeFull": "全苏多", + "sshSudoModeFullDescription": "用户可以用 sudo 运行任何命令。", + "sshSudoModeCommands": "命令", + "sshSudoModeCommandsDescription": "用户只能用 sudo 运行指定的命令。", + "sshSudo": "允许Sudo", + "sshSudoCommands": "Sudo 命令", + "sshSudoCommandsDescription": "逗号分隔的用户允许使用 sudo 运行的命令列表。", + "sshCreateHomeDir": "创建主目录", + "sshUnixGroups": "Unix 组", + "sshUnixGroupsDescription": "用逗号分隔了Unix组,将用户添加到目标主机上。", "retryAttempts": "重试次数", "expectedResponseCodes": "期望响应代码", "expectedResponseCodesDescription": "HTTP 状态码表示健康状态。如留空,200-300 被视为健康。", @@ -1569,6 +1722,8 @@ "resourcesTableNoInternalResourcesFound": "未找到内部资源。", "resourcesTableDestination": "目标", "resourcesTableAlias": "Alias", + "resourcesTableAliasAddress": "别名地址", + "resourcesTableAliasAddressInfo": "此地址是组织实用子网的一部分。它用来使用内部DNS解析来解析别名记录。", "resourcesTableClients": "客户端", "resourcesTableAndOnlyAccessibleInternally": "且仅在与客户端连接时可内部访问。", "resourcesTableNoTargets": "没有目标", @@ -1616,9 +1771,8 @@ "createInternalResourceDialogResourceProperties": "资源属性", "createInternalResourceDialogName": "名称", "createInternalResourceDialogSite": "站点", - "createInternalResourceDialogSelectSite": "选择站点...", - "createInternalResourceDialogSearchSites": "搜索站点...", - "createInternalResourceDialogNoSitesFound": "未找到站点。", + "selectSite": "选择站点...", + "noSitesFound": "未找到站点。", "createInternalResourceDialogProtocol": "协议", "createInternalResourceDialogTcp": "TCP", "createInternalResourceDialogUdp": "UDP", @@ -1658,7 +1812,7 @@ "siteAddressDescription": "站点的内部地址。必须属于组织的子网。", "siteNameDescription": "可以稍后更改的站点显示名称。", "autoLoginExternalIdp": "自动使用外部IDP登录", - "autoLoginExternalIdpDescription": "立即将用户重定向到外部IDP进行身份验证。", + "autoLoginExternalIdpDescription": "立即重定向用户到外部身份提供商进行身份验证。", "selectIdp": "选择IDP", "selectIdpPlaceholder": "选择一个IDP...", "selectIdpRequired": "在启用自动登录时,请选择一个IDP。", @@ -1670,7 +1824,7 @@ "autoLoginErrorNoRedirectUrl": "未从身份提供商收到重定向URL。", "autoLoginErrorGeneratingUrl": "生成身份验证URL失败。", "remoteExitNodeManageRemoteExitNodes": "远程节点", - "remoteExitNodeDescription": "自我主机一个或多个远程节点来扩展网络连接并减少对云的依赖性", + "remoteExitNodeDescription": "自托管您的远程中继和代理服务器节点", "remoteExitNodes": "节点", "searchRemoteExitNodes": "搜索节点...", "remoteExitNodeAdd": "添加节点", @@ -1680,20 +1834,22 @@ "remoteExitNodeConfirmDelete": "确认删除节点", "remoteExitNodeDelete": "删除节点", "sidebarRemoteExitNodes": "远程节点", + "remoteExitNodeId": "ID", + "remoteExitNodeSecretKey": "密钥", "remoteExitNodeCreate": { - "title": "创建节点", - "description": "创建一个新节点来扩展网络连接", + "title": "创建远程节点", + "description": "创建一个新的自托管远程中继和代理服务器节点", "viewAllButton": "查看所有节点", "strategy": { "title": "创建策略", - "description": "选择此选项以手动配置节点或生成新凭据。", + "description": "选择您想如何创建远程节点", "adopt": { "title": "采纳节点", "description": "如果您已经拥有该节点的凭据,请选择此项。" }, "generate": { "title": "生成密钥", - "description": "如果您想为节点生成新密钥,请选择此选项" + "description": "如果您想为节点生成新密钥,请选择此选项." } }, "adopt": { @@ -1806,9 +1962,33 @@ "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", "subnet": "子网", "subnetDescription": "此组织网络配置的子网。", - "authPage": "认证页面", - "authPageDescription": "配置组织认证页面", + "customDomain": "自定义域", + "authPage": "身份验证页面", + "authPageDescription": "为组织的身份验证页面设置自定义域", "authPageDomain": "认证页面域", + "authPageBranding": "自定义品牌", + "authPageBrandingDescription": "配置此组织身份验证页面的品牌", + "authPageBrandingUpdated": "授权页面品牌更新成功", + "authPageBrandingRemoved": "成功移除授权页面品牌", + "authPageBrandingRemoveTitle": "移除授权页面品牌", + "authPageBrandingQuestionRemove": "您确定要移除授权页面的品牌吗?", + "authPageBrandingDeleteConfirm": "确认删除品牌", + "brandingLogoURL": "Logo URL", + "brandingLogoURLOrPath": "徽标URL或路径", + "brandingLogoPathDescription": "输入网址或本地路径。", + "brandingLogoURLDescription": "请在您的徽标图片中输入一个可公开访问的 URL。", + "brandingPrimaryColor": "主要颜色", + "brandingLogoWidth": "宽度(px)", + "brandingLogoHeight": "高度(px)", + "brandingOrgTitle": "组织授权页面标题", + "brandingOrgDescription": "{orgName}将替换为组织名称", + "brandingOrgSubtitle": "组织授权页面副标题", + "brandingResourceTitle": "资源授权页面标题", + "brandingResourceSubtitle": "资源授权页面副标题", + "brandingResourceDescription": "{resourceName} 将替换为组织名称", + "saveAuthPageDomain": "保存域", + "saveAuthPageBranding": "保存品牌", + "removeAuthPageBranding": "移除品牌", "noDomainSet": "没有域设置", "changeDomain": "更改域", "selectDomain": "选择域", @@ -1817,7 +1997,7 @@ "setAuthPageDomain": "设置认证页面域", "failedToFetchCertificate": "获取证书失败", "failedToRestartCertificate": "重新启动证书失败", - "addDomainToEnableCustomAuthPages": "添加域名以启用组织自定义认证页面", + "addDomainToEnableCustomAuthPages": "用户将能够使用该域访问组织的登录页面并完成资源身份验证。", "selectDomainForOrgAuthPage": "选择组织认证页面的域", "domainPickerProvidedDomain": "提供的域", "domainPickerFreeProvidedDomain": "免费提供的域", @@ -1832,11 +2012,27 @@ "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" 无法为 {domain} 变为有效。", "domainPickerSubdomainSanitized": "子域已净化", "domainPickerSubdomainCorrected": "\"{sub}\" 已被更正为 \"{sanitized}\"", - "orgAuthSignInTitle": "登录到组织", + "orgAuthSignInTitle": "组织登录", "orgAuthChooseIdpDescription": "选择您的身份提供商以继续", "orgAuthNoIdpConfigured": "此机构没有配置任何身份提供者。您可以使用您的 Pangolin 身份登录。", "orgAuthSignInWithPangolin": "使用 Pangolin 登录", + "orgAuthSignInToOrg": "登录到组织", + "orgAuthSelectOrgTitle": "组织登录", + "orgAuthSelectOrgDescription": "输入您的组织ID以继续", + "orgAuthOrgIdPlaceholder": "您的组织", + "orgAuthOrgIdHelp": "输入您组织的唯一标识符", + "orgAuthSelectOrgHelp": "输入您的组织ID后,您将跳转到组织的登录页面,您可以使用SSO或组织凭据。", + "orgAuthRememberOrgId": "记住这个组织ID", + "orgAuthBackToSignIn": "返回标准登录", + "orgAuthNoAccount": "没有账户?", "subscriptionRequiredToUse": "需要订阅才能使用此功能。", + "mustUpgradeToUse": "您必须升级您的订阅才能使用此功能。", + "subscriptionRequiredTierToUse": "此功能需要 {tier} 或更高级别。", + "upgradeToTierToUse": "升级到 {tier} 或更高级别以使用此功能。", + "subscriptionTierTier1": "首页", + "subscriptionTierTier2": "团队", + "subscriptionTierTier3": "业务", + "subscriptionTierEnterprise": "企业", "idpDisabled": "身份提供者已禁用。", "orgAuthPageDisabled": "组织认证页面已禁用。", "domainRestartedDescription": "域验证重新启动成功", @@ -1850,6 +2046,8 @@ "enableTwoFactorAuthentication": "启用两步验证", "completeSecuritySteps": "完成安全步骤", "securitySettings": "安全设置", + "dangerSection": "危险区域", + "dangerSectionDescription": "永久删除与此组织相关的所有数据", "securitySettingsDescription": "配置组织安全策略", "requireTwoFactorForAllUsers": "所有用户需要两步验证", "requireTwoFactorDescription": "如果启用,此组织的所有内部用户必须启用双重身份验证才能访问组织。", @@ -1887,7 +2085,7 @@ "securityPolicyChangeWarningText": "这将影响组织中的所有用户", "authPageErrorUpdateMessage": "更新身份验证页面设置时出错", "authPageErrorUpdate": "无法更新认证页面", - "authPageUpdated": "身份验证页面更新成功", + "authPageDomainUpdated": "授权页面域更新成功", "healthCheckNotAvailable": "本地的", "rewritePath": "重写路径", "rewritePathDescription": "在转发到目标之前,可以选择重写路径。", @@ -1915,8 +2113,15 @@ "beta": "测试版", "manageUserDevices": "用户设备", "manageUserDevicesDescription": "查看和管理用户用来私下连接到资源的设备", + "downloadClientBannerTitle": "下载Pangolin客户端", + "downloadClientBannerDescription": "下载适用于您系统的Pangolin客户端以连接到Pangolin网络并私下访问资源。", "manageMachineClients": "管理机器客户端", "manageMachineClientsDescription": "创建和管理服务器和系统用于私密连接到资源的客户端", + "machineClientsBannerTitle": "服务器与自动化系统", + "machineClientsBannerDescription": "机器客户端适用于不与特定用户关联的服务器与自动化系统。它们使用ID和密钥进行身份验证,并可以与Pangolin CLI、Olm CLI或作为容器运行。", + "machineClientsBannerPangolinCLI": "Pangolin CLI", + "machineClientsBannerOlmCLI": "Olm CLI", + "machineClientsBannerOlmContainer": "Olm 容器", "clientsTableUserClients": "用户", "clientsTableMachineClients": "机", "licenseTableValidUntil": "有效期至", @@ -2015,6 +2220,32 @@ } } }, + "newPricingLicenseForm": { + "title": "获取许可证", + "description": "选择一个计划,告诉我们你计划如何使用 Pangolin。", + "chooseTier": "选择您的计划", + "viewPricingLink": "查看价格、特征和限制", + "tiers": { + "starter": { + "title": "启动器", + "description": "企业特征,25个用户,25个站点和社区支持。" + }, + "scale": { + "title": "缩放比例", + "description": "企业特征、50个用户、50个站点和优先支持。" + } + }, + "personalUseOnly": "仅供个人使用 (免费许可证-无签出)", + "buttons": { + "continueToCheckout": "继续签出" + }, + "toasts": { + "checkoutError": { + "title": "签出错误", + "description": "无法启动结帐。请重试。" + } + } + }, "priority": "优先权", "priorityDescription": "先评估更高优先级线路。优先级 = 100意味着自动排序(系统决定). 使用另一个数字强制执行手动优先级。", "instanceName": "实例名称", @@ -2060,13 +2291,15 @@ "request": "请求", "requests": "请求", "logs": "日志", - "logsSettingsDescription": "监视从此orginization中收集的日志", + "logsSettingsDescription": "监控从此组织收集的日志", "searchLogs": "搜索日志...", "action": "行 动", "actor": "执行者", "timestamp": "时间戳", "accessLogs": "访问日志", "exportCsv": "导出CSV", + "exportError": "导出CSV时发生未知错误", + "exportCsvTooltip": "在时间范围内", "actorId": "执行者ID", "allowedByRule": "根据规则允许", "allowedNoAuth": "无认证", @@ -2111,7 +2344,8 @@ "logRetentionEndOfFollowingYear": "下一年结束", "actionLogsDescription": "查看此机构执行的操作历史", "accessLogsDescription": "查看此机构资源的访问认证请求", - "licenseRequiredToUse": "需要企业许可证才能使用此功能。", + "licenseRequiredToUse": "使用此功能需要企业版许可证或Pangolin Cloud预约演示或POC试用。", + "ossEnterpriseEditionRequired": "需要 Enterprise Edition 才能使用此功能。 此功能也可在 Pangolin Cloud上获取。 预订演示或POC 试用。", "certResolver": "证书解决器", "certResolverDescription": "选择用于此资源的证书解析器。", "selectCertResolver": "选择证书解析", @@ -2120,7 +2354,7 @@ "unverified": "未验证", "domainSetting": "域设置", "domainSettingDescription": "配置域设置", - "preferWildcardCertDescription": "尝试生成通配符证书(需要正确配置的证书解析器)。", + "preferWildcardCertDescription": "尝试生成通配符证书(需要正确配置的证书解析器)。", "recordName": "记录名称", "auto": "自动操作", "TTL": "TTL", @@ -2172,6 +2406,8 @@ "deviceCodeInvalidFormat": "代码必须是9个字符(如A1AJ-N5JD)", "deviceCodeInvalidOrExpired": "无效或过期的代码", "deviceCodeVerifyFailed": "验证设备代码失败", + "deviceCodeValidating": "正在验证设备代码...", + "deviceCodeVerifying": "正在验证设备授权...", "signedInAs": "登录为", "deviceCodeEnterPrompt": "输入设备上显示的代码", "continue": "继续", @@ -2184,7 +2420,7 @@ "deviceOrganizationsAccess": "访问您的帐户拥有访问权限的所有组织", "deviceAuthorize": "授权{applicationName}", "deviceConnected": "设备已连接!", - "deviceAuthorizedMessage": "设备被授权访问您的帐户。", + "deviceAuthorizedMessage": "设备被授权访问您的帐户。请返回客户端应用程序。", "pangolinCloud": "邦戈林云", "viewDevices": "查看设备", "viewDevicesDescription": "管理您已连接的设备", @@ -2246,6 +2482,7 @@ "identifier": "Identifier", "deviceLoginUseDifferentAccount": "不是你?使用一个不同的帐户。", "deviceLoginDeviceRequestingAccessToAccount": "设备正在请求访问此帐户。", + "loginSelectAuthenticationMethod": "选择要继续的身份验证方法。", "noData": "无数据", "machineClients": "机器客户端", "install": "安装", @@ -2255,6 +2492,8 @@ "setupFailedToFetchSubnet": "获取默认子网失败", "setupSubnetAdvanced": "子网 (高级)", "setupSubnetDescription": "该组织内部网络的子网。", + "setupUtilitySubnet": "实用子网(高级)", + "setupUtilitySubnetDescription": "此组织的别名地址和DNS服务器的子网。", "siteRegenerateAndDisconnect": "重新生成和断开", "siteRegenerateAndDisconnectConfirmation": "您确定要重新生成凭据并断开此站点连接吗?", "siteRegenerateAndDisconnectWarning": "这将重新生成凭据并立即断开站点。该站点将需要重新启动新凭据。", @@ -2270,5 +2509,179 @@ "remoteExitNodeRegenerateAndDisconnectWarning": "这将重新生成凭据并立即断开远程退出节点。远程退出节点将需要用新的凭据重启。", "remoteExitNodeRegenerateCredentialsConfirmation": "您确定要重新生成此远程退出节点的凭据吗?", "remoteExitNodeRegenerateCredentialsWarning": "这将重新生成凭据。远程退出节点将保持连接,直到您手动重启它并使用新凭据。", - "agent": "代理" + "agent": "代理", + "personalUseOnly": "仅供个人使用", + "loginPageLicenseWatermark": "此实例仅限于个人使用许可。", + "instanceIsUnlicensed": "此实例未获得许可。", + "portRestrictions": "端口限制", + "allPorts": "所有", + "custom": "自定义", + "allPortsAllowed": "所有端口均允许", + "allPortsBlocked": "所有端口均阻止", + "tcpPortsDescription": "指定允许此资源使用的TCP端口。使用'*'表示所有端口,留空表示阻止所有端口,或输入用逗号分隔的端口和范围列表(例如:80,443,8000-9000)。", + "udpPortsDescription": "指定允许此资源使用的UDP端口。使用'*'表示所有端口,留空表示阻止所有端口,或输入用逗号分隔的端口和范围列表(例如:53,123,500-600)。", + "organizationLoginPageTitle": "组织登录页面", + "organizationLoginPageDescription": "自定义此组织的登录页面", + "resourceLoginPageTitle": "资源登录页面", + "resourceLoginPageDescription": "自定义个别资源的登录页面", + "enterConfirmation": "输入确认", + "blueprintViewDetails": "详细信息", + "defaultIdentityProvider": "默认身份提供商", + "defaultIdentityProviderDescription": "当选择默认身份提供商时,用户将自动重定向到提供商进行身份验证。", + "editInternalResourceDialogNetworkSettings": "网络设置", + "editInternalResourceDialogAccessPolicy": "访问策略", + "editInternalResourceDialogAddRoles": "添加角色", + "editInternalResourceDialogAddUsers": "添加用户", + "editInternalResourceDialogAddClients": "添加客户端", + "editInternalResourceDialogDestinationLabel": "目标", + "editInternalResourceDialogDestinationDescription": "指定内部资源的目标地址。根据选择的模式,这可以是主机名、IP地址或CIDR范围。可选的,设置一个内部DNS别名以便于识别。", + "editInternalResourceDialogPortRestrictionsDescription": "限制访问特定的TCP/UDP端口或允许/阻止所有端口。", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "访问控制", + "editInternalResourceDialogAccessControlDescription": "控制当连接到此资源时,哪些角色、用户和机器客户端可以访问。管理员始终具有访问权。", + "editInternalResourceDialogPortRangeValidationError": "端口范围必须为\"*\"表示所有端口,或一个用逗号分隔的端口和范围列表(例如:\"80,443,8000-9000\")。端口必须在1到65535之间。", + "internalResourceAuthDaemonStrategy": "SSH 认证守护进程位置", + "internalResourceAuthDaemonStrategyDescription": "选择 SSH 身份验证守护进程在哪里运行:站点(新建) 或远程主机。", + "internalResourceAuthDaemonDescription": "SSH 身份验证守护程序处理此资源的 SSH 密钥签名和PAM 身份验证。 选择它是在站点(新建)还是在单独的远程主机上运行。请参阅 文档。", + "internalResourceAuthDaemonDocsUrl": "https://docs.pangolin.net", + "internalResourceAuthDaemonStrategyPlaceholder": "选择策略", + "internalResourceAuthDaemonStrategyLabel": "地点", + "internalResourceAuthDaemonSite": "在站点", + "internalResourceAuthDaemonSiteDescription": "认证守护进程在站点上运行(新建)。", + "internalResourceAuthDaemonRemote": "远程主机", + "internalResourceAuthDaemonRemoteDescription": "认证守护进程运行在不是站点的主机上。", + "internalResourceAuthDaemonPort": "守护进程端口(可选)", + "orgAuthWhatsThis": "我的组织ID在哪里可以找到?", + "learnMore": "了解更多", + "backToHome": "返回首页", + "needToSignInToOrg": "需要使用您组织的身份提供商吗?", + "maintenanceMode": "维护模式", + "maintenanceModeDescription": "向访客显示维护页面", + "maintenanceModeType": "维护模式类型", + "showMaintenancePage": "只在所有后端目标都故障或不健康时显示维护页面。只要至少一个目标健康,您的资源将正常工作。", + "enableMaintenanceMode": "启用维护模式", + "automatic": "自动", + "automaticModeDescription": "如果所有后端目标都故障或不健康,则仅显示维护页面。只要至少一个目标健康,您的资源将正常工作。", + "forced": "强制", + "forcedModeDescription": "无论后端健康如何,都始终显示维护页面。用于计划维护时希望阻止所有访问。", + "warning:": "警告:", + "forcedeModeWarning": "所有流量将被引导到维护页面。您的后端资源不会收到任何请求。", + "pageTitle": "页面标题", + "pageTitleDescription": "维护页面显示的主标题", + "maintenancePageMessage": "维护信息", + "maintenancePageMessagePlaceholder": "我们很快回来! 我们的网站目前正在进行计划中的维护。", + "maintenancePageMessageDescription": "详细说明维护的消息", + "maintenancePageTimeTitle": "预计完成时间(可选)", + "maintenanceTime": "例如,2小时,11月1日下午5:00", + "maintenanceEstimatedTimeDescription": "您期望维护完成的时间", + "editDomain": "编辑域名", + "editDomainDescription": "选择您资源的域", + "maintenanceModeDisabledTooltip": "启用此功能需要有效的许可证。", + "maintenanceScreenTitle": "服务暂时不可用", + "maintenanceScreenMessage": "我们目前遇到技术问题。 请稍后再回来查看。", + "maintenanceScreenEstimatedCompletion": "预计完成时间:", + "createInternalResourceDialogDestinationRequired": "需要目标地址", + "available": "可用", + "archived": "已存档", + "noArchivedDevices": "未找到存档设备", + "deviceArchived": "设备已存档", + "deviceArchivedDescription": "设备已成功归档。", + "errorArchivingDevice": "错误存档设备", + "failedToArchiveDevice": "归档设备失败", + "deviceQuestionArchive": "您确定要存档此设备吗?", + "deviceMessageArchive": "设备将被存档并从活动设备列表中删除。", + "deviceArchiveConfirm": "归档设备", + "archiveDevice": "归档设备", + "archive": "存档", + "deviceUnarchived": "设备未存档", + "deviceUnarchivedDescription": "设备已成功解除归档。", + "errorUnarchivingDevice": "卸载设备时出错", + "failedToUnarchiveDevice": "取消归档设备失败", + "unarchive": "取消存档", + "archiveClient": "归档客户端", + "archiveClientQuestion": "您确定要存档此客户端吗?", + "archiveClientMessage": "客户端将被存档并从您活跃的客户端列表中删除。", + "archiveClientConfirm": "归档客户端", + "blockClient": "屏蔽客户端", + "blockClientQuestion": "您确定要屏蔽此客户端?", + "blockClientMessage": "如果当前连接,设备将被迫断开连接。您可以稍后取消屏蔽设备。", + "blockClientConfirm": "屏蔽客户端", + "active": "已启用", + "usernameOrEmail": "用户名或电子邮件", + "selectYourOrganization": "选择您的组织", + "signInTo": "登录到", + "signInWithPassword": "使用密码继续", + "noAuthMethodsAvailable": "该组织没有可用的身份验证方法。", + "enterPassword": "输入您的密码", + "enterMfaCode": "从您的身份验证程序中输入代码", + "securityKeyRequired": "请使用您的安全密钥登录。", + "needToUseAnotherAccount": "需要使用不同的帐户?", + "loginLegalDisclaimer": "点击下面的按钮,您确认您已经阅读了,理解, 并同意 服务条款隐私政策。", + "termsOfService": "服务条款", + "privacyPolicy": "隐私政策", + "userNotFoundWithUsername": "找不到该用户名。", + "verify": "验证", + "signIn": "登录", + "forgotPassword": "忘记密码?", + "orgSignInTip": "如果您以前已经登录,您可以在上面输入您的用户名或电子邮件来验证您的组织身份提供者。这很容易!", + "continueAnyway": "仍然继续", + "dontShowAgain": "不再显示", + "orgSignInNotice": "您知道吗?", + "signupOrgNotice": "试图登录?", + "signupOrgTip": "您是否试图通过您的组织的身份提供者登录?", + "signupOrgLink": "使用您的组织登录或注册", + "verifyEmailLogInWithDifferentAccount": "使用不同的帐户", + "logIn": "登录", + "deviceInformation": "设备信息", + "deviceInformationDescription": "关于设备和代理的信息", + "deviceSecurity": "设备安全", + "deviceSecurityDescription": "设备安全态势信息", + "platform": "平台", + "macosVersion": "macOS 版本", + "windowsVersion": "Windows 版本", + "iosVersion": "iOS 版本", + "androidVersion": "Android 版本", + "osVersion": "操作系统版本", + "kernelVersion": "内核版本", + "deviceModel": "设备模型", + "serialNumber": "序列号", + "hostname": "Hostname", + "firstSeen": "第一次查看", + "lastSeen": "上次查看时间", + "biometricsEnabled": "生物计已启用", + "diskEncrypted": "磁盘加密", + "firewallEnabled": "防火墙已启用", + "autoUpdatesEnabled": "启用自动更新", + "tpmAvailable": "TPM 可用", + "windowsAntivirusEnabled": "抗病毒已启用", + "macosSipEnabled": "系统完整性保护 (SIP)", + "macosGatekeeperEnabled": "Gatekeeper", + "macosFirewallStealthMode": "防火墙隐形模式", + "linuxAppArmorEnabled": "AppArmor", + "linuxSELinuxEnabled": "SELinux", + "deviceSettingsDescription": "查看设备信息和设置", + "devicePendingApprovalDescription": "此设备正在等待批准", + "deviceBlockedDescription": "此设备目前已被屏蔽。除非解除屏蔽,否则无法连接到任何资源。", + "unblockClient": "解除屏蔽客户端", + "unblockClientDescription": "设备已解除阻止", + "unarchiveClient": "取消归档客户端", + "unarchiveClientDescription": "设备已被取消存档", + "block": "封禁", + "unblock": "取消屏蔽", + "deviceActions": "设备操作", + "deviceActionsDescription": "管理设备状态和访问权限", + "devicePendingApprovalBannerDescription": "此设备正在等待批准。在批准之前,它将无法连接到资源。", + "connected": "已连接", + "disconnected": "断开连接", + "approvalsEmptyStateTitle": "设备批准未启用", + "approvalsEmptyStateDescription": "在用户连接新设备之前,允许设备批准角色,需要管理员批准。", + "approvalsEmptyStateStep1Title": "转到角色", + "approvalsEmptyStateStep1Description": "导航到您组织的角色设置来配置设备批准。", + "approvalsEmptyStateStep2Title": "启用设备批准", + "approvalsEmptyStateStep2Description": "编辑角色并启用“需要设备审批”选项。具有此角色的用户需要管理员批准新设备。", + "approvalsEmptyStatePreviewDescription": "预览:如果启用,待处理设备请求将出现在这里供审核", + "approvalsEmptyStateButtonText": "管理角色", + "domainErrorTitle": "我们在验证您的域名时遇到了问题" } diff --git a/messages/zh-TW.json b/messages/zh-TW.json index a7e11f602..8b9d05f53 100644 --- a/messages/zh-TW.json +++ b/messages/zh-TW.json @@ -1,2099 +1,2399 @@ { - "setupCreate": "創建您的第一個組織、網站和資源", - "setupNewOrg": "新建組織", - "setupCreateOrg": "創建組織", - "setupCreateResources": "創建資源", - "setupOrgName": "組織名稱", - "orgDisplayName": "這是您組織的顯示名稱。", - "orgId": "組織ID", - "setupIdentifierMessage": "這是您組織的唯一標識符。這是與顯示名稱分開的。", - "setupErrorIdentifier": "組織ID 已被使用。請另選一個。", - "componentsErrorNoMemberCreate": "您目前不是任何組織的成員。創建組織以開始操作。", - "componentsErrorNoMember": "您目前不是任何組織的成員。", - "welcome": "歡迎使用 Pangolin", - "welcomeTo": "歡迎來到", - "componentsCreateOrg": "創建組織", - "componentsMember": "您屬於{count, plural, =0 {沒有組織} one {一個組織} other {# 個組織}}。", - "componentsInvalidKey": "檢測到無效或過期的許可證金鑰。按照許可證條款操作以繼續使用所有功能。", - "dismiss": "忽略", - "componentsLicenseViolation": "許可證超限:該伺服器使用了 {usedSites} 個站點,已超過授權的 {maxSites} 個。請遵守許可證條款以繼續使用全部功能。", - "componentsSupporterMessage": "感謝您的支持!您現在是 Pangolin 的 {tier} 用戶。", - "inviteErrorNotValid": "很抱歉,但看起來你試圖訪問的邀請尚未被接受或不再有效。", - "inviteErrorUser": "很抱歉,但看起來你想要訪問的邀請不是這個用戶。", - "inviteLoginUser": "請確保您以正確的用戶登錄。", - "inviteErrorNoUser": "很抱歉,但看起來你想訪問的邀請不是一個存在的用戶。", - "inviteCreateUser": "請先創建一個帳戶。", - "goHome": "返回首頁", - "inviteLogInOtherUser": "以不同的用戶登錄", - "createAnAccount": "創建帳戶", - "inviteNotAccepted": "邀請未接受", - "authCreateAccount": "創建一個帳戶以開始", - "authNoAccount": "沒有帳戶?", - "email": "電子郵件地址", - "password": "密碼", - "confirmPassword": "確認密碼", - "createAccount": "創建帳戶", - "viewSettings": "查看設置", - "delete": "刪除", - "name": "名稱", - "online": "在線", - "offline": "離線的", - "site": "站點", - "dataIn": "數據輸入", - "dataOut": "數據輸出", - "connectionType": "連接類型", - "tunnelType": "隧道類型", - "local": "本地的", - "edit": "編輯", - "siteConfirmDelete": "確認刪除站點", - "siteDelete": "刪除站點", - "siteMessageRemove": "一旦移除,站點將無法訪問。與站點相關的所有目標也將被移除。", - "siteQuestionRemove": "您確定要從組織中刪除該站點嗎?", - "siteManageSites": "管理站點", - "siteDescription": "允許通過安全隧道連接到您的網路", - "siteCreate": "創建站點", - "siteCreateDescription2": "按照下面的步驟創建和連接一個新站點", - "siteCreateDescription": "創建一個新站點開始連接您的資源", - "close": "關閉", - "siteErrorCreate": "創建站點出錯", - "siteErrorCreateKeyPair": "找不到金鑰對或站點預設值", - "siteErrorCreateDefaults": "未找到站點預設值", - "method": "方法", - "siteMethodDescription": "這是您將如何顯示連接。", - "siteLearnNewt": "學習如何在您的系統上安裝 Newt", - "siteSeeConfigOnce": "您只能看到一次配置。", - "siteLoadWGConfig": "正在載入 WireGuard 配置...", - "siteDocker": "擴展 Docker 部署詳細資訊", - "toggle": "切換", - "dockerCompose": "Docker 配置", - "dockerRun": "停靠欄", - "siteLearnLocal": "本地站點不需要隧道連接,點擊了解更多", - "siteConfirmCopy": "我已經複製了配置資訊", - "searchSitesProgress": "搜索站點...", - "siteAdd": "添加站點", - "siteInstallNewt": "安裝 Newt", - "siteInstallNewtDescription": "在您的系統中運行 Newt", - "WgConfiguration": "WireGuard 配置", - "WgConfigurationDescription": "使用以下配置連接到您的網路", - "operatingSystem": "操作系統", - "commands": "命令", - "recommended": "推薦", - "siteNewtDescription": "為獲得最佳用戶體驗,請使用 Newt。其底層採用 WireGuard 技術,可直接通過 Pangolin 控制台,使用區域網路地址訪問您私有網路中的資源。", - "siteRunsInDocker": "在 Docker 中運行", - "siteRunsInShell": "在 macOS 、 Linux 和 Windows 的 Shell 中運行", - "siteErrorDelete": "刪除站點出錯", - "siteErrorUpdate": "更新站點失敗", - "siteErrorUpdateDescription": "更新站點時出錯。", - "siteUpdated": "站點已更新", - "siteUpdatedDescription": "網站已更新。", - "siteGeneralDescription": "配置此站點的常規設置", - "siteSettingDescription": "配置您網站上的設置", - "siteSetting": "{siteName} 設置", - "siteNewtTunnel": "Newt 隧道 (推薦)", - "siteNewtTunnelDescription": "最簡單的方式來連接到您的網路。不需要任何額外設置。", - "siteWg": "基本 WireGuard", - "siteWgDescription": "使用任何 WireGuard 用戶端來建立隧道。需要手動配置 NAT。", - "siteWgDescriptionSaas": "使用任何WireGuard用戶端建立隧道。需要手動配置NAT。僅適用於自託管節點。", - "siteLocalDescription": "僅限本地資源。不需要隧道。", - "siteLocalDescriptionSaas": "僅本地資源。沒有隧道。僅在遠程節點上可用。", - "siteSeeAll": "查看所有站點", - "siteTunnelDescription": "確定如何連接到您的網站", - "siteNewtCredentials": "Newt 憑據", - "siteNewtCredentialsDescription": "這是 Newt 伺服器的身份驗證憑據", - "siteCredentialsSave": "保存您的憑據", - "siteCredentialsSaveDescription": "您只能看到一次。請確保將其複製並保存到一個安全的地方。", - "siteInfo": "站點資訊", - "status": "狀態", - "shareTitle": "管理共享連結", - "shareDescription": "創建可共享的連結,允許暫時或永久訪問您的資源", - "shareSearch": "搜索共享連結...", - "shareCreate": "創建共享連結", - "shareErrorDelete": "刪除連結失敗", - "shareErrorDeleteMessage": "刪除連結時出錯", - "shareDeleted": "連結已刪除", - "shareDeletedDescription": "連結已刪除", - "shareTokenDescription": "您的訪問令牌可以透過兩種方式傳遞:作為查詢參數或請求頭。 每次驗證訪問請求都必須從用戶端傳遞。", - "accessToken": "訪問令牌", - "usageExamples": "用法範例", - "tokenId": "令牌 ID", - "requestHeades": "請求頭", - "queryParameter": "查詢參數", - "importantNote": "重要提示", - "shareImportantDescription": "出於安全考慮,建議盡可能在使用請求頭傳遞參數,因為查詢參數可能會被瀏覽器歷史記錄或伺服器日誌記錄。", - "token": "令牌", - "shareTokenSecurety": "請妥善保管您的訪問令牌,不要將其暴露在公開訪問的區域或用戶端代碼中。", - "shareErrorFetchResource": "獲取資源失敗", - "shareErrorFetchResourceDescription": "獲取資源時出錯", - "shareErrorCreate": "無法創建共享連結", - "shareErrorCreateDescription": "創建共享連結時出錯", - "shareCreateDescription": "任何具有此連結的人都可以訪問資源", - "shareTitleOptional": "標題 (可選)", - "expireIn": "過期時間", - "neverExpire": "永不過期", - "shareExpireDescription": "過期時間是連結可以使用並提供對資源的訪問時間。 此時間後,連結將不再工作,使用此連結的用戶將失去對資源的訪問。", - "shareSeeOnce": "您只能看到一次此連結。請確保複製它。", - "shareAccessHint": "任何具有此連結的人都可以訪問該資源。小心地分享它。", - "shareTokenUsage": "查看訪問令牌使用情況", - "createLink": "創建連結", - "resourcesNotFound": "找不到資源", - "resourceSearch": "搜索資源", - "openMenu": "打開菜單", - "resource": "資源", - "title": "標題", - "created": "已創建", - "expires": "過期時間", - "never": "永不過期", - "shareErrorSelectResource": "請選擇一個資源", - "resourceTitle": "管理資源", - "resourceDescription": "為您的私人應用程式創建安全代理", - "resourcesSearch": "搜索資源...", - "resourceAdd": "添加資源", - "resourceErrorDelte": "刪除資源時出錯", - "authentication": "認證", - "protected": "受到保護", - "notProtected": "未受到保護", - "resourceMessageRemove": "一旦刪除,資源將不再可訪問。與該資源相關的所有目標也將被刪除。", - "resourceQuestionRemove": "您確定要從組織中刪除資源嗎?", - "resourceHTTP": "HTTPS 資源", - "resourceHTTPDescription": "使用子域或根域名通過 HTTPS 向您的應用程式提出代理請求。", - "resourceRaw": "TCP/UDP 資源", - "resourceRawDescription": "使用 TCP/UDP 使用埠號向您的應用提出代理請求。", - "resourceCreate": "創建資源", - "resourceCreateDescription": "按照下面的步驟創建新資源", - "resourceSeeAll": "查看所有資源", - "resourceInfo": "資源資訊", - "resourceNameDescription": "這是資源的顯示名稱。", - "siteSelect": "選擇站點", - "siteSearch": "搜索站點", - "siteNotFound": "未找到站點。", - "selectCountry": "選擇國家", - "searchCountries": "搜索國家...", - "noCountryFound": "找不到國家。", - "siteSelectionDescription": "此站點將為目標提供連接。", - "resourceType": "資源類型", - "resourceTypeDescription": "確定如何訪問您的資源", - "resourceHTTPSSettings": "HTTPS 設置", - "resourceHTTPSSettingsDescription": "配置如何通過 HTTPS 訪問您的資源", - "domainType": "域類型", - "subdomain": "子域名", - "baseDomain": "根域名", - "subdomnainDescription": "您的資源可以訪問的子域名。", - "resourceRawSettings": "TCP/UDP 設置", - "resourceRawSettingsDescription": "配置如何通過 TCP/UDP 訪問您的資源。 您映射資源到主機Pangolin伺服器上的埠,這樣您就可以訪問伺服器-公共-ip:mapped埠的資源。", - "protocol": "協議", - "protocolSelect": "選擇協議", - "resourcePortNumber": "埠號", - "resourcePortNumberDescription": "代理請求的外部埠號。", - "cancel": "取消", - "resourceConfig": "配置片段", - "resourceConfigDescription": "複製並黏貼這些配置片段以設置您的 TCP/UDP 資源", - "resourceAddEntrypoints": "Traefik: 添加入口點", - "resourceExposePorts": "Gerbil:在 Docker Compose 中顯示埠", - "resourceLearnRaw": "學習如何配置 TCP/UDP 資源", - "resourceBack": "返回資源", - "resourceGoTo": "轉到資源", - "resourceDelete": "刪除資源", - "resourceDeleteConfirm": "確認刪除資源", - "visibility": "可見性", - "enabled": "已啟用", - "disabled": "已禁用", - "general": "概覽", - "generalSettings": "常規設置", - "proxy": "代理伺服器", - "internal": "內部設置", - "rules": "規則", - "resourceSettingDescription": "配置您資源上的設置", - "resourceSetting": "{resourceName} 設置", - "alwaysAllow": "一律允許", - "alwaysDeny": "一律拒絕", - "passToAuth": "傳遞至認證", - "orgSettingsDescription": "配置您組織的一般設定", - "orgGeneralSettings": "組織設置", - "orgGeneralSettingsDescription": "管理您的機構詳細資訊和配置", - "saveGeneralSettings": "保存常規設置", - "saveSettings": "保存設置", - "orgDangerZone": "危險區域", - "orgDangerZoneDescription": "一旦刪除該組織,將無法恢復,請務必確認。", - "orgDelete": "刪除組織", - "orgDeleteConfirm": "確認刪除組織", - "orgMessageRemove": "此操作不可逆,這將刪除所有相關數據。", - "orgMessageConfirm": "要確認,請在下面輸入組織名稱。", - "orgQuestionRemove": "您確定要刪除組織嗎?", - "orgUpdated": "組織已更新", - "orgUpdatedDescription": "組織已更新。", - "orgErrorUpdate": "更新組織失敗", - "orgErrorUpdateMessage": "更新組織時出錯。", - "orgErrorFetch": "獲取組織失敗", - "orgErrorFetchMessage": "列出您的組織時出錯", - "orgErrorDelete": "刪除組織失敗", - "orgErrorDeleteMessage": "刪除組織時出錯。", - "orgDeleted": "組織已刪除", - "orgDeletedMessage": "組織及其數據已被刪除。", - "orgMissing": "缺少組織 ID", - "orgMissingMessage": "沒有組織ID,無法重新生成邀請。", - "accessUsersManage": "管理用戶", - "accessUsersDescription": "邀請用戶並位他們添加角色以管理訪問您的組織", - "accessUsersSearch": "搜索用戶...", - "accessUserCreate": "創建用戶", - "accessUserRemove": "刪除用戶", - "username": "使用者名稱", - "identityProvider": "身份提供商", - "role": "角色", - "nameRequired": "名稱是必填項", - "accessRolesManage": "管理角色", - "accessRolesDescription": "配置角色來管理訪問您的組織", - "accessRolesSearch": "搜索角色...", - "accessRolesAdd": "添加角色", - "accessRoleDelete": "刪除角色", - "description": "描述", - "inviteTitle": "打開邀請", - "inviteDescription": "管理您給其他用戶的邀請", - "inviteSearch": "搜索邀請...", - "minutes": "分鐘", - "hours": "小時", - "days": "天", - "weeks": "周", - "months": "月", - "years": "年", - "day": "{count, plural, other {# 天}}", - "apiKeysTitle": "API 金鑰", - "apiKeysConfirmCopy2": "您必須確認您已複製 API 金鑰。", - "apiKeysErrorCreate": "創建 API 金鑰出錯", - "apiKeysErrorSetPermission": "設置權限出錯", - "apiKeysCreate": "生成 API 金鑰", - "apiKeysCreateDescription": "為您的組織生成一個新的 API 金鑰", - "apiKeysGeneralSettings": "權限", - "apiKeysGeneralSettingsDescription": "確定此 API 金鑰可以做什麼", - "apiKeysList": "您的 API 金鑰", - "apiKeysSave": "保存您的 API 金鑰", - "apiKeysSaveDescription": "該資訊僅會顯示一次,請確保將其複製到安全的位置。", - "apiKeysInfo": "您的 API 金鑰是:", - "apiKeysConfirmCopy": "我已複製 API 金鑰", - "generate": "生成", - "done": "完成", - "apiKeysSeeAll": "查看所有 API 金鑰", - "apiKeysPermissionsErrorLoadingActions": "載入 API 金鑰操作時出錯", - "apiKeysPermissionsErrorUpdate": "設置權限出錯", - "apiKeysPermissionsUpdated": "權限已更新", - "apiKeysPermissionsUpdatedDescription": "權限已更新。", - "apiKeysPermissionsGeneralSettings": "權限", - "apiKeysPermissionsGeneralSettingsDescription": "確定此 API 金鑰可以做什麼", - "apiKeysPermissionsSave": "保存權限", - "apiKeysPermissionsTitle": "權限", - "apiKeys": "API 金鑰", - "searchApiKeys": "搜索 API 金鑰...", - "apiKeysAdd": "生成 API 金鑰", - "apiKeysErrorDelete": "刪除 API 金鑰出錯", - "apiKeysErrorDeleteMessage": "刪除 API 金鑰出錯", - "apiKeysQuestionRemove": "您確定要從組織中刪除 API 金鑰嗎?", - "apiKeysMessageRemove": "一旦刪除,此API金鑰將無法被使用。", - "apiKeysDeleteConfirm": "確認刪除 API 金鑰", - "apiKeysDelete": "刪除 API 金鑰", - "apiKeysManage": "管理 API 金鑰", - "apiKeysDescription": "API 金鑰用於認證集成 API", - "apiKeysSettings": "{apiKeyName} 設置", - "userTitle": "管理所有用戶", - "userDescription": "查看和管理系統中的所有用戶", - "userAbount": "關於用戶管理", - "userAbountDescription": "此表格顯示系統中所有根用戶對象。每個用戶可能屬於多個組織。 從組織中刪除用戶不會刪除其根用戶對象 - 他們將保留在系統中。 要從系統中完全刪除用戶,您必須使用此表格中的刪除操作刪除其根用戶對象。", - "userServer": "伺服器用戶", - "userSearch": "搜索伺服器用戶...", - "userErrorDelete": "刪除用戶時出錯", - "userDeleteConfirm": "確認刪除用戶", - "userDeleteServer": "從伺服器刪除用戶", - "userMessageRemove": "該用戶將被從所有組織中刪除並完全從伺服器中刪除。", - "userQuestionRemove": "您確定要從伺服器永久刪除用戶嗎?", - "licenseKey": "許可證金鑰", - "valid": "有效", - "numberOfSites": "站點數量", - "licenseKeySearch": "搜索許可證金鑰...", - "licenseKeyAdd": "添加許可證金鑰", - "type": "類型", - "licenseKeyRequired": "需要許可證金鑰", - "licenseTermsAgree": "您必須同意許可條款", - "licenseErrorKeyLoad": "載入許可證金鑰失敗", - "licenseErrorKeyLoadDescription": "載入許可證金鑰時出錯。", - "licenseErrorKeyDelete": "刪除許可證金鑰失敗", - "licenseErrorKeyDeleteDescription": "刪除許可證金鑰時出錯。", - "licenseKeyDeleted": "許可證金鑰已刪除", - "licenseKeyDeletedDescription": "許可證金鑰已被刪除。", - "licenseErrorKeyActivate": "啟用許可證金鑰失敗", - "licenseErrorKeyActivateDescription": "啟用許可證金鑰時出錯。", - "licenseAbout": "關於許可協議", - "communityEdition": "社區版", - "licenseAboutDescription": "這是針對商業環境中使用Pangolin的商業和企業用戶。 如果您正在使用 Pangolin 供個人使用,您可以忽略此部分。", - "licenseKeyActivated": "授權金鑰已啟用", - "licenseKeyActivatedDescription": "已成功啟用許可證金鑰。", - "licenseErrorKeyRecheck": "重新檢查許可證金鑰失敗", - "licenseErrorKeyRecheckDescription": "重新檢查許可證金鑰時出錯。", - "licenseErrorKeyRechecked": "重新檢查許可證金鑰", - "licenseErrorKeyRecheckedDescription": "已重新檢查所有許可證金鑰", - "licenseActivateKey": "啟用許可證金鑰", - "licenseActivateKeyDescription": "輸入一個許可金鑰來啟用它。", - "licenseActivate": "啟用許可證", - "licenseAgreement": "通過檢查此框,您確認您已經閱讀並同意與您的許可證金鑰相關的許可條款。", - "fossorialLicense": "查看Fossorial Commercial License和訂閱條款", - "licenseMessageRemove": "這將刪除許可證金鑰和它授予的所有相關權限。", - "licenseMessageConfirm": "要確認,請在下面輸入許可證金鑰。", - "licenseQuestionRemove": "您確定要刪除許可證金鑰?", - "licenseKeyDelete": "刪除許可證金鑰", - "licenseKeyDeleteConfirm": "確認刪除許可證金鑰", - "licenseTitle": "管理許可證狀態", - "licenseTitleDescription": "查看和管理系統中的許可證金鑰", - "licenseHost": "主機許可證", - "licenseHostDescription": "管理主機的主許可證金鑰。", - "licensedNot": "未授權", - "hostId": "主機 ID", - "licenseReckeckAll": "重新檢查所有金鑰", - "licenseSiteUsage": "站點使用情況", - "licenseSiteUsageDecsription": "查看使用此許可的站點數量。", - "licenseNoSiteLimit": "使用未經許可主機的站點數量沒有限制。", - "licensePurchase": "購買許可證", - "licensePurchaseSites": "購買更多站點", - "licenseSitesUsedMax": "使用了 {usedSites}/{maxSites} 個站點", - "licenseSitesUsed": "{count, plural, =0 {# 站點} one {# 站點} other {# 站點}}", - "licensePurchaseDescription": "請選擇您希望 {selectedMode, select, license {直接購買許可證,您可以隨時增加更多站點。} other {為現有許可證購買更多站點}}", - "licenseFee": "許可證費用", - "licensePriceSite": "每個站點的價格", - "total": "總計", - "licenseContinuePayment": "繼續付款", - "pricingPage": "定價頁面", - "pricingPortal": "前往付款頁面", - "licensePricingPage": "關於最新的價格和折扣,請訪問 ", - "invite": "邀請", - "inviteRegenerate": "重新生成邀請", - "inviteRegenerateDescription": "撤銷以前的邀請並創建一個新的邀請", - "inviteRemove": "移除邀請", - "inviteRemoveError": "刪除邀請失敗", - "inviteRemoveErrorDescription": "刪除邀請時出錯。", - "inviteRemoved": "邀請已刪除", - "inviteRemovedDescription": "為 {email} 創建的邀請已刪除", - "inviteQuestionRemove": "您確定要刪除邀請嗎?", - "inviteMessageRemove": "一旦刪除,這個邀請將不再有效。", - "inviteMessageConfirm": "要確認,請在下面輸入邀請的電子郵件地址。", - "inviteQuestionRegenerate": "您確定要重新邀請 {email} 嗎?這將會撤銷掉之前的邀請", - "inviteRemoveConfirm": "確認刪除邀請", - "inviteRegenerated": "重新生成邀請", - "inviteSent": "邀請郵件已成功發送至 {email}。", - "inviteSentEmail": "發送電子郵件通知給用戶", - "inviteGenerate": "已為 {email} 創建新的邀請。", - "inviteDuplicateError": "重複的邀請", - "inviteDuplicateErrorDescription": "此用戶的邀請已存在。", - "inviteRateLimitError": "超出速率限制", - "inviteRateLimitErrorDescription": "您超過了每小時3次再生的限制。請稍後再試。", - "inviteRegenerateError": "重新生成邀請失敗", - "inviteRegenerateErrorDescription": "重新生成邀請時出錯。", - "inviteValidityPeriod": "有效期", - "inviteValidityPeriodSelect": "選擇有效期", - "inviteRegenerateMessage": "邀請已重新生成。用戶必須訪問下面的連結才能接受邀請。", - "inviteRegenerateButton": "重新生成", - "expiresAt": "到期於", - "accessRoleUnknown": "未知角色", - "placeholder": "占位符", - "userErrorOrgRemove": "刪除用戶失敗", - "userErrorOrgRemoveDescription": "刪除用戶時出錯。", - "userOrgRemoved": "用戶已刪除", - "userOrgRemovedDescription": "已將 {email} 從組織中移除。", - "userQuestionOrgRemove": "您確定要從組織中刪除此用戶嗎?", - "userMessageOrgRemove": "一旦刪除,這個用戶將不再能夠訪問組織。 你總是可以稍後重新邀請他們,但他們需要再次接受邀請。", - "userRemoveOrgConfirm": "確認刪除用戶", - "userRemoveOrg": "從組織中刪除用戶", - "users": "用戶", - "accessRoleMember": "成員", - "accessRoleOwner": "所有者", - "userConfirmed": "已確認", - "idpNameInternal": "內部設置", - "emailInvalid": "無效的電子郵件地址", - "inviteValidityDuration": "請選擇持續時間", - "accessRoleSelectPlease": "請選擇一個角色", - "usernameRequired": "必須輸入使用者名稱", - "idpSelectPlease": "請選擇身份提供商", - "idpGenericOidc": "通用的 OAuth2/OIDC 提供商。", - "accessRoleErrorFetch": "獲取角色失敗", - "accessRoleErrorFetchDescription": "獲取角色時出錯", - "idpErrorFetch": "獲取身份提供者失敗", - "idpErrorFetchDescription": "獲取身份提供者時出錯", - "userErrorExists": "用戶已存在", - "userErrorExistsDescription": "此用戶已經是組織成員。", - "inviteError": "邀請用戶失敗", - "inviteErrorDescription": "邀請用戶時出錯", - "userInvited": "用戶邀請", - "userInvitedDescription": "用戶已被成功邀請。", - "userErrorCreate": "創建用戶失敗", - "userErrorCreateDescription": "創建用戶時出錯", - "userCreated": "用戶已創建", - "userCreatedDescription": "用戶已成功創建。", - "userTypeInternal": "內部用戶", - "userTypeInternalDescription": "邀請用戶直接加入您的組織。", - "userTypeExternal": "外部用戶", - "userTypeExternalDescription": "創建一個具有外部身份提供商的用戶。", - "accessUserCreateDescription": "按照下面的步驟創建一個新用戶", - "userSeeAll": "查看所有用戶", - "userTypeTitle": "用戶類型", - "userTypeDescription": "確定如何創建用戶", - "userSettings": "用戶資訊", - "userSettingsDescription": "輸入新用戶的詳細資訊", - "inviteEmailSent": "發送邀請郵件給用戶", - "inviteValid": "有效", - "selectDuration": "選擇持續時間", - "accessRoleSelect": "選擇角色", - "inviteEmailSentDescription": "一封電子郵件已經發送給用戶,帶有下面的訪問連結。他們必須訪問該連結才能接受邀請。", - "inviteSentDescription": "用戶已被邀請。他們必須訪問下面的連結才能接受邀請。", - "inviteExpiresIn": "邀請將在{days, plural, other {# 天}}後過期。", - "idpTitle": "身份提供商", - "idpSelect": "為外部用戶選擇身份提供商", - "idpNotConfigured": "沒有配置身份提供者。請在創建外部用戶之前配置身份提供者。", - "usernameUniq": "這必須匹配所選身份提供者中存在的唯一使用者名稱。", - "emailOptional": "電子郵件(可選)", - "nameOptional": "名稱(可選)", - "accessControls": "訪問控制", - "userDescription2": "管理此用戶的設置", - "accessRoleErrorAdd": "添加用戶到角色失敗", - "accessRoleErrorAddDescription": "添加用戶到角色時出錯。", - "userSaved": "用戶已保存", - "userSavedDescription": "用戶已更新。", - "autoProvisioned": "自動設置", - "autoProvisionedDescription": "允許此用戶由身份提供商自動管理", - "accessControlsDescription": "管理此用戶在組織中可以訪問和做什麼", - "accessControlsSubmit": "保存訪問控制", - "roles": "角色", - "accessUsersRoles": "管理用戶和角色", - "accessUsersRolesDescription": "邀請用戶並將他們添加到角色以管理訪問您的組織", - "key": "關鍵字", - "createdAt": "創建於", - "proxyErrorInvalidHeader": "無效的自訂主機 Header。使用域名格式,或將空保存為取消自訂 Header。", - "proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。", - "proxyEnableSSL": "啟用 SSL", - "proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。", - "target": "Target", - "configureTarget": "配置目標", - "targetErrorFetch": "獲取目標失敗", - "targetErrorFetchDescription": "獲取目標時出錯", - "siteErrorFetch": "獲取資源失敗", - "siteErrorFetchDescription": "獲取資源時出錯", - "targetErrorDuplicate": "重複的目標", - "targetErrorDuplicateDescription": "具有這些設置的目標已存在", - "targetWireGuardErrorInvalidIp": "無效的目標IP", - "targetWireGuardErrorInvalidIpDescription": "目標IP必須在站點子網內", - "targetsUpdated": "目標已更新", - "targetsUpdatedDescription": "目標和設置更新成功", - "targetsErrorUpdate": "更新目標失敗", - "targetsErrorUpdateDescription": "更新目標時出錯", - "targetTlsUpdate": "TLS 設置已更新", - "targetTlsUpdateDescription": "您的 TLS 設置已成功更新", - "targetErrorTlsUpdate": "更新 TLS 設置失敗", - "targetErrorTlsUpdateDescription": "更新 TLS 設置時出錯", - "proxyUpdated": "代理設置已更新", - "proxyUpdatedDescription": "您的代理設置已成功更新", - "proxyErrorUpdate": "更新代理設置失敗", - "proxyErrorUpdateDescription": "更新代理設置時出錯", - "targetAddr": "IP / 域名", - "targetPort": "埠", - "targetProtocol": "協議", - "targetTlsSettings": "安全連接配置", - "targetTlsSettingsDescription": "配置資源的 SSL/TLS 設置", - "targetTlsSettingsAdvanced": "高級TLS設置", - "targetTlsSni": "TLS 伺服器名稱", - "targetTlsSniDescription": "SNI使用的 TLS 伺服器名稱。留空使用預設值。", - "targetTlsSubmit": "保存設置", - "targets": "目標配置", - "targetsDescription": "設置目標來路由流量到您的後端服務", - "targetStickySessions": "啟用置頂會話", - "targetStickySessionsDescription": "將連接保持在同一個後端目標的整個會話中。", - "methodSelect": "選擇方法", - "targetSubmit": "添加目標", - "targetNoOne": "此資源沒有任何目標。添加目標來配置向您後端發送請求的位置。", - "targetNoOneDescription": "在上面添加多個目標將啟用負載平衡。", - "targetsSubmit": "保存目標", - "addTarget": "添加目標", - "targetErrorInvalidIp": "無效的 IP 地址", - "targetErrorInvalidIpDescription": "請輸入有效的IP位址或主機名", - "targetErrorInvalidPort": "無效的埠", - "targetErrorInvalidPortDescription": "請輸入有效的埠號", - "targetErrorNoSite": "沒有選擇站點", - "targetErrorNoSiteDescription": "請選擇目標站點", - "targetCreated": "目標已創建", - "targetCreatedDescription": "目標已成功創建", - "targetErrorCreate": "創建目標失敗", - "targetErrorCreateDescription": "創建目標時出錯", - "save": "保存", - "proxyAdditional": "附加代理設置", - "proxyAdditionalDescription": "配置你的資源如何處理代理設置", - "proxyCustomHeader": "自訂主機 Header", - "proxyCustomHeaderDescription": "代理請求時設置的 Header。留空則使用預設值。", - "proxyAdditionalSubmit": "保存代理設置", - "subnetMaskErrorInvalid": "子網掩碼無效。必須在 0 和 32 之間。", - "ipAddressErrorInvalidFormat": "無效的 IP 地址格式", - "ipAddressErrorInvalidOctet": "無效的 IP 地址", - "path": "路徑", - "matchPath": "匹配路徑", - "ipAddressRange": "IP 範圍", - "rulesErrorFetch": "獲取規則失敗", - "rulesErrorFetchDescription": "獲取規則時出錯", - "rulesErrorDuplicate": "複製規則", - "rulesErrorDuplicateDescription": "帶有這些設置的規則已存在", - "rulesErrorInvalidIpAddressRange": "無效的 CIDR", - "rulesErrorInvalidIpAddressRangeDescription": "請輸入一個有效的 CIDR 值", - "rulesErrorInvalidUrl": "無效的 URL 路徑", - "rulesErrorInvalidUrlDescription": "請輸入一個有效的 URL 路徑值", - "rulesErrorInvalidIpAddress": "無效的 IP", - "rulesErrorInvalidIpAddressDescription": "請輸入一個有效的IP位址", - "rulesErrorUpdate": "更新規則失敗", - "rulesErrorUpdateDescription": "更新規則時出錯", - "rulesUpdated": "啟用規則", - "rulesUpdatedDescription": "規則已更新", - "rulesMatchIpAddressRangeDescription": "以 CIDR 格式輸入地址(如:103.21.244.0/22)", - "rulesMatchIpAddress": "輸入IP位址(例如,103.21.244.12)", - "rulesMatchUrl": "輸入一個 URL 路徑或模式(例如/api/v1/todos 或 /api/v1/*)", - "rulesErrorInvalidPriority": "無效的優先度", - "rulesErrorInvalidPriorityDescription": "請輸入一個有效的優先度", - "rulesErrorDuplicatePriority": "重複的優先度", - "rulesErrorDuplicatePriorityDescription": "請輸入唯一的優先度", - "ruleUpdated": "規則已更新", - "ruleUpdatedDescription": "規則更新成功", - "ruleErrorUpdate": "操作失敗", - "ruleErrorUpdateDescription": "保存過程中發生錯誤", - "rulesPriority": "優先權", - "rulesAction": "行為", - "rulesMatchType": "匹配類型", - "value": "值", - "rulesAbout": "關於規則", - "rulesAboutDescription": "規則使您能夠依據特定條件控制資源訪問權限。您可以創建基於 IP 地址或 URL 路徑的規則,以允許或拒絕訪問。", - "rulesActions": "行動", - "rulesActionAlwaysAllow": "總是允許:繞過所有身份驗證方法", - "rulesActionAlwaysDeny": "總是拒絕:阻止所有請求;無法嘗試驗證", - "rulesActionPassToAuth": "傳遞至認證:允許嘗試身份驗證方法", - "rulesMatchCriteria": "匹配條件", - "rulesMatchCriteriaIpAddress": "匹配一個指定的 IP 地址", - "rulesMatchCriteriaIpAddressRange": "在 CIDR 符號中匹配一系列IP位址", - "rulesMatchCriteriaUrl": "匹配一個 URL 路徑或模式", - "rulesEnable": "啟用規則", - "rulesEnableDescription": "啟用或禁用此資源的規則評估", - "rulesResource": "資源規則配置", - "rulesResourceDescription": "配置規則來控制對您資源的訪問", - "ruleSubmit": "添加規則", - "rulesNoOne": "沒有規則。使用表單添加規則。", - "rulesOrder": "規則按優先順序評定。", - "rulesSubmit": "保存規則", - "resourceErrorCreate": "創建資源時出錯", - "resourceErrorCreateDescription": "創建資源時出錯", - "resourceErrorCreateMessage": "創建資源時發生錯誤:", - "resourceErrorCreateMessageDescription": "發生意外錯誤", - "sitesErrorFetch": "獲取站點出錯", - "sitesErrorFetchDescription": "獲取站點時出錯", - "domainsErrorFetch": "獲取域名出錯", - "domainsErrorFetchDescription": "獲取域時出錯", - "none": "無", - "unknown": "未知", - "resources": "資源", - "resourcesDescription": "資源是您私有網路中運行的應用程式的代理。您可以為私有網路中的任何 HTTP/HTTPS 或 TCP/UDP 服務創建資源。每個資源都必須連接到一個站點,以通過加密的 WireGuard 隧道實現私密且安全的連接。", - "resourcesWireGuardConnect": "採用 WireGuard 提供的加密安全連接", - "resourcesMultipleAuthenticationMethods": "配置多個身份驗證方法", - "resourcesUsersRolesAccess": "基於用戶和角色的訪問控制", - "resourcesErrorUpdate": "切換資源失敗", - "resourcesErrorUpdateDescription": "更新資源時出錯", - "access": "訪問權限", - "shareLink": "{resource} 的分享連結", - "resourceSelect": "選擇資源", - "shareLinks": "分享連結", - "share": "分享連結", - "shareDescription2": "創建資源共享連結。連結提供對資源的臨時或無限制訪問。 當您創建連結時,您可以配置連結的到期時間。", - "shareEasyCreate": "輕鬆創建和分享", - "shareConfigurableExpirationDuration": "可配置的過期時間", - "shareSecureAndRevocable": "安全和可撤銷的", - "nameMin": "名稱長度必須大於 {len} 字元。", - "nameMax": "名稱長度必須小於 {len} 字元。", - "sitesConfirmCopy": "請確認您已經複製了配置。", - "unknownCommand": "未知命令", - "newtErrorFetchReleases": "無法獲取版本資訊: {err}", - "newtErrorFetchLatest": "無法獲取最新版資訊: {err}", - "newtEndpoint": "Newt 端點", - "newtId": "Newt ID", - "newtSecretKey": "Newt 私鑰", - "architecture": "架構", - "sites": "站點", - "siteWgAnyClients": "使用任何 WireGuard 用戶端連接。您必須使用對等IP解決您的內部資源。", - "siteWgCompatibleAllClients": "與所有 WireGuard 用戶端相容", - "siteWgManualConfigurationRequired": "需要手動配置", - "userErrorNotAdminOrOwner": "用戶不是管理員或所有者", - "pangolinSettings": "設置 - Pangolin", - "accessRoleYour": "您的角色:", - "accessRoleSelect2": "選擇角色", - "accessUserSelect": "選擇一個用戶", - "otpEmailEnter": "輸入電子郵件", - "otpEmailEnterDescription": "在輸入欄位輸入後按 Enter 鍵添加電子郵件。", - "otpEmailErrorInvalid": "無效的信箱地址。通配符(*)必須占據整個開頭部分。", - "otpEmailSmtpRequired": "需要先配置 SMTP", - "otpEmailSmtpRequiredDescription": "必須在伺服器上啟用 SMTP 才能使用一次性密碼驗證。", - "otpEmailTitle": "一次性密碼", - "otpEmailTitleDescription": "資源訪問需要基於電子郵件的身份驗證", - "otpEmailWhitelist": "電子郵件白名單", - "otpEmailWhitelistList": "白名單郵件", - "otpEmailWhitelistListDescription": "只有擁有這些電子郵件地址的用戶才能訪問此資源。 他們將被提示輸入一次性密碼發送到他們的電子郵件。 通配符 (*@example.com) 可以用來允許來自一個域名的任何電子郵件地址。", - "otpEmailWhitelistSave": "保存白名單", - "passwordAdd": "添加密碼", - "passwordRemove": "刪除密碼", - "pincodeAdd": "添加 PIN 碼", - "pincodeRemove": "移除 PIN 碼", - "resourceAuthMethods": "身份驗證方法", - "resourceAuthMethodsDescriptions": "允許透過額外的認證方法訪問資源", - "resourceAuthSettingsSave": "保存成功", - "resourceAuthSettingsSaveDescription": "已保存身份驗證設置", - "resourceErrorAuthFetch": "獲取數據失敗", - "resourceErrorAuthFetchDescription": "獲取數據時出錯", - "resourceErrorPasswordRemove": "刪除資源密碼出錯", - "resourceErrorPasswordRemoveDescription": "刪除資源密碼時出錯", - "resourceErrorPasswordSetup": "設置資源密碼出錯", - "resourceErrorPasswordSetupDescription": "設置資源密碼時出錯", - "resourceErrorPincodeRemove": "刪除資源固定碼時出錯", - "resourceErrorPincodeRemoveDescription": "刪除資源PIN碼時出錯", - "resourceErrorPincodeSetup": "設置資源 PIN 碼時出錯", - "resourceErrorPincodeSetupDescription": "設置資源 PIN 碼時發生錯誤", - "resourceErrorUsersRolesSave": "設置角色失敗", - "resourceErrorUsersRolesSaveDescription": "設置角色時出錯", - "resourceErrorWhitelistSave": "保存白名單失敗", - "resourceErrorWhitelistSaveDescription": "保存白名單時出錯", - "resourcePasswordSubmit": "啟用密碼保護", - "resourcePasswordProtection": "密碼保護 {status}", - "resourcePasswordRemove": "已刪除資源密碼", - "resourcePasswordRemoveDescription": "已成功刪除資源密碼", - "resourcePasswordSetup": "設置資源密碼", - "resourcePasswordSetupDescription": "已成功設置資源密碼", - "resourcePasswordSetupTitle": "設置密碼", - "resourcePasswordSetupTitleDescription": "設置密碼來保護此資源", - "resourcePincode": "PIN 碼", - "resourcePincodeSubmit": "啟用 PIN 碼保護", - "resourcePincodeProtection": "PIN 碼保護 {status}", - "resourcePincodeRemove": "資源 PIN 碼已刪除", - "resourcePincodeRemoveDescription": "已成功刪除資源 PIN 碼", - "resourcePincodeSetup": "資源 PIN 碼已設置", - "resourcePincodeSetupDescription": "資源 PIN 碼已成功設置", - "resourcePincodeSetupTitle": "設置 PIN 碼", - "resourcePincodeSetupTitleDescription": "設置 PIN 碼來保護此資源", - "resourceRoleDescription": "管理員總是可以訪問此資源。", - "resourceUsersRoles": "用戶和角色", - "resourceUsersRolesDescription": "配置用戶和角色可以訪問此資源", - "resourceUsersRolesSubmit": "保存用戶和角色", - "resourceWhitelistSave": "保存成功", - "resourceWhitelistSaveDescription": "白名單設置已保存", - "ssoUse": "使用平台 SSO", - "ssoUseDescription": "對於所有啟用此功能的資源,現有用戶只需登錄一次。", - "proxyErrorInvalidPort": "無效的埠號", - "subdomainErrorInvalid": "無效的子域", - "domainErrorFetch": "獲取域名失敗", - "domainErrorFetchDescription": "獲取域名時出錯", - "resourceErrorUpdate": "更新資源失敗", - "resourceErrorUpdateDescription": "更新資源時出錯", - "resourceUpdated": "資源已更新", - "resourceUpdatedDescription": "資源已成功更新", - "resourceErrorTransfer": "轉移資源失敗", - "resourceErrorTransferDescription": "轉移資源時出錯", - "resourceTransferred": "資源已傳輸", - "resourceTransferredDescription": "資源已成功傳輸", - "resourceErrorToggle": "切換資源失敗", - "resourceErrorToggleDescription": "更新資源時出錯", - "resourceVisibilityTitle": "可見性", - "resourceVisibilityTitleDescription": "完全啟用或禁用資源可見性", - "resourceGeneral": "常規設置", - "resourceGeneralDescription": "配置此資源的常規設置", - "resourceEnable": "啟用資源", - "resourceTransfer": "轉移資源", - "resourceTransferDescription": "將此資源轉移到另一個站點", - "resourceTransferSubmit": "轉移資源", - "siteDestination": "目標站點", - "searchSites": "搜索站點", - "accessRoleCreate": "創建角色", - "accessRoleCreateDescription": "創建一個新角色來分組用戶並管理他們的權限。", - "accessRoleCreateSubmit": "創建角色", - "accessRoleCreated": "角色已創建", - "accessRoleCreatedDescription": "角色已成功創建。", - "accessRoleErrorCreate": "創建角色失敗", - "accessRoleErrorCreateDescription": "創建角色時出錯。", - "accessRoleErrorNewRequired": "需要新角色", - "accessRoleErrorRemove": "刪除角色失敗", - "accessRoleErrorRemoveDescription": "刪除角色時出錯。", - "accessRoleName": "角色名稱", - "accessRoleQuestionRemove": "您即將刪除 {name} 角色。 此操作無法撤銷。", - "accessRoleRemove": "刪除角色", - "accessRoleRemoveDescription": "從組織中刪除角色", - "accessRoleRemoveSubmit": "刪除角色", - "accessRoleRemoved": "角色已刪除", - "accessRoleRemovedDescription": "角色已成功刪除。", - "accessRoleRequiredRemove": "刪除此角色之前,請選擇一個新角色來轉移現有成員。", - "manage": "管理", - "sitesNotFound": "未找到站點。", - "pangolinServerAdmin": "伺服器管理員 - Pangolin", - "licenseTierProfessional": "專業許可證", - "licenseTierEnterprise": "企業許可證", - "licenseTierPersonal": "個人許可證", - "licensed": "已授權", - "yes": "是", - "no": "否", - "sitesAdditional": "其他站點", - "licenseKeys": "許可證金鑰", - "sitestCountDecrease": "減少站點數量", - "sitestCountIncrease": "增加站點數量", - "idpManage": "管理身份提供商", - "idpManageDescription": "查看和管理系統中的身份提供商", - "idpDeletedDescription": "身份提供商刪除成功", - "idpOidc": "OAuth2/OIDC", - "idpQuestionRemove": "您確定要永久刪除身份提供者嗎?", - "idpMessageRemove": "這將刪除身份提供者和所有相關的配置。透過此提供者進行身份驗證的用戶將無法登錄。", - "idpMessageConfirm": "要確認,請在下面輸入身份提供者的名稱。", - "idpConfirmDelete": "確認刪除身份提供商", - "idpDelete": "刪除身份提供商", - "idp": "身份提供商", - "idpSearch": "搜索身份提供者...", - "idpAdd": "添加身份提供商", - "idpClientIdRequired": "用戶端 ID 是必需的。", - "idpClientSecretRequired": "用戶端金鑰是必需的。", - "idpErrorAuthUrlInvalid": "身份驗證 URL 必須是有效的 URL。", - "idpErrorTokenUrlInvalid": "令牌 URL 必須是有效的 URL。", - "idpPathRequired": "標識符路徑是必需的。", - "idpScopeRequired": "授權範圍是必需的。", - "idpOidcDescription": "配置 OpenID 連接身份提供商", - "idpCreatedDescription": "身份提供商創建成功", - "idpCreate": "創建身份提供商", - "idpCreateDescription": "配置用戶身份驗證的新身份提供商", - "idpSeeAll": "查看所有身份提供商", - "idpSettingsDescription": "配置身份提供者的基本資訊", - "idpDisplayName": "此身份提供商的顯示名稱", - "idpAutoProvisionUsers": "自動提供用戶", - "idpAutoProvisionUsersDescription": "如果啟用,用戶將在首次登錄時自動在系統中創建,並且能夠映射用戶到角色和組織。", - "licenseBadge": "EE", - "idpType": "提供者類型", - "idpTypeDescription": "選擇您想要配置的身份提供者類型", - "idpOidcConfigure": "OAuth2/OIDC 配置", - "idpOidcConfigureDescription": "配置 OAuth2/OIDC 供應商端點和憑據", - "idpClientId": "用戶端ID", - "idpClientIdDescription": "來自您身份提供商的 OAuth2 用戶端 ID", - "idpClientSecret": "用戶端金鑰", - "idpClientSecretDescription": "來自身份提供商的 OAuth2 用戶端金鑰", - "idpAuthUrl": "授權 URL", - "idpAuthUrlDescription": "OAuth2 授權端點的 URL", - "idpTokenUrl": "令牌 URL", - "idpTokenUrlDescription": "OAuth2 令牌端點的 URL", - "idpOidcConfigureAlert": "重要提示", - "idpOidcConfigureAlertDescription": "創建身份提供方後,您需要在其設置中配置回調 URL。回調 URL 會在創建成功後提供。", - "idpToken": "令牌配置", - "idpTokenDescription": "配置如何從 ID 令牌中提取用戶資訊", - "idpJmespathAbout": "關於 JMESPath", - "idpJmespathAboutDescription": "以下路徑使用 JMESPath 語法從 ID 令牌中提取值。", - "idpJmespathAboutDescriptionLink": "了解更多 JMESPath 資訊", - "idpJmespathLabel": "標識符路徑", - "idpJmespathLabelDescription": "ID 令牌中用戶標識符的路徑", - "idpJmespathEmailPathOptional": "信箱路徑(可選)", - "idpJmespathEmailPathOptionalDescription": "ID 令牌中用戶信箱的路徑", - "idpJmespathNamePathOptional": "使用者名稱路徑(可選)", - "idpJmespathNamePathOptionalDescription": "ID 令牌中使用者名稱的路徑", - "idpOidcConfigureScopes": "作用域(Scopes)", - "idpOidcConfigureScopesDescription": "以空格分隔的 OAuth2 請求作用域列表", - "idpSubmit": "創建身份提供商", - "orgPolicies": "組織策略", - "idpSettings": "{idpName} 設置", - "idpCreateSettingsDescription": "配置身份提供商的設置", - "roleMapping": "角色映射", - "orgMapping": "組織映射", - "orgPoliciesSearch": "搜索組織策略...", - "orgPoliciesAdd": "添加組織策略", - "orgRequired": "組織是必填項", - "error": "錯誤", - "success": "成功", - "orgPolicyAddedDescription": "策略添加成功", - "orgPolicyUpdatedDescription": "策略更新成功", - "orgPolicyDeletedDescription": "已成功刪除策略", - "defaultMappingsUpdatedDescription": "默認映射更新成功", - "orgPoliciesAbout": "關於組織政策", - "orgPoliciesAboutDescription": "組織策略用於根據用戶的 ID 令牌來控制對組織的訪問。 您可以指定 JMESPath 表達式來提取角色和組織資訊從 ID 令牌中提取資訊。", - "orgPoliciesAboutDescriptionLink": "欲了解更多資訊,請參閱文件。", - "defaultMappingsOptional": "默認映射(可選)", - "defaultMappingsOptionalDescription": "當沒有為某個組織定義組織的政策時,使用默認映射。 您可以指定默認角色和組織映射回到這裡。", - "defaultMappingsRole": "默認角色映射", - "defaultMappingsRoleDescription": "此表達式的結果必須返回組織中定義的角色名稱作為字串。", - "defaultMappingsOrg": "默認組織映射", - "defaultMappingsOrgDescription": "此表達式必須返回 組織ID 或 true 才能允許用戶訪問組織。", - "defaultMappingsSubmit": "保存默認映射", - "orgPoliciesEdit": "編輯組織策略", - "org": "組織", - "orgSelect": "選擇組織", - "orgSearch": "搜索", - "orgNotFound": "找不到組織。", - "roleMappingPathOptional": "角色映射路徑(可選)", - "orgMappingPathOptional": "組織映射路徑(可選)", - "orgPolicyUpdate": "更新策略", - "orgPolicyAdd": "添加策略", - "orgPolicyConfig": "配置組織訪問權限", - "idpUpdatedDescription": "身份提供商更新成功", - "redirectUrl": "重定向網址", - "redirectUrlAbout": "關於重定向網址", - "redirectUrlAboutDescription": "這是用戶在驗證後將被重定向到的URL。您需要在身份提供商設置中配置此URL。", - "pangolinAuth": "認證 - Pangolin", - "verificationCodeLengthRequirements": "您的驗證碼必須是 8 個字元。", - "errorOccurred": "發生錯誤", - "emailErrorVerify": "驗證電子郵件失敗:", - "emailVerified": "電子郵件驗證成功!重定向您...", - "verificationCodeErrorResend": "無法重新發送驗證碼:", - "verificationCodeResend": "驗證碼已重新發送", - "verificationCodeResendDescription": "我們已將驗證碼重新發送到您的電子郵件地址。請檢查您的收件箱。", - "emailVerify": "驗證電子郵件", - "emailVerifyDescription": "輸入驗證碼發送到您的電子郵件地址。", - "verificationCode": "驗證碼", - "verificationCodeEmailSent": "我們向您的電子郵件地址發送了驗證碼。", - "submit": "提交", - "emailVerifyResendProgress": "正在重新發送...", - "emailVerifyResend": "沒有收到代碼?點擊此處重新發送", - "passwordNotMatch": "密碼不匹配", - "signupError": "註冊時出錯", - "pangolinLogoAlt": "Pangolin 標誌", - "inviteAlready": "看起來您已被邀請!", - "inviteAlreadyDescription": "要接受邀請,您必須登錄或創建一個帳戶。", - "signupQuestion": "已經有一個帳戶?", - "login": "登錄", - "resourceNotFound": "找不到資源", - "resourceNotFoundDescription": "您要訪問的資源不存在。", - "pincodeRequirementsLength": "PIN碼必須是 6 位數字", - "pincodeRequirementsChars": "PIN 必須只包含數字", - "passwordRequirementsLength": "密碼必須至少 1 個字元長", - "passwordRequirementsTitle": "密碼要求:", - "passwordRequirementLength": "至少 8 個字元長", - "passwordRequirementUppercase": "至少一個大寫字母", - "passwordRequirementLowercase": "至少一個小寫字母", - "passwordRequirementNumber": "至少一個數字", - "passwordRequirementSpecial": "至少一個特殊字元", - "passwordRequirementsMet": "✓ 密碼滿足所有要求", - "passwordStrength": "密碼強度", - "passwordStrengthWeak": "弱", - "passwordStrengthMedium": "中", - "passwordStrengthStrong": "強", - "passwordRequirements": "要求:", - "passwordRequirementLengthText": "8+ 個字元", - "passwordRequirementUppercaseText": "大寫字母 (A-Z)", - "passwordRequirementLowercaseText": "小寫字母 (a-z)", - "passwordRequirementNumberText": "數字 (0-9)", - "passwordRequirementSpecialText": "特殊字元 (!@#$%...)", - "passwordsDoNotMatch": "密碼不匹配", - "otpEmailRequirementsLength": "OTP 必須至少 1 個字元長", - "otpEmailSent": "OTP 已發送", - "otpEmailSentDescription": "OTP 已經發送到您的電子郵件", - "otpEmailErrorAuthenticate": "通過電子郵件身份驗證失敗", - "pincodeErrorAuthenticate": "Pincode 驗證失敗", - "passwordErrorAuthenticate": "密碼驗證失敗", - "poweredBy": "支持者:", - "authenticationRequired": "需要身份驗證", - "authenticationMethodChoose": "請選擇您偏好的方式來訪問 {name}", - "authenticationRequest": "您必須通過身份驗證才能訪問 {name}", - "user": "用戶", - "pincodeInput": "6 位數字 PIN 碼", - "pincodeSubmit": "使用 PIN 登錄", - "passwordSubmit": "使用密碼登錄", - "otpEmailDescription": "一次性代碼將發送到此電子郵件。", - "otpEmailSend": "發送一次性代碼", - "otpEmail": "一次性密碼 (OTP)", - "otpEmailSubmit": "提交 OTP", - "backToEmail": "回到電子郵件", - "noSupportKey": "伺服器當前未使用支持者金鑰,歡迎支持本項目!", - "accessDenied": "訪問被拒絕", - "accessDeniedDescription": "當前帳戶無權訪問此資源。如認為這是錯誤,請與管理員聯繫。", - "accessTokenError": "檢查訪問令牌時出錯", - "accessGranted": "已授予訪問", - "accessUrlInvalid": "訪問 URL 無效", - "accessGrantedDescription": "您已獲准訪問此資源,正在為您跳轉...", - "accessUrlInvalidDescription": "此共享訪問URL無效。請聯絡資源所有者獲取新URL。", - "tokenInvalid": "無效的令牌", - "pincodeInvalid": "無效的代碼", - "passwordErrorRequestReset": "請求重設失敗:", - "passwordErrorReset": "重設密碼失敗:", - "passwordResetSuccess": "密碼重設成功!返回登錄...", - "passwordReset": "重設密碼", - "passwordResetDescription": "按照步驟重設您的密碼", - "passwordResetSent": "我們將發送一個驗證碼到這個電子郵件地址。", - "passwordResetCode": "驗證碼", - "passwordResetCodeDescription": "請檢查您的電子郵件以獲取驗證碼。", - "passwordNew": "新密碼", - "passwordNewConfirm": "確認新密碼", - "changePassword": "更改密碼", - "changePasswordDescription": "更新您的帳戶密碼", - "oldPassword": "當前密碼", - "newPassword": "新密碼", - "confirmNewPassword": "確認新密碼", - "changePasswordError": "更改密碼失敗", - "changePasswordErrorDescription": "更改您的密碼時出錯", - "changePasswordSuccess": "密碼修改成功", - "changePasswordSuccessDescription": "您的密碼已成功更新", - "passwordExpiryRequired": "需要密碼過期", - "passwordExpiryDescription": "該機構要求您每 {maxDays} 天更改一次密碼。", - "changePasswordNow": "現在更改密碼", - "pincodeAuth": "驗證器代碼", - "pincodeSubmit2": "提交代碼", - "passwordResetSubmit": "請求重設", - "passwordBack": "回到密碼", - "loginBack": "返回登錄", - "signup": "註冊", - "loginStart": "登錄以開始", - "idpOidcTokenValidating": "正在驗證 OIDC 令牌", - "idpOidcTokenResponse": "驗證 OIDC 令牌響應", - "idpErrorOidcTokenValidating": "驗證 OIDC 令牌出錯", - "idpConnectingTo": "連接到{name}", - "idpConnectingToDescription": "正在驗證您的身份", - "idpConnectingToProcess": "正在連接...", - "idpConnectingToFinished": "已連接", - "idpErrorConnectingTo": "無法連接到 {name},請聯絡管理員協助處理。", - "idpErrorNotFound": "找不到 IdP", - "inviteInvalid": "無效邀請", - "inviteInvalidDescription": "邀請連結無效。", - "inviteErrorWrongUser": "邀請不是該用戶的", - "inviteErrorUserNotExists": "用戶不存在。請先創建帳戶。", - "inviteErrorLoginRequired": "您必須登錄才能接受邀請", - "inviteErrorExpired": "邀請可能已過期", - "inviteErrorRevoked": "邀請可能已被吊銷了", - "inviteErrorTypo": "邀請連結中可能有一個類型", - "pangolinSetup": "認證 - Pangolin", - "orgNameRequired": "組織名稱是必需的", - "orgIdRequired": "組織ID是必需的", - "orgErrorCreate": "創建組織時出錯", - "pageNotFound": "找不到頁面", - "pageNotFoundDescription": "哎呀!您正在尋找的頁面不存在。", - "overview": "概覽", - "home": "首頁", - "accessControl": "訪問控制", - "settings": "設置", - "usersAll": "所有用戶", - "license": "許可協議", - "pangolinDashboard": "儀錶板 - Pangolin", - "noResults": "未找到任何結果。", - "terabytes": "{count} TB", - "gigabytes": "{count} GB", - "megabytes": "{count} MB", - "tagsEntered": "已輸入的標籤", - "tagsEnteredDescription": "這些是您輸入的標籤。", - "tagsWarnCannotBeLessThanZero": "最大標籤和最小標籤不能小於 0", - "tagsWarnNotAllowedAutocompleteOptions": "標記不允許為每個自動完成選項", - "tagsWarnInvalid": "無效的標籤,每個有效標籤", - "tagWarnTooShort": "標籤 {tagText} 太短", - "tagWarnTooLong": "標籤 {tagText} 太長", - "tagsWarnReachedMaxNumber": "已達到允許標籤的最大數量", - "tagWarnDuplicate": "未添加重複標籤 {tagText}", - "supportKeyInvalid": "無效金鑰", - "supportKeyInvalidDescription": "您的支持者金鑰無效。", - "supportKeyValid": "有效的金鑰", - "supportKeyValidDescription": "您的支持者金鑰已被驗證。感謝您的支持!", - "supportKeyErrorValidationDescription": "驗證支持者金鑰失敗。", - "supportKey": "支持開發和通過一個 Pangolin !", - "supportKeyDescription": "購買支持者鑰匙,幫助我們繼續為社區發展 Pangolin 。 您的貢獻使我們能夠投入更多的時間來維護和添加所有人的新功能。 我們永遠不會用這個來支付牆上的功能。這與任何商業版是分開的。", - "supportKeyPet": "您還可以領養並見到屬於自己的 Pangolin!", - "supportKeyPurchase": "付款通過 GitHub 進行處理,之後您可以在以下位置獲取您的金鑰:", - "supportKeyPurchaseLink": "我們的網站", - "supportKeyPurchase2": "並在這裡兌換。", - "supportKeyLearnMore": "了解更多。", - "supportKeyOptions": "請選擇最適合您的選項。", - "supportKetOptionFull": "完全支持者", - "forWholeServer": "適用於整個伺服器", - "lifetimePurchase": "終身購買", - "supporterStatus": "支持者狀態", - "buy": "購買", - "supportKeyOptionLimited": "有限支持者", - "forFiveUsers": "適用於 5 或更少用戶", - "supportKeyRedeem": "兌換支持者金鑰", - "supportKeyHideSevenDays": "隱藏 7 天", - "supportKeyEnter": "輸入支持者金鑰", - "supportKeyEnterDescription": "見到你自己的 Pangolin!", - "githubUsername": "GitHub 使用者名稱", - "supportKeyInput": "支持者金鑰", - "supportKeyBuy": "購買支持者金鑰", - "logoutError": "註銷錯誤", - "signingAs": "登錄為", - "serverAdmin": "伺服器管理員", - "managedSelfhosted": "託管自託管", - "otpEnable": "啟用雙因子認證", - "otpDisable": "禁用雙因子認證", - "logout": "登出", - "licenseTierProfessionalRequired": "需要專業版", - "licenseTierProfessionalRequiredDescription": "此功能僅在專業版可用。", - "actionGetOrg": "獲取組織", - "updateOrgUser": "更新組織用戶", - "createOrgUser": "創建組織用戶", - "actionUpdateOrg": "更新組織", - "actionUpdateUser": "更新用戶", - "actionGetUser": "獲取用戶", - "actionGetOrgUser": "獲取組織用戶", - "actionListOrgDomains": "列出組織域", - "actionCreateSite": "創建站點", - "actionDeleteSite": "刪除站點", - "actionGetSite": "獲取站點", - "actionListSites": "站點列表", - "actionApplyBlueprint": "應用藍圖", - "setupToken": "設置令牌", - "setupTokenDescription": "從伺服器控制台輸入設定令牌。", - "setupTokenRequired": "需要設置令牌", - "actionUpdateSite": "更新站點", - "actionListSiteRoles": "允許站點角色列表", - "actionCreateResource": "創建資源", - "actionDeleteResource": "刪除資源", - "actionGetResource": "獲取資源", - "actionListResource": "列出資源", - "actionUpdateResource": "更新資源", - "actionListResourceUsers": "列出資源用戶", - "actionSetResourceUsers": "設置資源用戶", - "actionSetAllowedResourceRoles": "設置允許的資源角色", - "actionListAllowedResourceRoles": "列出允許的資源角色", - "actionSetResourcePassword": "設置資源密碼", - "actionSetResourcePincode": "設置資源粉碼", - "actionSetResourceEmailWhitelist": "設置資源電子郵件白名單", - "actionGetResourceEmailWhitelist": "獲取資源電子郵件白名單", - "actionCreateTarget": "創建目標", - "actionDeleteTarget": "刪除目標", - "actionGetTarget": "獲取目標", - "actionListTargets": "列表目標", - "actionUpdateTarget": "更新目標", - "actionCreateRole": "創建角色", - "actionDeleteRole": "刪除角色", - "actionGetRole": "獲取角色", - "actionListRole": "角色列表", - "actionUpdateRole": "更新角色", - "actionListAllowedRoleResources": "列表允許的角色資源", - "actionInviteUser": "邀請用戶", - "actionRemoveUser": "刪除用戶", - "actionListUsers": "列出用戶", - "actionAddUserRole": "添加用戶角色", - "actionGenerateAccessToken": "生成訪問令牌", - "actionDeleteAccessToken": "刪除訪問令牌", - "actionListAccessTokens": "訪問令牌", - "actionCreateResourceRule": "創建資源規則", - "actionDeleteResourceRule": "刪除資源規則", - "actionListResourceRules": "列出資源規則", - "actionUpdateResourceRule": "更新資源規則", - "actionListOrgs": "列出組織", - "actionCheckOrgId": "檢查組織ID", - "actionCreateOrg": "創建組織", - "actionDeleteOrg": "刪除組織", - "actionListApiKeys": "列出 API 金鑰", - "actionListApiKeyActions": "列出 API 金鑰動作", - "actionSetApiKeyActions": "設置 API 金鑰允許的操作", - "actionCreateApiKey": "創建 API 金鑰", - "actionDeleteApiKey": "刪除 API 金鑰", - "actionCreateIdp": "創建 IDP", - "actionUpdateIdp": "更新 IDP", - "actionDeleteIdp": "刪除 IDP", - "actionListIdps": "列出 IDP", - "actionGetIdp": "獲取 IDP", - "actionCreateIdpOrg": "創建 IDP 組織策略", - "actionDeleteIdpOrg": "刪除 IDP 組織策略", - "actionListIdpOrgs": "列出 IDP 組織", - "actionUpdateIdpOrg": "更新 IDP 組織", - "actionCreateClient": "創建用戶端", - "actionDeleteClient": "刪除用戶端", - "actionUpdateClient": "更新用戶端", - "actionListClients": "列出用戶端", - "actionGetClient": "獲取用戶端", - "actionCreateSiteResource": "創建站點資源", - "actionDeleteSiteResource": "刪除站點資源", - "actionGetSiteResource": "獲取站點資源", - "actionListSiteResources": "列出站點資源", - "actionUpdateSiteResource": "更新站點資源", - "actionListInvitations": "邀請列表", - "noneSelected": "未選擇", - "orgNotFound2": "未找到組織。", - "searchProgress": "搜索中...", - "create": "創建", - "orgs": "組織", - "loginError": "登錄時出錯", - "passwordForgot": "忘記密碼?", - "otpAuth": "兩步驗證", - "otpAuthDescription": "從您的身份驗證程序中輸入代碼或您的單次備份代碼。", - "otpAuthSubmit": "提交代碼", - "idpContinue": "或者繼續", - "otpAuthBack": "返回登錄", - "navbar": "導航菜單", - "navbarDescription": "應用程式的主導航菜單", - "navbarDocsLink": "文件", - "otpErrorEnable": "無法啟用 2FA", - "otpErrorEnableDescription": "啟用 2FA 時出錯", - "otpSetupCheckCode": "請輸入您的 6 位數字代碼", - "otpSetupCheckCodeRetry": "無效的代碼。請重試。", - "otpSetup": "啟用兩步驗證", - "otpSetupDescription": "用額外的保護層來保護您的帳戶", - "otpSetupScanQr": "用您的身份驗證程序掃描此二維碼或手動輸入金鑰:", - "otpSetupSecretCode": "驗證器代碼", - "otpSetupSuccess": "啟用兩步驗證", - "otpSetupSuccessStoreBackupCodes": "您的帳戶現在更加安全。不要忘記保存您的備份代碼。", - "otpErrorDisable": "無法禁用 2FA", - "otpErrorDisableDescription": "禁用 2FA 時出錯", - "otpRemove": "禁用兩步驗證", - "otpRemoveDescription": "為您的帳戶禁用兩步驗證", - "otpRemoveSuccess": "雙重身份驗證已禁用", - "otpRemoveSuccessMessage": "您的帳戶已禁用雙重身份驗證。您可以隨時再次啟用它。", - "otpRemoveSubmit": "禁用兩步驗證", - "paginator": "第 {current} 頁,共 {last} 頁", - "paginatorToFirst": "轉到第一頁", - "paginatorToPrevious": "轉到上一頁", - "paginatorToNext": "轉到下一頁", - "paginatorToLast": "轉到最後一頁", - "copyText": "複製文本", - "copyTextFailed": "複製文本失敗: ", - "copyTextClipboard": "複製到剪貼簿", - "inviteErrorInvalidConfirmation": "無效確認", - "passwordRequired": "必須填寫密碼", - "allowAll": "允許所有", - "permissionsAllowAll": "允許所有權限", - "githubUsernameRequired": "必須填寫 GitHub 使用者名稱", - "supportKeyRequired": "必須填寫支持者金鑰", - "passwordRequirementsChars": "密碼至少需要 8 個字元", - "language": "語言", - "verificationCodeRequired": "必須輸入代碼", - "userErrorNoUpdate": "沒有要更新的用戶", - "siteErrorNoUpdate": "沒有要更新的站點", - "resourceErrorNoUpdate": "沒有可更新的資源", - "authErrorNoUpdate": "沒有要更新的身份驗證資訊", - "orgErrorNoUpdate": "沒有要更新的組織", - "orgErrorNoProvided": "未提供組織", - "apiKeysErrorNoUpdate": "沒有要更新的 API 金鑰", - "sidebarOverview": "概覽", - "sidebarHome": "首頁", - "sidebarSites": "站點", - "sidebarResources": "資源", - "sidebarAccessControl": "訪問控制", - "sidebarUsers": "用戶", - "sidebarInvitations": "邀請", - "sidebarRoles": "角色", - "sidebarShareableLinks": "分享連結", - "sidebarApiKeys": "API 金鑰", - "sidebarSettings": "設置", - "sidebarAllUsers": "所有用戶", - "sidebarIdentityProviders": "身份提供商", - "sidebarLicense": "證書", - "sidebarClients": "用戶端", - "sidebarDomains": "域", - "sidebarBluePrints": "藍圖", - "blueprints": "藍圖", - "blueprintsDescription": "應用聲明配置並查看先前運行的", - "blueprintAdd": "添加藍圖", - "blueprintGoBack": "查看所有藍圖", - "blueprintCreate": "創建藍圖", - "blueprintCreateDescription2": "按照下面的步驟創建和應用新的藍圖", - "blueprintDetails": "藍圖詳細資訊", - "blueprintDetailsDescription": "查看應用藍圖的結果和發生的任何錯誤", - "blueprintInfo": "藍圖資訊", - "message": "留言", - "blueprintContentsDescription": "定義描述您基礎設施的 YAML 內容", - "blueprintErrorCreateDescription": "應用藍圖時出錯", - "blueprintErrorCreate": "創建藍圖時出錯", - "searchBlueprintProgress": "搜索藍圖...", - "appliedAt": "應用於", - "source": "來源", - "contents": "目錄", - "parsedContents": "解析內容 (只讀)", - "enableDockerSocket": "啟用 Docker 藍圖", - "enableDockerSocketDescription": "啟用 Docker Socket 標籤擦除藍圖標籤。套接字路徑必須提供給新的。", - "enableDockerSocketLink": "了解更多", - "viewDockerContainers": "查看停靠容器", - "containersIn": "{siteName} 中的容器", - "selectContainerDescription": "選擇任何容器作為目標的主機名。點擊埠使用埠。", - "containerName": "名稱", - "containerImage": "圖片", - "containerState": "狀態", - "containerNetworks": "網路", - "containerHostnameIp": "主機名/IP", - "containerLabels": "標籤", - "containerLabelsCount": "{count, plural, other {# 標籤}}", - "containerLabelsTitle": "容器標籤", - "containerLabelEmpty": "<為空>", - "containerPorts": "埠", - "containerPortsMore": "+{count} 更多", - "containerActions": "行動", - "select": "選擇", - "noContainersMatchingFilters": "沒有找到匹配當前過濾器的容器。", - "showContainersWithoutPorts": "顯示沒有埠的容器", - "showStoppedContainers": "顯示已停止的容器", - "noContainersFound": "未找到容器。請確保 Docker 容器正在運行。", - "searchContainersPlaceholder": "在 {count} 個容器中搜索...", - "searchResultsCount": "{count, plural, other {# 個結果}}", - "filters": "篩選器", - "filterOptions": "過濾器選項", - "filterPorts": "埠", - "filterStopped": "已停止", - "clearAllFilters": "清除所有過濾器", - "columns": "列", - "toggleColumns": "切換列", - "refreshContainersList": "刷新容器列表", - "searching": "搜索中...", - "noContainersFoundMatching": "未找到與 \"{filter}\" 匹配的容器。", - "light": "淺色", - "dark": "深色", - "system": "系統", - "theme": "主題", - "subnetRequired": "子網是必填項", - "initialSetupTitle": "初始伺服器設置", - "initialSetupDescription": "創建初始伺服器管理員帳戶。 只能存在一個伺服器管理員。 您可以隨時更改這些憑據。", - "createAdminAccount": "創建管理員帳戶", - "setupErrorCreateAdmin": "創建伺服器管理員帳戶時發生錯誤。", - "certificateStatus": "證書狀態", - "loading": "載入中", - "restart": "重啟", - "domains": "域", - "domainsDescription": "管理您的組織域", - "domainsSearch": "搜索域...", - "domainAdd": "添加域", - "domainAddDescription": "在您的組織中註冊新域", - "domainCreate": "創建域", - "domainCreatedDescription": "域創建成功", - "domainDeletedDescription": "成功刪除域", - "domainQuestionRemove": "您確定要從您的帳戶中刪除域名嗎?", - "domainMessageRemove": "移除後,該域將不再與您的帳戶關聯。", - "domainConfirmDelete": "確認刪除域", - "domainDelete": "刪除域", - "domain": "域", - "selectDomainTypeNsName": "域委派(NS)", - "selectDomainTypeNsDescription": "此域及其所有子域。當您希望控制整個域區域時使用此選項。", - "selectDomainTypeCnameName": "單個域(CNAME)", - "selectDomainTypeCnameDescription": "僅此特定域。用於單個子域或特定域條目。", - "selectDomainTypeWildcardName": "通配符域", - "selectDomainTypeWildcardDescription": "此域名及其子域名。", - "domainDelegation": "單個域", - "selectType": "選擇一個類型", - "actions": "操作", - "refresh": "刷新", - "refreshError": "刷新數據失敗", - "verified": "已驗證", - "pending": "待定", - "sidebarBilling": "計費", - "billing": "計費", - "orgBillingDescription": "管理您的帳單資訊和訂閱", - "github": "GitHub", - "pangolinHosted": "Pangolin 託管", - "fossorial": "Fossorial", - "completeAccountSetup": "完成帳戶設定", - "completeAccountSetupDescription": "設置您的密碼以開始", - "accountSetupSent": "我們將發送帳號設定代碼到該電子郵件地址。", - "accountSetupCode": "設置代碼", - "accountSetupCodeDescription": "請檢查您的信箱以獲取設置代碼。", - "passwordCreate": "創建密碼", - "passwordCreateConfirm": "確認密碼", - "accountSetupSubmit": "發送設置代碼", - "completeSetup": "完成設置", - "accountSetupSuccess": "帳號設定完成!歡迎來到 Pangolin!", - "documentation": "文件", - "saveAllSettings": "保存所有設置", - "settingsUpdated": "設置已更新", - "settingsUpdatedDescription": "所有設置已成功更新", - "settingsErrorUpdate": "設置更新失敗", - "settingsErrorUpdateDescription": "更新設置時發生錯誤", - "sidebarCollapse": "摺疊", - "sidebarExpand": "展開", - "newtUpdateAvailable": "更新可用", - "newtUpdateAvailableInfo": "新版本的 Newt 已可用。請更新到最新版本以獲得最佳體驗。", - "domainPickerEnterDomain": "域名", - "domainPickerPlaceholder": "example.com", - "domainPickerDescription": "輸入資源的完整域名以查看可用選項。", - "domainPickerDescriptionSaas": "輸入完整域名、子域或名稱以查看可用選項。", - "domainPickerTabAll": "所有", - "domainPickerTabOrganization": "組織", - "domainPickerTabProvided": "提供的", - "domainPickerSortAsc": "A-Z", - "domainPickerSortDesc": "Z-A", - "domainPickerCheckingAvailability": "檢查可用性...", - "domainPickerNoMatchingDomains": "未找到匹配的域名。嘗試不同的域名或檢查您組織的域名設置。", - "domainPickerOrganizationDomains": "組織域", - "domainPickerProvidedDomains": "提供的域", - "domainPickerSubdomain": "子域:{subdomain}", - "domainPickerNamespace": "命名空間:{namespace}", - "domainPickerShowMore": "顯示更多", - "regionSelectorTitle": "選擇區域", - "regionSelectorInfo": "選擇區域以幫助提升您所在地的性能。您不必與伺服器在相同的區域。", - "regionSelectorPlaceholder": "選擇一個區域", - "regionSelectorComingSoon": "即將推出", - "billingLoadingSubscription": "正在載入訂閱...", - "billingFreeTier": "免費層", - "billingWarningOverLimit": "警告:您已超出一個或多個使用限制。在您修改訂閱或調整使用情況之前,您的站點將無法連接。", - "billingUsageLimitsOverview": "使用限制概覽", - "billingMonitorUsage": "監控您的使用情況以對比已配置的限制。如需提高限制請聯絡我們 support@pangolin.net。", - "billingDataUsage": "數據使用情況", - "billingOnlineTime": "站點在線時間", - "billingUsers": "活躍用戶", - "billingDomains": "活躍域", - "billingRemoteExitNodes": "活躍自託管節點", - "billingNoLimitConfigured": "未配置限制", - "billingEstimatedPeriod": "估計結算週期", - "billingIncludedUsage": "包含的使用量", - "billingIncludedUsageDescription": "您當前訂閱計劃中包含的使用量", - "billingFreeTierIncludedUsage": "免費層使用額度", - "billingIncluded": "包含", - "billingEstimatedTotal": "預計總額:", - "billingNotes": "備註", - "billingEstimateNote": "這是根據您當前使用情況的估算。", - "billingActualChargesMayVary": "實際費用可能會有變化。", - "billingBilledAtEnd": "您將在結算週期結束時被計費。", - "billingModifySubscription": "修改訂閱", - "billingStartSubscription": "開始訂閱", - "billingRecurringCharge": "週期性收費", - "billingManageSubscriptionSettings": "管理您的訂閱設置和偏好", - "billingNoActiveSubscription": "您沒有活躍的訂閱。開始訂閱以增加使用限制。", - "billingFailedToLoadSubscription": "無法載入訂閱", - "billingFailedToLoadUsage": "無法載入使用情況", - "billingFailedToGetCheckoutUrl": "無法獲取結帳網址", - "billingPleaseTryAgainLater": "請稍後再試。", - "billingCheckoutError": "結帳錯誤", - "billingFailedToGetPortalUrl": "無法獲取門戶網址", - "billingPortalError": "門戶錯誤", - "billingDataUsageInfo": "當連接到雲端時,您將為透過安全隧道傳輸的所有數據收取費用。 這包括您所有站點的進出流量。 當您達到上限時,您的站點將斷開連接,直到您升級計劃或減少使用。使用節點時不收取數據。", - "billingOnlineTimeInfo": "您要根據您的網站連接到雲端的時間長短收取費用。 例如,44,640 分鐘等於一個 24/7 全月運行的網站。 當您達到上限時,您的站點將斷開連接,直到您升級計劃或減少使用。使用節點時不收取費用。", - "billingUsersInfo": "根據您組織中的活躍用戶數量收費。按日計算帳單。", - "billingDomainInfo": "根據組織中活躍域的數量收費。按日計算帳單。", - "billingRemoteExitNodesInfo": "根據您組織中已管理節點的數量收費。按日計算帳單。", - "domainNotFound": "域未找到", - "domainNotFoundDescription": "此資源已禁用,因為該域不再在我們的系統中存在。請為此資源設置一個新域。", - "failed": "失敗", - "createNewOrgDescription": "創建一個新組織", - "organization": "組織", - "port": "埠", - "securityKeyManage": "管理安全金鑰", - "securityKeyDescription": "添加或刪除用於無密碼認證的安全金鑰", - "securityKeyRegister": "註冊新的安全金鑰", - "securityKeyList": "您的安全金鑰", - "securityKeyNone": "尚未註冊安全金鑰", - "securityKeyNameRequired": "名稱為必填項", - "securityKeyRemove": "刪除", - "securityKeyLastUsed": "上次使用:{date}", - "securityKeyNameLabel": "名稱", - "securityKeyRegisterSuccess": "安全金鑰註冊成功", - "securityKeyRegisterError": "註冊安全金鑰失敗", - "securityKeyRemoveSuccess": "安全金鑰刪除成功", - "securityKeyRemoveError": "刪除安全金鑰失敗", - "securityKeyLoadError": "載入安全金鑰失敗", - "securityKeyLogin": "使用安全金鑰繼續", - "securityKeyAuthError": "使用安全金鑰認證失敗", - "securityKeyRecommendation": "考慮在其他設備上註冊另一個安全金鑰,以確保不會被鎖定在您的帳戶之外。", - "registering": "註冊中...", - "securityKeyPrompt": "請使用您的安全金鑰驗證身份。確保您的安全金鑰已連接並準備好。", - "securityKeyBrowserNotSupported": "您的瀏覽器不支持安全金鑰。請使用像 Chrome、Firefox 或 Safari 這樣的現代瀏覽器。", - "securityKeyPermissionDenied": "請允許訪問您的安全金鑰以繼續登錄。", - "securityKeyRemovedTooQuickly": "請保持您的安全金鑰連接,直到登錄過程完成。", - "securityKeyNotSupported": "您的安全金鑰可能不相容。請嘗試不同的安全金鑰。", - "securityKeyUnknownError": "使用安全金鑰時出現問題。請再試一次。", - "twoFactorRequired": "註冊安全金鑰需要兩步驗證。", - "twoFactor": "兩步驗證", - "twoFactorAuthentication": "兩步驗證", - "twoFactorDescription": "這個組織需要雙重身份驗證。", - "enableTwoFactor": "啟用兩步驗證", - "organizationSecurityPolicy": "組織安全政策", - "organizationSecurityPolicyDescription": "此機構擁有安全要求,您必須先滿足才能訪問", - "securityRequirements": "安全要求", - "allRequirementsMet": "已滿足所有要求", - "completeRequirementsToContinue": "完成下面的要求以繼續訪問此組織", - "youCanNowAccessOrganization": "您現在可以訪問此組織", - "reauthenticationRequired": "會話長度", - "reauthenticationDescription": "該機構要求您每 {maxDays} 天登錄一次。", - "reauthenticationDescriptionHours": "該機構要求您每 {maxHours} 小時登錄一次。", - "reauthenticateNow": "再次登錄", - "adminEnabled2FaOnYourAccount": "管理員已為 {email} 啟用兩步驗證。請完成設置以繼續。", - "securityKeyAdd": "添加安全金鑰", - "securityKeyRegisterTitle": "註冊新安全金鑰", - "securityKeyRegisterDescription": "連接您的安全金鑰並輸入名稱以便識別", - "securityKeyTwoFactorRequired": "要求兩步驗證", - "securityKeyTwoFactorDescription": "請輸入你的兩步驗證代碼以註冊安全金鑰", - "securityKeyTwoFactorRemoveDescription": "請輸入你的兩步驗證代碼以移除安全金鑰", - "securityKeyTwoFactorCode": "雙因素代碼", - "securityKeyRemoveTitle": "移除安全金鑰", - "securityKeyRemoveDescription": "輸入您的密碼以移除安全金鑰 \"{name}\"", - "securityKeyNoKeysRegistered": "沒有註冊安全金鑰", - "securityKeyNoKeysDescription": "添加安全金鑰以加強您的帳戶安全", - "createDomainRequired": "必須輸入域", - "createDomainAddDnsRecords": "添加 DNS 記錄", - "createDomainAddDnsRecordsDescription": "將以下 DNS 記錄添加到您的域名提供商以完成設置。", - "createDomainNsRecords": "NS 記錄", - "createDomainRecord": "記錄", - "createDomainType": "類型:", - "createDomainName": "名稱:", - "createDomainValue": "值:", - "createDomainCnameRecords": "CNAME 記錄", - "createDomainARecords": "A記錄", - "createDomainRecordNumber": "記錄 {number}", - "createDomainTxtRecords": "TXT 記錄", - "createDomainSaveTheseRecords": "保存這些記錄", - "createDomainSaveTheseRecordsDescription": "務必保存這些 DNS 記錄,因為您將無法再次查看它們。", - "createDomainDnsPropagation": "DNS 傳播", - "createDomainDnsPropagationDescription": "DNS 更改可能需要一些時間才能在網路上傳播。這可能需要從幾分鐘到 48 小時,具體取決於您的 DNS 提供商和 TTL 設置。", - "resourcePortRequired": "非 HTTP 資源必須輸入埠號", - "resourcePortNotAllowed": "HTTP 資源不應設置埠號", - "billingPricingCalculatorLink": "價格計算機", - "signUpTerms": { - "IAgreeToThe": "我同意", - "termsOfService": "服務條款", - "and": "和", - "privacyPolicy": "隱私政策" + "setupCreate": "創建您的第一個組織、網站和資源", + "headerAuthCompatibilityInfo": "啟用此選項以在缺少驗證令牌時強制回傳 401 未授權回應。這對於不會在沒有伺服器挑戰的情況下發送憑證的瀏覽器或特定 HTTP 函式庫是必需的。", + "headerAuthCompatibility": "擴展相容性", + "setupNewOrg": "新建組織", + "setupCreateOrg": "創建組織", + "setupCreateResources": "創建資源", + "setupOrgName": "組織名稱", + "orgDisplayName": "這是您組織的顯示名稱。", + "orgId": "組織ID", + "setupIdentifierMessage": "這是您組織的唯一標識符。這是與顯示名稱分開的。", + "setupErrorIdentifier": "組織ID 已被使用。請另選一個。", + "componentsErrorNoMemberCreate": "您目前不是任何組織的成員。創建組織以開始操作。", + "componentsErrorNoMember": "您目前不是任何組織的成員。", + "welcome": "歡迎使用 Pangolin", + "welcomeTo": "歡迎來到", + "componentsCreateOrg": "創建組織", + "componentsMember": "您屬於 {count, plural, =0 {沒有組織} one {一個組織} other {# 個組織}}。", + "componentsInvalidKey": "檢測到無效或過期的許可證金鑰。按照許可證條款操作以繼續使用所有功能。", + "dismiss": "忽略", + "componentsLicenseViolation": "許可證超限:該伺服器使用了 {usedSites} 個站點,已超過授權的 {maxSites} 個。請遵守許可證條款以繼續使用全部功能。", + "componentsSupporterMessage": "感謝您的支持!您現在是 Pangolin 的 {tier} 用戶。", + "inviteErrorNotValid": "很抱歉,但看起來你試圖訪問的邀請尚未被接受或不再有效。", + "inviteErrorUser": "很抱歉,但看起來你想要訪問的邀請不是這個用戶。", + "inviteLoginUser": "請確保您以正確的用戶登錄。", + "inviteErrorNoUser": "很抱歉,但看起來你想訪問的邀請不是一個存在的用戶。", + "inviteCreateUser": "請先創建一個帳戶。", + "goHome": "返回首頁", + "inviteLogInOtherUser": "以不同的用戶登錄", + "createAnAccount": "創建帳戶", + "inviteNotAccepted": "邀請未接受", + "authCreateAccount": "創建一個帳戶以開始", + "authNoAccount": "沒有帳戶?", + "email": "電子郵件地址", + "password": "密碼", + "confirmPassword": "確認密碼", + "createAccount": "創建帳戶", + "viewSettings": "查看設置", + "delete": "刪除", + "name": "名稱", + "online": "在線", + "offline": "離線的", + "site": "站點", + "dataIn": "數據輸入", + "dataOut": "數據輸出", + "connectionType": "連接類型", + "tunnelType": "隧道類型", + "local": "本地的", + "edit": "編輯", + "siteConfirmDelete": "確認刪除站點", + "siteDelete": "刪除站點", + "siteMessageRemove": "一旦移除,站點將無法訪問。與站點相關的所有目標也將被移除。", + "siteQuestionRemove": "您確定要從組織中刪除該站點嗎?", + "siteManageSites": "管理站點", + "siteDescription": "允許通過安全隧道連接到您的網路", + "sitesBannerTitle": "連接任何網路", + "sitesBannerDescription": "站點是與遠端網路的連接,使 Pangolin 能夠為任何地方的使用者提供對公共或私有資源的存取。在任何可以執行二進位檔案或容器的地方安裝站點網路連接器 (Newt) 以建立連接。", + "sitesBannerButtonText": "安裝站點", + "siteCreate": "創建站點", + "siteCreateDescription2": "按照下面的步驟創建和連接一個新站點", + "siteCreateDescription": "創建一個新站點開始連接您的資源", + "close": "關閉", + "siteErrorCreate": "創建站點出錯", + "siteErrorCreateKeyPair": "找不到金鑰對或站點預設值", + "siteErrorCreateDefaults": "未找到站點預設值", + "method": "方法", + "siteMethodDescription": "這是您將如何顯示連接。", + "siteLearnNewt": "學習如何在您的系統上安裝 Newt", + "siteSeeConfigOnce": "您只能看到一次配置。", + "siteLoadWGConfig": "正在載入 WireGuard 配置...", + "siteDocker": "擴展 Docker 部署詳細資訊", + "toggle": "切換", + "dockerCompose": "Docker Compose", + "dockerRun": "Docker Run", + "siteLearnLocal": "本地站點不需要隧道連接,點擊了解更多", + "siteConfirmCopy": "我已經複製了配置資訊", + "searchSitesProgress": "搜索站點...", + "siteAdd": "添加站點", + "siteInstallNewt": "安裝 Newt", + "siteInstallNewtDescription": "在您的系統中運行 Newt", + "WgConfiguration": "WireGuard 配置", + "WgConfigurationDescription": "使用以下配置連接到您的網路", + "operatingSystem": "操作系統", + "commands": "命令", + "recommended": "推薦", + "siteNewtDescription": "為獲得最佳用戶體驗,請使用 Newt。其底層採用 WireGuard 技術,可直接通過 Pangolin 控制台,使用區域網路地址訪問您私有網路中的資源。", + "siteRunsInDocker": "在 Docker 中運行", + "siteRunsInShell": "在 macOS 、 Linux 和 Windows 的 Shell 中運行", + "siteErrorDelete": "刪除站點出錯", + "siteErrorUpdate": "更新站點失敗", + "siteErrorUpdateDescription": "更新站點時出錯。", + "siteUpdated": "站點已更新", + "siteUpdatedDescription": "網站已更新。", + "siteGeneralDescription": "配置此站點的常規設置", + "siteSettingDescription": "配置您網站上的設置", + "siteSetting": "{siteName} 設置", + "siteNewtTunnel": "Newt 隧道 (推薦)", + "siteNewtTunnelDescription": "最簡單的方式來連接到您的網路。不需要任何額外設置。", + "siteWg": "基本 WireGuard", + "siteWgDescription": "使用任何 WireGuard 用戶端來建立隧道。需要手動配置 NAT。", + "siteWgDescriptionSaas": "使用任何 WireGuard 用戶端建立隧道。需要手動配置 NAT。僅適用於自託管節點。", + "siteLocalDescription": "僅限本地資源。不需要隧道。", + "siteLocalDescriptionSaas": "僅本地資源。沒有隧道。僅在遠程節點上可用。", + "siteSeeAll": "查看所有站點", + "siteTunnelDescription": "確定如何連接到您的網站", + "siteNewtCredentials": "Newt 憑證", + "siteNewtCredentialsDescription": "這是 Newt 伺服器的身份驗證憑證", + "remoteNodeCredentialsDescription": "這是遠端節點與伺服器進行驗證的方式", + "siteCredentialsSave": "保存您的憑證", + "siteCredentialsSaveDescription": "您只能看到一次。請確保將其複製並保存到一個安全的地方。", + "siteInfo": "站點資訊", + "status": "狀態", + "shareTitle": "管理共享連結", + "shareDescription": "創建可共享的連結,允許暫時或永久訪問您的資源", + "shareSearch": "搜索共享連結...", + "shareCreate": "創建共享連結", + "shareErrorDelete": "刪除連結失敗", + "shareErrorDeleteMessage": "刪除連結時出錯", + "shareDeleted": "連結已刪除", + "shareDeletedDescription": "連結已刪除", + "shareTokenDescription": "您的訪問令牌可以透過兩種方式傳遞:作為查詢參數或請求頭。 每次驗證訪問請求都必須從用戶端傳遞。", + "accessToken": "訪問令牌", + "usageExamples": "用法範例", + "tokenId": "令牌 ID", + "requestHeades": "請求頭", + "queryParameter": "查詢參數", + "importantNote": "重要提示", + "shareImportantDescription": "出於安全考慮,建議盡可能在使用請求頭傳遞參數,因為查詢參數可能會被瀏覽器歷史記錄或伺服器日誌記錄。", + "token": "令牌", + "shareTokenSecurety": "請妥善保管您的訪問令牌,不要將其暴露在公開訪問的區域或用戶端代碼中。", + "shareErrorFetchResource": "獲取資源失敗", + "shareErrorFetchResourceDescription": "獲取資源時出錯", + "shareErrorCreate": "無法創建共享連結", + "shareErrorCreateDescription": "創建共享連結時出錯", + "shareCreateDescription": "任何具有此連結的人都可以訪問資源", + "shareTitleOptional": "標題 (可選)", + "expireIn": "過期時間", + "neverExpire": "永不過期", + "shareExpireDescription": "過期時間是連結可以使用並提供對資源的訪問時間。 此時間後,連結將不再工作,使用此連結的用戶將失去對資源的訪問。", + "shareSeeOnce": "您只能看到一次此連結。請確保複製它。", + "shareAccessHint": "任何具有此連結的人都可以訪問該資源。小心地分享它。", + "shareTokenUsage": "查看訪問令牌使用情況", + "createLink": "創建連結", + "resourcesNotFound": "找不到資源", + "resourceSearch": "搜索資源", + "openMenu": "打開菜單", + "resource": "資源", + "title": "標題", + "created": "已創建", + "expires": "過期時間", + "never": "永不過期", + "shareErrorSelectResource": "請選擇一個資源", + "proxyResourceTitle": "管理公開資源", + "proxyResourceDescription": "建立和管理可透過網頁瀏覽器公開存取的資源", + "proxyResourcesBannerTitle": "基於網頁的公開存取", + "proxyResourcesBannerDescription": "公開資源是任何人都可以透過網頁瀏覽器存取的 HTTPS 或 TCP/UDP 代理。與私有資源不同,它們不需要客戶端軟體,並且可以包含基於身份和情境感知的存取策略。", + "clientResourceTitle": "管理私有資源", + "clientResourceDescription": "建立和管理只能透過已連接的客戶端存取的資源", + "privateResourcesBannerTitle": "零信任私有存取", + "privateResourcesBannerDescription": "私有資源使用零信任安全性,確保使用者和機器只能存取您明確授權的資源。連接使用者裝置或機器客戶端以透過安全的虛擬私人網路存取這些資源。", + "resourcesSearch": "搜索資源...", + "resourceAdd": "添加資源", + "resourceErrorDelte": "刪除資源時出錯", + "authentication": "認證", + "protected": "受到保護", + "notProtected": "未受到保護", + "resourceMessageRemove": "一旦刪除,資源將不再可訪問。與該資源相關的所有目標也將被刪除。", + "resourceQuestionRemove": "您確定要從組織中刪除資源嗎?", + "resourceHTTP": "HTTPS 資源", + "resourceHTTPDescription": "使用子域或根域名通過 HTTPS 向您的應用程式提出代理請求。", + "resourceRaw": "TCP/UDP 資源", + "resourceRawDescription": "使用 TCP/UDP 使用埠號向您的應用提出代理請求。", + "resourceCreate": "創建資源", + "resourceCreateDescription": "按照下面的步驟創建新資源", + "resourceSeeAll": "查看所有資源", + "resourceInfo": "資源資訊", + "resourceNameDescription": "這是資源的顯示名稱。", + "siteSelect": "選擇站點", + "siteSearch": "搜索站點", + "siteNotFound": "未找到站點。", + "selectCountry": "選擇國家", + "searchCountries": "搜索國家...", + "noCountryFound": "找不到國家。", + "siteSelectionDescription": "此站點將為目標提供連接。", + "resourceType": "資源類型", + "resourceTypeDescription": "確定如何訪問您的資源", + "resourceHTTPSSettings": "HTTPS 設置", + "resourceHTTPSSettingsDescription": "配置如何通過 HTTPS 訪問您的資源", + "domainType": "域類型", + "subdomain": "子域名", + "baseDomain": "根域名", + "subdomnainDescription": "您的資源可以訪問的子域名。", + "resourceRawSettings": "TCP/UDP 設置", + "resourceRawSettingsDescription": "設定如何透過 TCP/UDP 存取資源", + "protocol": "協議", + "protocolSelect": "選擇協議", + "resourcePortNumber": "埠號", + "resourcePortNumberDescription": "代理請求的外部埠號。", + "cancel": "取消", + "resourceConfig": "配置片段", + "resourceConfigDescription": "複製並黏貼這些配置片段以設置您的 TCP/UDP 資源", + "resourceAddEntrypoints": "Traefik: 添加入口點", + "resourceExposePorts": "Gerbil:在 Docker Compose 中顯示埠", + "resourceLearnRaw": "學習如何配置 TCP/UDP 資源", + "resourceBack": "返回資源", + "resourceGoTo": "轉到資源", + "resourceDelete": "刪除資源", + "resourceDeleteConfirm": "確認刪除資源", + "visibility": "可見性", + "enabled": "已啟用", + "disabled": "已禁用", + "general": "概覽", + "generalSettings": "常規設置", + "proxy": "代理伺服器", + "internal": "內部設置", + "rules": "規則", + "resourceSettingDescription": "配置您資源上的設置", + "resourceSetting": "{resourceName} 設置", + "alwaysAllow": "一律允許", + "alwaysDeny": "一律拒絕", + "passToAuth": "傳遞至認證", + "orgSettingsDescription": "配置您組織的一般設定", + "orgGeneralSettings": "組織設置", + "orgGeneralSettingsDescription": "管理您的機構詳細資訊和配置", + "saveGeneralSettings": "保存常規設置", + "saveSettings": "保存設置", + "orgDangerZone": "危險區域", + "orgDangerZoneDescription": "一旦刪除該組織,將無法恢復,請務必確認。", + "orgDelete": "刪除組織", + "orgDeleteConfirm": "確認刪除組織", + "orgMessageRemove": "此操作不可逆,這將刪除所有相關數據。", + "orgMessageConfirm": "要確認,請在下面輸入組織名稱。", + "orgQuestionRemove": "您確定要刪除組織嗎?", + "orgUpdated": "組織已更新", + "orgUpdatedDescription": "組織已更新。", + "orgErrorUpdate": "更新組織失敗", + "orgErrorUpdateMessage": "更新組織時出錯。", + "orgErrorFetch": "獲取組織失敗", + "orgErrorFetchMessage": "列出您的組織時出錯", + "orgErrorDelete": "刪除組織失敗", + "orgErrorDeleteMessage": "刪除組織時出錯。", + "orgDeleted": "組織已刪除", + "orgDeletedMessage": "組織及其數據已被刪除。", + "orgMissing": "缺少組織 ID", + "orgMissingMessage": "沒有組織ID,無法重新生成邀請。", + "accessUsersManage": "管理用戶", + "accessUsersDescription": "邀請用戶並位他們添加角色以管理訪問您的組織", + "accessUsersSearch": "搜索用戶...", + "accessUserCreate": "創建用戶", + "accessUserRemove": "刪除用戶", + "username": "使用者名稱", + "identityProvider": "身份提供商", + "role": "角色", + "nameRequired": "名稱是必填項", + "accessRolesManage": "管理角色", + "accessRolesDescription": "配置角色來管理訪問您的組織", + "accessRolesSearch": "搜索角色...", + "accessRolesAdd": "添加角色", + "accessRoleDelete": "刪除角色", + "description": "描述", + "inviteTitle": "打開邀請", + "inviteDescription": "管理您給其他用戶的邀請", + "inviteSearch": "搜索邀請...", + "minutes": "分鐘", + "hours": "小時", + "days": "天", + "weeks": "周", + "months": "月", + "years": "年", + "day": "{count, plural, other {# 天}}", + "apiKeysTitle": "API 金鑰", + "apiKeysConfirmCopy2": "您必須確認您已複製 API 金鑰。", + "apiKeysErrorCreate": "創建 API 金鑰出錯", + "apiKeysErrorSetPermission": "設置權限出錯", + "apiKeysCreate": "生成 API 金鑰", + "apiKeysCreateDescription": "為您的組織生成一個新的 API 金鑰", + "apiKeysGeneralSettings": "權限", + "apiKeysGeneralSettingsDescription": "確定此 API 金鑰可以做什麼", + "apiKeysList": "您的 API 金鑰", + "apiKeysSave": "保存您的 API 金鑰", + "apiKeysSaveDescription": "該資訊僅會顯示一次,請確保將其複製到安全的位置。", + "apiKeysInfo": "您的 API 金鑰是:", + "apiKeysConfirmCopy": "我已複製 API 金鑰", + "generate": "生成", + "done": "完成", + "apiKeysSeeAll": "查看所有 API 金鑰", + "apiKeysPermissionsErrorLoadingActions": "載入 API 金鑰操作時出錯", + "apiKeysPermissionsErrorUpdate": "設置權限出錯", + "apiKeysPermissionsUpdated": "權限已更新", + "apiKeysPermissionsUpdatedDescription": "權限已更新。", + "apiKeysPermissionsGeneralSettings": "權限", + "apiKeysPermissionsGeneralSettingsDescription": "確定此 API 金鑰可以做什麼", + "apiKeysPermissionsSave": "保存權限", + "apiKeysPermissionsTitle": "權限", + "apiKeys": "API 金鑰", + "searchApiKeys": "搜索 API 金鑰...", + "apiKeysAdd": "生成 API 金鑰", + "apiKeysErrorDelete": "刪除 API 金鑰出錯", + "apiKeysErrorDeleteMessage": "刪除 API 金鑰出錯", + "apiKeysQuestionRemove": "您確定要從組織中刪除 API 金鑰嗎?", + "apiKeysMessageRemove": "一旦刪除,此API金鑰將無法被使用。", + "apiKeysDeleteConfirm": "確認刪除 API 金鑰", + "apiKeysDelete": "刪除 API 金鑰", + "apiKeysManage": "管理 API 金鑰", + "apiKeysDescription": "API 金鑰用於認證集成 API", + "apiKeysSettings": "{apiKeyName} 設置", + "userTitle": "管理所有用戶", + "userDescription": "查看和管理系統中的所有用戶", + "userAbount": "關於用戶管理", + "userAbountDescription": "此表格顯示系統中所有根用戶對象。每個用戶可能屬於多個組織。 從組織中刪除用戶不會刪除其根用戶對象 - 他們將保留在系統中。 要從系統中完全刪除用戶,您必須使用此表格中的刪除操作刪除其根用戶對象。", + "userServer": "伺服器用戶", + "userSearch": "搜索伺服器用戶...", + "userErrorDelete": "刪除用戶時出錯", + "userDeleteConfirm": "確認刪除用戶", + "userDeleteServer": "從伺服器刪除用戶", + "userMessageRemove": "該用戶將被從所有組織中刪除並完全從伺服器中刪除。", + "userQuestionRemove": "您確定要從伺服器永久刪除用戶嗎?", + "licenseKey": "許可證金鑰", + "valid": "有效", + "numberOfSites": "站點數量", + "licenseKeySearch": "搜索許可證金鑰...", + "licenseKeyAdd": "添加許可證金鑰", + "type": "類型", + "licenseKeyRequired": "需要許可證金鑰", + "licenseTermsAgree": "您必須同意許可條款", + "licenseErrorKeyLoad": "載入許可證金鑰失敗", + "licenseErrorKeyLoadDescription": "載入許可證金鑰時出錯。", + "licenseErrorKeyDelete": "刪除許可證金鑰失敗", + "licenseErrorKeyDeleteDescription": "刪除許可證金鑰時出錯。", + "licenseKeyDeleted": "許可證金鑰已刪除", + "licenseKeyDeletedDescription": "許可證金鑰已被刪除。", + "licenseErrorKeyActivate": "啟用許可證金鑰失敗", + "licenseErrorKeyActivateDescription": "啟用許可證金鑰時出錯。", + "licenseAbout": "關於許可協議", + "communityEdition": "社區版", + "licenseAboutDescription": "這是針對商業環境中使用Pangolin的商業和企業用戶。 如果您正在使用 Pangolin 供個人使用,您可以忽略此部分。", + "licenseKeyActivated": "授權金鑰已啟用", + "licenseKeyActivatedDescription": "已成功啟用許可證金鑰。", + "licenseErrorKeyRecheck": "重新檢查許可證金鑰失敗", + "licenseErrorKeyRecheckDescription": "重新檢查許可證金鑰時出錯。", + "licenseErrorKeyRechecked": "重新檢查許可證金鑰", + "licenseErrorKeyRecheckedDescription": "已重新檢查所有許可證金鑰", + "licenseActivateKey": "啟用許可證金鑰", + "licenseActivateKeyDescription": "輸入一個許可金鑰來啟用它。", + "licenseActivate": "啟用許可證", + "licenseAgreement": "通過檢查此框,您確認您已經閱讀並同意與您的許可證金鑰相關的許可條款。", + "fossorialLicense": "查看Fossorial Commercial License和訂閱條款", + "licenseMessageRemove": "這將刪除許可證金鑰和它授予的所有相關權限。", + "licenseMessageConfirm": "要確認,請在下面輸入許可證金鑰。", + "licenseQuestionRemove": "您確定要刪除許可證金鑰?", + "licenseKeyDelete": "刪除許可證金鑰", + "licenseKeyDeleteConfirm": "確認刪除許可證金鑰", + "licenseTitle": "管理許可證狀態", + "licenseTitleDescription": "查看和管理系統中的許可證金鑰", + "licenseHost": "主機許可證", + "licenseHostDescription": "管理主機的主許可證金鑰。", + "licensedNot": "未授權", + "hostId": "主機 ID", + "licenseReckeckAll": "重新檢查所有金鑰", + "licenseSiteUsage": "站點使用情況", + "licenseSiteUsageDecsription": "查看使用此許可的站點數量。", + "licenseNoSiteLimit": "使用未經許可主機的站點數量沒有限制。", + "licensePurchase": "購買許可證", + "licensePurchaseSites": "購買更多站點", + "licenseSitesUsedMax": "使用了 {usedSites}/{maxSites} 個站點", + "licenseSitesUsed": "{count, plural, =0 {# 站點} one {# 站點} other {# 站點}}", + "licensePurchaseDescription": "請選擇您希望 {selectedMode, select, license {直接購買許可證,您可以隨時增加更多站點。} other {為現有許可證購買更多站點}}", + "licenseFee": "許可證費用", + "licensePriceSite": "每個站點的價格", + "total": "總計", + "licenseContinuePayment": "繼續付款", + "pricingPage": "定價頁面", + "pricingPortal": "前往付款頁面", + "licensePricingPage": "關於最新的價格和折扣,請訪問 ", + "invite": "邀請", + "inviteRegenerate": "重新生成邀請", + "inviteRegenerateDescription": "撤銷以前的邀請並創建一個新的邀請", + "inviteRemove": "移除邀請", + "inviteRemoveError": "刪除邀請失敗", + "inviteRemoveErrorDescription": "刪除邀請時出錯。", + "inviteRemoved": "邀請已刪除", + "inviteRemovedDescription": "為 {email} 創建的邀請已刪除", + "inviteQuestionRemove": "您確定要刪除邀請嗎?", + "inviteMessageRemove": "一旦刪除,這個邀請將不再有效。", + "inviteMessageConfirm": "要確認,請在下面輸入邀請的電子郵件地址。", + "inviteQuestionRegenerate": "您確定要重新邀請 {email} 嗎?這將會撤銷掉之前的邀請", + "inviteRemoveConfirm": "確認刪除邀請", + "inviteRegenerated": "重新生成邀請", + "inviteSent": "邀請郵件已成功發送至 {email}。", + "inviteSentEmail": "發送電子郵件通知給用戶", + "inviteGenerate": "已為 {email} 創建新的邀請。", + "inviteDuplicateError": "重複的邀請", + "inviteDuplicateErrorDescription": "此用戶的邀請已存在。", + "inviteRateLimitError": "超出速率限制", + "inviteRateLimitErrorDescription": "您超過了每小時3次再生的限制。請稍後再試。", + "inviteRegenerateError": "重新生成邀請失敗", + "inviteRegenerateErrorDescription": "重新生成邀請時出錯。", + "inviteValidityPeriod": "有效期", + "inviteValidityPeriodSelect": "選擇有效期", + "inviteRegenerateMessage": "邀請已重新生成。用戶必須訪問下面的連結才能接受邀請。", + "inviteRegenerateButton": "重新生成", + "expiresAt": "到期於", + "accessRoleUnknown": "未知角色", + "placeholder": "占位符", + "userErrorOrgRemove": "刪除用戶失敗", + "userErrorOrgRemoveDescription": "刪除用戶時出錯。", + "userOrgRemoved": "用戶已刪除", + "userOrgRemovedDescription": "已將 {email} 從組織中移除。", + "userQuestionOrgRemove": "您確定要從組織中刪除此用戶嗎?", + "userMessageOrgRemove": "一旦刪除,這個用戶將不再能夠訪問組織。 你總是可以稍後重新邀請他們,但他們需要再次接受邀請。", + "userRemoveOrgConfirm": "確認刪除用戶", + "userRemoveOrg": "從組織中刪除用戶", + "users": "用戶", + "accessRoleMember": "成員", + "accessRoleOwner": "所有者", + "userConfirmed": "已確認", + "idpNameInternal": "內部設置", + "emailInvalid": "無效的電子郵件地址", + "inviteValidityDuration": "請選擇持續時間", + "accessRoleSelectPlease": "請選擇一個角色", + "usernameRequired": "必須輸入使用者名稱", + "idpSelectPlease": "請選擇身份提供商", + "idpGenericOidc": "通用的 OAuth2/OIDC 提供商。", + "accessRoleErrorFetch": "獲取角色失敗", + "accessRoleErrorFetchDescription": "獲取角色時出錯", + "idpErrorFetch": "獲取身份提供者失敗", + "idpErrorFetchDescription": "獲取身份提供者時出錯", + "userErrorExists": "用戶已存在", + "userErrorExistsDescription": "此用戶已經是組織成員。", + "inviteError": "邀請用戶失敗", + "inviteErrorDescription": "邀請用戶時出錯", + "userInvited": "用戶邀請", + "userInvitedDescription": "用戶已被成功邀請。", + "userErrorCreate": "創建用戶失敗", + "userErrorCreateDescription": "創建用戶時出錯", + "userCreated": "用戶已創建", + "userCreatedDescription": "用戶已成功創建。", + "userTypeInternal": "內部用戶", + "userTypeInternalDescription": "邀請用戶直接加入您的組織。", + "userTypeExternal": "外部用戶", + "userTypeExternalDescription": "創建一個具有外部身份提供商的用戶。", + "accessUserCreateDescription": "按照下面的步驟創建一個新用戶", + "userSeeAll": "查看所有用戶", + "userTypeTitle": "用戶類型", + "userTypeDescription": "確定如何創建用戶", + "userSettings": "用戶資訊", + "userSettingsDescription": "輸入新用戶的詳細資訊", + "inviteEmailSent": "發送邀請郵件給用戶", + "inviteValid": "有效", + "selectDuration": "選擇持續時間", + "selectResource": "選擇資源", + "filterByResource": "依資源篩選", + "resetFilters": "重設篩選條件", + "totalBlocked": "被 Pangolin 阻擋的請求", + "totalRequests": "總請求數", + "requestsByCountry": "依國家/地區的請求", + "requestsByDay": "依日期的請求", + "blocked": "已阻擋", + "allowed": "已允許", + "topCountries": "熱門國家/地區", + "accessRoleSelect": "選擇角色", + "inviteEmailSentDescription": "一封電子郵件已經發送給用戶,帶有下面的訪問連結。他們必須訪問該連結才能接受邀請。", + "inviteSentDescription": "用戶已被邀請。他們必須訪問下面的連結才能接受邀請。", + "inviteExpiresIn": "邀請將在{days, plural, other {# 天}}後過期。", + "idpTitle": "身份提供商", + "idpSelect": "為外部用戶選擇身份提供商", + "idpNotConfigured": "沒有配置身份提供者。請在創建外部用戶之前配置身份提供者。", + "usernameUniq": "這必須匹配所選身份提供者中存在的唯一使用者名稱。", + "emailOptional": "電子郵件(可選)", + "nameOptional": "名稱(可選)", + "accessControls": "訪問控制", + "userDescription2": "管理此用戶的設置", + "accessRoleErrorAdd": "添加用戶到角色失敗", + "accessRoleErrorAddDescription": "添加用戶到角色時出錯。", + "userSaved": "用戶已保存", + "userSavedDescription": "用戶已更新。", + "autoProvisioned": "自動設置", + "autoProvisionedDescription": "允許此用戶由身份提供商自動管理", + "accessControlsDescription": "管理此用戶在組織中可以訪問和做什麼", + "accessControlsSubmit": "保存訪問控制", + "roles": "角色", + "accessUsersRoles": "管理用戶和角色", + "accessUsersRolesDescription": "邀請用戶並將他們添加到角色以管理訪問您的組織", + "key": "關鍵字", + "createdAt": "創建於", + "proxyErrorInvalidHeader": "無效的自訂主機 Header。使用域名格式,或將空保存為取消自訂 Header。", + "proxyErrorTls": "無效的 TLS 伺服器名稱。使用域名格式,或保存空以刪除 TLS 伺服器名稱。", + "proxyEnableSSL": "啟用 SSL", + "proxyEnableSSLDescription": "啟用 SSL/TLS 加密以確保您目標的 HTTPS 連接。", + "target": "目標", + "configureTarget": "配置目標", + "targetErrorFetch": "獲取目標失敗", + "targetErrorFetchDescription": "獲取目標時出錯", + "siteErrorFetch": "獲取資源失敗", + "siteErrorFetchDescription": "獲取資源時出錯", + "targetErrorDuplicate": "重複的目標", + "targetErrorDuplicateDescription": "具有這些設置的目標已存在", + "targetWireGuardErrorInvalidIp": "無效的目標IP", + "targetWireGuardErrorInvalidIpDescription": "目標IP必須在站點子網內", + "targetsUpdated": "目標已更新", + "targetsUpdatedDescription": "目標和設置更新成功", + "targetsErrorUpdate": "更新目標失敗", + "targetsErrorUpdateDescription": "更新目標時出錯", + "targetTlsUpdate": "TLS 設置已更新", + "targetTlsUpdateDescription": "您的 TLS 設置已成功更新", + "targetErrorTlsUpdate": "更新 TLS 設置失敗", + "targetErrorTlsUpdateDescription": "更新 TLS 設置時出錯", + "proxyUpdated": "代理設置已更新", + "proxyUpdatedDescription": "您的代理設置已成功更新", + "proxyErrorUpdate": "更新代理設置失敗", + "proxyErrorUpdateDescription": "更新代理設置時出錯", + "targetAddr": "IP / 域名", + "targetPort": "埠", + "targetProtocol": "協議", + "targetTlsSettings": "安全連接配置", + "targetTlsSettingsDescription": "配置資源的 SSL/TLS 設置", + "targetTlsSettingsAdvanced": "高級TLS設置", + "targetTlsSni": "TLS 伺服器名稱", + "targetTlsSniDescription": "SNI使用的 TLS 伺服器名稱。留空使用預設值。", + "targetTlsSubmit": "保存設置", + "targets": "目標配置", + "targetsDescription": "設置目標來路由流量到您的後端服務", + "targetStickySessions": "啟用置頂會話", + "targetStickySessionsDescription": "將連接保持在同一個後端目標的整個會話中。", + "methodSelect": "選擇方法", + "targetSubmit": "添加目標", + "targetNoOne": "此資源沒有任何目標。添加目標來配置向您後端發送請求的位置。", + "targetNoOneDescription": "在上面添加多個目標將啟用負載平衡。", + "targetsSubmit": "保存目標", + "addTarget": "添加目標", + "targetErrorInvalidIp": "無效的 IP 地址", + "targetErrorInvalidIpDescription": "請輸入有效的IP位址或主機名", + "targetErrorInvalidPort": "無效的埠", + "targetErrorInvalidPortDescription": "請輸入有效的埠號", + "targetErrorNoSite": "沒有選擇站點", + "targetErrorNoSiteDescription": "請選擇目標站點", + "targetCreated": "目標已創建", + "targetCreatedDescription": "目標已成功創建", + "targetErrorCreate": "創建目標失敗", + "targetErrorCreateDescription": "創建目標時出錯", + "tlsServerName": "TLS 伺服器名稱", + "tlsServerNameDescription": "用於 SNI 的 TLS 伺服器名稱", + "save": "保存", + "proxyAdditional": "附加代理設置", + "proxyAdditionalDescription": "配置你的資源如何處理代理設置", + "proxyCustomHeader": "自訂主機 Header", + "proxyCustomHeaderDescription": "代理請求時設置的 Header。留空則使用預設值。", + "proxyAdditionalSubmit": "保存代理設置", + "subnetMaskErrorInvalid": "子網掩碼無效。必須在 0 和 32 之間。", + "ipAddressErrorInvalidFormat": "無效的 IP 地址格式", + "ipAddressErrorInvalidOctet": "無效的 IP 地址", + "path": "路徑", + "matchPath": "匹配路徑", + "ipAddressRange": "IP 範圍", + "rulesErrorFetch": "獲取規則失敗", + "rulesErrorFetchDescription": "獲取規則時出錯", + "rulesErrorDuplicate": "複製規則", + "rulesErrorDuplicateDescription": "帶有這些設置的規則已存在", + "rulesErrorInvalidIpAddressRange": "無效的 CIDR", + "rulesErrorInvalidIpAddressRangeDescription": "請輸入一個有效的 CIDR 值", + "rulesErrorInvalidUrl": "無效的 URL 路徑", + "rulesErrorInvalidUrlDescription": "請輸入一個有效的 URL 路徑值", + "rulesErrorInvalidIpAddress": "無效的 IP", + "rulesErrorInvalidIpAddressDescription": "請輸入一個有效的IP位址", + "rulesErrorUpdate": "更新規則失敗", + "rulesErrorUpdateDescription": "更新規則時出錯", + "rulesUpdated": "啟用規則", + "rulesUpdatedDescription": "規則已更新", + "rulesMatchIpAddressRangeDescription": "以 CIDR 格式輸入地址(如:103.21.244.0/22)", + "rulesMatchIpAddress": "輸入IP位址(例如,103.21.244.12)", + "rulesMatchUrl": "輸入一個 URL 路徑或模式(例如/api/v1/todos 或 /api/v1/*)", + "rulesErrorInvalidPriority": "無效的優先度", + "rulesErrorInvalidPriorityDescription": "請輸入一個有效的優先度", + "rulesErrorDuplicatePriority": "重複的優先度", + "rulesErrorDuplicatePriorityDescription": "請輸入唯一的優先度", + "ruleUpdated": "規則已更新", + "ruleUpdatedDescription": "規則更新成功", + "ruleErrorUpdate": "操作失敗", + "ruleErrorUpdateDescription": "保存過程中發生錯誤", + "rulesPriority": "優先權", + "rulesAction": "行為", + "rulesMatchType": "匹配類型", + "value": "值", + "rulesAbout": "關於規則", + "rulesAboutDescription": "規則使您能夠依據特定條件控制資源訪問權限。您可以創建基於 IP 地址或 URL 路徑的規則,以允許或拒絕訪問。", + "rulesActions": "行動", + "rulesActionAlwaysAllow": "總是允許:繞過所有身份驗證方法", + "rulesActionAlwaysDeny": "總是拒絕:阻止所有請求;無法嘗試驗證", + "rulesActionPassToAuth": "傳遞至認證:允許嘗試身份驗證方法", + "rulesMatchCriteria": "匹配條件", + "rulesMatchCriteriaIpAddress": "匹配一個指定的 IP 地址", + "rulesMatchCriteriaIpAddressRange": "在 CIDR 符號中匹配一系列IP位址", + "rulesMatchCriteriaUrl": "匹配一個 URL 路徑或模式", + "rulesEnable": "啟用規則", + "rulesEnableDescription": "啟用或禁用此資源的規則評估", + "rulesResource": "資源規則配置", + "rulesResourceDescription": "配置規則來控制對您資源的訪問", + "ruleSubmit": "添加規則", + "rulesNoOne": "沒有規則。使用表單添加規則。", + "rulesOrder": "規則按優先順序評定。", + "rulesSubmit": "保存規則", + "resourceErrorCreate": "創建資源時出錯", + "resourceErrorCreateDescription": "創建資源時出錯", + "resourceErrorCreateMessage": "創建資源時發生錯誤:", + "resourceErrorCreateMessageDescription": "發生意外錯誤", + "sitesErrorFetch": "獲取站點出錯", + "sitesErrorFetchDescription": "獲取站點時出錯", + "domainsErrorFetch": "獲取域名出錯", + "domainsErrorFetchDescription": "獲取域時出錯", + "none": "無", + "unknown": "未知", + "resources": "資源", + "resourcesDescription": "資源是您私有網路中運行的應用程式的代理。您可以為私有網路中的任何 HTTP/HTTPS 或 TCP/UDP 服務創建資源。每個資源都必須連接到一個站點,以通過加密的 WireGuard 隧道實現私密且安全的連接。", + "resourcesWireGuardConnect": "採用 WireGuard 提供的加密安全連接", + "resourcesMultipleAuthenticationMethods": "配置多個身份驗證方法", + "resourcesUsersRolesAccess": "基於用戶和角色的訪問控制", + "resourcesErrorUpdate": "切換資源失敗", + "resourcesErrorUpdateDescription": "更新資源時出錯", + "access": "訪問權限", + "shareLink": "{resource} 的分享連結", + "resourceSelect": "選擇資源", + "shareLinks": "分享連結", + "share": "分享連結", + "shareDescription2": "創建資源共享連結。連結提供對資源的臨時或無限制訪問。 當您創建連結時,您可以配置連結的到期時間。", + "shareEasyCreate": "輕鬆創建和分享", + "shareConfigurableExpirationDuration": "可配置的過期時間", + "shareSecureAndRevocable": "安全和可撤銷的", + "nameMin": "名稱長度必須大於 {len} 字元。", + "nameMax": "名稱長度必須小於 {len} 字元。", + "sitesConfirmCopy": "請確認您已經複製了配置。", + "unknownCommand": "未知命令", + "newtErrorFetchReleases": "無法獲取版本資訊: {err}", + "newtErrorFetchLatest": "無法獲取最新版資訊: {err}", + "newtEndpoint": "Newt 端點", + "newtId": "Newt ID", + "newtSecretKey": "Newt 私鑰", + "architecture": "架構", + "sites": "站點", + "siteWgAnyClients": "使用任何 WireGuard 用戶端連接。您必須使用對等IP解決您的內部資源。", + "siteWgCompatibleAllClients": "與所有 WireGuard 用戶端相容", + "siteWgManualConfigurationRequired": "需要手動配置", + "userErrorNotAdminOrOwner": "用戶不是管理員或所有者", + "pangolinSettings": "設置 - Pangolin", + "accessRoleYour": "您的角色:", + "accessRoleSelect2": "選擇角色", + "accessUserSelect": "選擇一個用戶", + "otpEmailEnter": "輸入電子郵件", + "otpEmailEnterDescription": "在輸入欄位輸入後按 Enter 鍵添加電子郵件。", + "otpEmailErrorInvalid": "無效的信箱地址。通配符(*)必須占據整個開頭部分。", + "otpEmailSmtpRequired": "需要先配置 SMTP", + "otpEmailSmtpRequiredDescription": "必須在伺服器上啟用 SMTP 才能使用一次性密碼驗證。", + "otpEmailTitle": "一次性密碼", + "otpEmailTitleDescription": "資源訪問需要基於電子郵件的身份驗證", + "otpEmailWhitelist": "電子郵件白名單", + "otpEmailWhitelistList": "白名單郵件", + "otpEmailWhitelistListDescription": "只有擁有這些電子郵件地址的用戶才能訪問此資源。 他們將被提示輸入一次性密碼發送到他們的電子郵件。 通配符 (*@example.com) 可以用來允許來自一個域名的任何電子郵件地址。", + "otpEmailWhitelistSave": "保存白名單", + "passwordAdd": "添加密碼", + "passwordRemove": "刪除密碼", + "pincodeAdd": "添加 PIN 碼", + "pincodeRemove": "移除 PIN 碼", + "resourceAuthMethods": "身份驗證方法", + "resourceAuthMethodsDescriptions": "允許透過額外的認證方法訪問資源", + "resourceAuthSettingsSave": "保存成功", + "resourceAuthSettingsSaveDescription": "已保存身份驗證設置", + "resourceErrorAuthFetch": "獲取數據失敗", + "resourceErrorAuthFetchDescription": "獲取數據時出錯", + "resourceErrorPasswordRemove": "刪除資源密碼出錯", + "resourceErrorPasswordRemoveDescription": "刪除資源密碼時出錯", + "resourceErrorPasswordSetup": "設置資源密碼出錯", + "resourceErrorPasswordSetupDescription": "設置資源密碼時出錯", + "resourceErrorPincodeRemove": "刪除資源固定碼時出錯", + "resourceErrorPincodeRemoveDescription": "刪除資源PIN碼時出錯", + "resourceErrorPincodeSetup": "設置資源 PIN 碼時出錯", + "resourceErrorPincodeSetupDescription": "設置資源 PIN 碼時發生錯誤", + "resourceErrorUsersRolesSave": "設置角色失敗", + "resourceErrorUsersRolesSaveDescription": "設置角色時出錯", + "resourceErrorWhitelistSave": "保存白名單失敗", + "resourceErrorWhitelistSaveDescription": "保存白名單時出錯", + "resourcePasswordSubmit": "啟用密碼保護", + "resourcePasswordProtection": "密碼保護 {status}", + "resourcePasswordRemove": "已刪除資源密碼", + "resourcePasswordRemoveDescription": "已成功刪除資源密碼", + "resourcePasswordSetup": "設置資源密碼", + "resourcePasswordSetupDescription": "已成功設置資源密碼", + "resourcePasswordSetupTitle": "設置密碼", + "resourcePasswordSetupTitleDescription": "設置密碼來保護此資源", + "resourcePincode": "PIN 碼", + "resourcePincodeSubmit": "啟用 PIN 碼保護", + "resourcePincodeProtection": "PIN 碼保護 {status}", + "resourcePincodeRemove": "資源 PIN 碼已刪除", + "resourcePincodeRemoveDescription": "已成功刪除資源 PIN 碼", + "resourcePincodeSetup": "資源 PIN 碼已設置", + "resourcePincodeSetupDescription": "資源 PIN 碼已成功設置", + "resourcePincodeSetupTitle": "設置 PIN 碼", + "resourcePincodeSetupTitleDescription": "設置 PIN 碼來保護此資源", + "resourceRoleDescription": "管理員總是可以訪問此資源。", + "resourceUsersRoles": "用戶和角色", + "resourceUsersRolesDescription": "配置用戶和角色可以訪問此資源", + "resourceUsersRolesSubmit": "保存用戶和角色", + "resourceWhitelistSave": "保存成功", + "resourceWhitelistSaveDescription": "白名單設置已保存", + "ssoUse": "使用平台 SSO", + "ssoUseDescription": "對於所有啟用此功能的資源,現有用戶只需登錄一次。", + "proxyErrorInvalidPort": "無效的埠號", + "subdomainErrorInvalid": "無效的子域", + "domainErrorFetch": "獲取域名失敗", + "domainErrorFetchDescription": "獲取域名時出錯", + "resourceErrorUpdate": "更新資源失敗", + "resourceErrorUpdateDescription": "更新資源時出錯", + "resourceUpdated": "資源已更新", + "resourceUpdatedDescription": "資源已成功更新", + "resourceErrorTransfer": "轉移資源失敗", + "resourceErrorTransferDescription": "轉移資源時出錯", + "resourceTransferred": "資源已傳輸", + "resourceTransferredDescription": "資源已成功傳輸", + "resourceErrorToggle": "切換資源失敗", + "resourceErrorToggleDescription": "更新資源時出錯", + "resourceVisibilityTitle": "可見性", + "resourceVisibilityTitleDescription": "完全啟用或禁用資源可見性", + "resourceGeneral": "常規設置", + "resourceGeneralDescription": "配置此資源的常規設置", + "resourceEnable": "啟用資源", + "resourceTransfer": "轉移資源", + "resourceTransferDescription": "將此資源轉移到另一個站點", + "resourceTransferSubmit": "轉移資源", + "siteDestination": "目標站點", + "searchSites": "搜索站點", + "countries": "國家/地區", + "accessRoleCreate": "創建角色", + "accessRoleCreateDescription": "創建一個新角色來分組用戶並管理他們的權限。", + "accessRoleCreateSubmit": "創建角色", + "accessRoleCreated": "角色已創建", + "accessRoleCreatedDescription": "角色已成功創建。", + "accessRoleErrorCreate": "創建角色失敗", + "accessRoleErrorCreateDescription": "創建角色時出錯。", + "accessRoleErrorNewRequired": "需要新角色", + "accessRoleErrorRemove": "刪除角色失敗", + "accessRoleErrorRemoveDescription": "刪除角色時出錯。", + "accessRoleName": "角色名稱", + "accessRoleQuestionRemove": "您即將刪除 {name} 角色。 此操作無法撤銷。", + "accessRoleRemove": "刪除角色", + "accessRoleRemoveDescription": "從組織中刪除角色", + "accessRoleRemoveSubmit": "刪除角色", + "accessRoleRemoved": "角色已刪除", + "accessRoleRemovedDescription": "角色已成功刪除。", + "accessRoleRequiredRemove": "刪除此角色之前,請選擇一個新角色來轉移現有成員。", + "manage": "管理", + "sitesNotFound": "未找到站點。", + "pangolinServerAdmin": "伺服器管理員 - Pangolin", + "licenseTierProfessional": "專業許可證", + "licenseTierEnterprise": "企業許可證", + "licenseTierPersonal": "個人許可證", + "licensed": "已授權", + "yes": "是", + "no": "否", + "sitesAdditional": "其他站點", + "licenseKeys": "許可證金鑰", + "sitestCountDecrease": "減少站點數量", + "sitestCountIncrease": "增加站點數量", + "idpManage": "管理身份提供商", + "idpManageDescription": "查看和管理系統中的身份提供商", + "idpDeletedDescription": "身份提供商刪除成功", + "idpOidc": "OAuth2/OIDC", + "idpQuestionRemove": "您確定要永久刪除身份提供者嗎?", + "idpMessageRemove": "這將刪除身份提供者和所有相關的配置。透過此提供者進行身份驗證的用戶將無法登錄。", + "idpMessageConfirm": "要確認,請在下面輸入身份提供者的名稱。", + "idpConfirmDelete": "確認刪除身份提供商", + "idpDelete": "刪除身份提供商", + "idp": "身份提供商", + "idpSearch": "搜索身份提供者...", + "idpAdd": "添加身份提供商", + "idpClientIdRequired": "用戶端 ID 是必需的。", + "idpClientSecretRequired": "用戶端金鑰是必需的。", + "idpErrorAuthUrlInvalid": "身份驗證 URL 必須是有效的 URL。", + "idpErrorTokenUrlInvalid": "令牌 URL 必須是有效的 URL。", + "idpPathRequired": "標識符路徑是必需的。", + "idpScopeRequired": "授權範圍是必需的。", + "idpOidcDescription": "配置 OpenID 連接身份提供商", + "idpCreatedDescription": "身份提供商創建成功", + "idpCreate": "創建身份提供商", + "idpCreateDescription": "配置用戶身份驗證的新身份提供商", + "idpSeeAll": "查看所有身份提供商", + "idpSettingsDescription": "配置身份提供者的基本資訊", + "idpDisplayName": "此身份提供商的顯示名稱", + "idpAutoProvisionUsers": "自動提供用戶", + "idpAutoProvisionUsersDescription": "如果啟用,用戶將在首次登錄時自動在系統中創建,並且能夠映射用戶到角色和組織。", + "licenseBadge": "EE", + "idpType": "提供者類型", + "idpTypeDescription": "選擇您想要配置的身份提供者類型", + "idpOidcConfigure": "OAuth2/OIDC 配置", + "idpOidcConfigureDescription": "配置 OAuth2/OIDC 供應商端點和憑據", + "idpClientId": "用戶端ID", + "idpClientIdDescription": "來自您身份提供商的 OAuth2 用戶端 ID", + "idpClientSecret": "用戶端金鑰", + "idpClientSecretDescription": "來自身份提供商的 OAuth2 用戶端金鑰", + "idpAuthUrl": "授權 URL", + "idpAuthUrlDescription": "OAuth2 授權端點的 URL", + "idpTokenUrl": "令牌 URL", + "idpTokenUrlDescription": "OAuth2 令牌端點的 URL", + "idpOidcConfigureAlert": "重要提示", + "idpOidcConfigureAlertDescription": "創建身份提供方後,您需要在其設置中配置回調 URL。回調 URL 會在創建成功後提供。", + "idpToken": "令牌配置", + "idpTokenDescription": "配置如何從 ID 令牌中提取用戶資訊", + "idpJmespathAbout": "關於 JMESPath", + "idpJmespathAboutDescription": "以下路徑使用 JMESPath 語法從 ID 令牌中提取值。", + "idpJmespathAboutDescriptionLink": "了解更多 JMESPath 資訊", + "idpJmespathLabel": "標識符路徑", + "idpJmespathLabelDescription": "ID 令牌中用戶標識符的路徑", + "idpJmespathEmailPathOptional": "信箱路徑(可選)", + "idpJmespathEmailPathOptionalDescription": "ID 令牌中用戶信箱的路徑", + "idpJmespathNamePathOptional": "使用者名稱路徑(可選)", + "idpJmespathNamePathOptionalDescription": "ID 令牌中使用者名稱的路徑", + "idpOidcConfigureScopes": "作用域(Scopes)", + "idpOidcConfigureScopesDescription": "以空格分隔的 OAuth2 請求作用域列表", + "idpSubmit": "創建身份提供商", + "orgPolicies": "組織策略", + "idpSettings": "{idpName} 設置", + "idpCreateSettingsDescription": "配置身份提供商的設置", + "roleMapping": "角色映射", + "orgMapping": "組織映射", + "orgPoliciesSearch": "搜索組織策略...", + "orgPoliciesAdd": "添加組織策略", + "orgRequired": "組織是必填項", + "error": "錯誤", + "success": "成功", + "orgPolicyAddedDescription": "策略添加成功", + "orgPolicyUpdatedDescription": "策略更新成功", + "orgPolicyDeletedDescription": "已成功刪除策略", + "defaultMappingsUpdatedDescription": "默認映射更新成功", + "orgPoliciesAbout": "關於組織政策", + "orgPoliciesAboutDescription": "組織策略用於根據用戶的 ID 令牌來控制對組織的訪問。 您可以指定 JMESPath 表達式來提取角色和組織資訊從 ID 令牌中提取資訊。", + "orgPoliciesAboutDescriptionLink": "欲了解更多資訊,請參閱文件。", + "defaultMappingsOptional": "默認映射(可選)", + "defaultMappingsOptionalDescription": "當沒有為某個組織定義組織的政策時,使用默認映射。 您可以指定默認角色和組織映射回到這裡。", + "defaultMappingsRole": "默認角色映射", + "defaultMappingsRoleDescription": "此表達式的結果必須返回組織中定義的角色名稱作為字串。", + "defaultMappingsOrg": "默認組織映射", + "defaultMappingsOrgDescription": "此表達式必須返回 組織ID 或 true 才能允許用戶訪問組織。", + "defaultMappingsSubmit": "保存默認映射", + "orgPoliciesEdit": "編輯組織策略", + "org": "組織", + "orgSelect": "選擇組織", + "orgSearch": "搜索", + "orgNotFound": "找不到組織。", + "roleMappingPathOptional": "角色映射路徑(可選)", + "orgMappingPathOptional": "組織映射路徑(可選)", + "orgPolicyUpdate": "更新策略", + "orgPolicyAdd": "添加策略", + "orgPolicyConfig": "配置組織訪問權限", + "idpUpdatedDescription": "身份提供商更新成功", + "redirectUrl": "重定向網址", + "orgIdpRedirectUrls": "重新導向網址", + "redirectUrlAbout": "關於重定向網址", + "redirectUrlAboutDescription": "這是用戶在驗證後將被重定向到的URL。您需要在身份提供商設置中配置此URL。", + "pangolinAuth": "認證 - Pangolin", + "verificationCodeLengthRequirements": "您的驗證碼必須是 8 個字元。", + "errorOccurred": "發生錯誤", + "emailErrorVerify": "驗證電子郵件失敗:", + "emailVerified": "電子郵件驗證成功!重定向您...", + "verificationCodeErrorResend": "無法重新發送驗證碼:", + "verificationCodeResend": "驗證碼已重新發送", + "verificationCodeResendDescription": "我們已將驗證碼重新發送到您的電子郵件地址。請檢查您的收件箱。", + "emailVerify": "驗證電子郵件", + "emailVerifyDescription": "輸入驗證碼發送到您的電子郵件地址。", + "verificationCode": "驗證碼", + "verificationCodeEmailSent": "我們向您的電子郵件地址發送了驗證碼。", + "submit": "提交", + "emailVerifyResendProgress": "正在重新發送...", + "emailVerifyResend": "沒有收到代碼?點擊此處重新發送", + "passwordNotMatch": "密碼不匹配", + "signupError": "註冊時出錯", + "pangolinLogoAlt": "Pangolin 標誌", + "inviteAlready": "看起來您已被邀請!", + "inviteAlreadyDescription": "要接受邀請,您必須登錄或創建一個帳戶。", + "signupQuestion": "已經有一個帳戶?", + "login": "登錄", + "resourceNotFound": "找不到資源", + "resourceNotFoundDescription": "您要訪問的資源不存在。", + "pincodeRequirementsLength": "PIN碼必須是 6 位數字", + "pincodeRequirementsChars": "PIN 必須只包含數字", + "passwordRequirementsLength": "密碼必須至少 1 個字元長", + "passwordRequirementsTitle": "密碼要求:", + "passwordRequirementLength": "至少 8 個字元長", + "passwordRequirementUppercase": "至少一個大寫字母", + "passwordRequirementLowercase": "至少一個小寫字母", + "passwordRequirementNumber": "至少一個數字", + "passwordRequirementSpecial": "至少一個特殊字元", + "passwordRequirementsMet": "✓ 密碼滿足所有要求", + "passwordStrength": "密碼強度", + "passwordStrengthWeak": "弱", + "passwordStrengthMedium": "中", + "passwordStrengthStrong": "強", + "passwordRequirements": "要求:", + "passwordRequirementLengthText": "8+ 個字元", + "passwordRequirementUppercaseText": "大寫字母 (A-Z)", + "passwordRequirementLowercaseText": "小寫字母 (a-z)", + "passwordRequirementNumberText": "數字 (0-9)", + "passwordRequirementSpecialText": "特殊字元 (!@#$%...)", + "passwordsDoNotMatch": "密碼不匹配", + "otpEmailRequirementsLength": "OTP 必須至少 1 個字元長", + "otpEmailSent": "OTP 已發送", + "otpEmailSentDescription": "OTP 已經發送到您的電子郵件", + "otpEmailErrorAuthenticate": "通過電子郵件身份驗證失敗", + "pincodeErrorAuthenticate": "Pincode 驗證失敗", + "passwordErrorAuthenticate": "密碼驗證失敗", + "poweredBy": "支持者:", + "authenticationRequired": "需要身份驗證", + "authenticationMethodChoose": "請選擇您偏好的方式來訪問 {name}", + "authenticationRequest": "您必須通過身份驗證才能訪問 {name}", + "user": "用戶", + "pincodeInput": "6 位數字 PIN 碼", + "pincodeSubmit": "使用 PIN 登錄", + "passwordSubmit": "使用密碼登錄", + "otpEmailDescription": "一次性代碼將發送到此電子郵件。", + "otpEmailSend": "發送一次性代碼", + "otpEmail": "一次性密碼 (OTP)", + "otpEmailSubmit": "提交 OTP", + "backToEmail": "回到電子郵件", + "noSupportKey": "伺服器當前未使用支持者金鑰,歡迎支持本項目!", + "accessDenied": "訪問被拒絕", + "accessDeniedDescription": "當前帳戶無權訪問此資源。如認為這是錯誤,請與管理員聯繫。", + "accessTokenError": "檢查訪問令牌時出錯", + "accessGranted": "已授予訪問", + "accessUrlInvalid": "訪問 URL 無效", + "accessGrantedDescription": "您已獲准訪問此資源,正在為您跳轉...", + "accessUrlInvalidDescription": "此共享訪問URL無效。請聯絡資源所有者獲取新URL。", + "tokenInvalid": "無效的令牌", + "pincodeInvalid": "無效的代碼", + "passwordErrorRequestReset": "請求重設失敗:", + "passwordErrorReset": "重設密碼失敗:", + "passwordResetSuccess": "密碼重設成功!返回登錄...", + "passwordReset": "重設密碼", + "passwordResetDescription": "按照步驟重設您的密碼", + "passwordResetSent": "我們將發送一個驗證碼到這個電子郵件地址。", + "passwordResetCode": "驗證碼", + "passwordResetCodeDescription": "請檢查您的電子郵件以獲取驗證碼。", + "generatePasswordResetCode": "產生密碼重設代碼", + "passwordResetCodeGenerated": "密碼重設代碼已產生", + "passwordResetCodeGeneratedDescription": "請將此代碼分享給使用者。他們可以用它來重設密碼。", + "passwordResetUrl": "重設網址", + "passwordNew": "新密碼", + "passwordNewConfirm": "確認新密碼", + "changePassword": "更改密碼", + "changePasswordDescription": "更新您的帳戶密碼", + "oldPassword": "當前密碼", + "newPassword": "新密碼", + "confirmNewPassword": "確認新密碼", + "changePasswordError": "更改密碼失敗", + "changePasswordErrorDescription": "更改您的密碼時出錯", + "changePasswordSuccess": "密碼修改成功", + "changePasswordSuccessDescription": "您的密碼已成功更新", + "passwordExpiryRequired": "需要密碼過期", + "passwordExpiryDescription": "該機構要求您每 {maxDays} 天更改一次密碼。", + "changePasswordNow": "現在更改密碼", + "pincodeAuth": "驗證器代碼", + "pincodeSubmit2": "提交代碼", + "passwordResetSubmit": "請求重設", + "passwordResetAlreadyHaveCode": "輸入代碼", + "passwordResetSmtpRequired": "請聯絡您的管理員", + "passwordResetSmtpRequiredDescription": "需要密碼重設代碼才能重設您的密碼。請聯絡您的管理員尋求協助。", + "passwordBack": "回到密碼", + "loginBack": "返回登錄", + "signup": "註冊", + "loginStart": "登錄以開始", + "idpOidcTokenValidating": "正在驗證 OIDC 令牌", + "idpOidcTokenResponse": "驗證 OIDC 令牌響應", + "idpErrorOidcTokenValidating": "驗證 OIDC 令牌出錯", + "idpConnectingTo": "連接到{name}", + "idpConnectingToDescription": "正在驗證您的身份", + "idpConnectingToProcess": "正在連接...", + "idpConnectingToFinished": "已連接", + "idpErrorConnectingTo": "無法連接到 {name},請聯絡管理員協助處理。", + "idpErrorNotFound": "找不到 IdP", + "inviteInvalid": "無效邀請", + "inviteInvalidDescription": "邀請連結無效。", + "inviteErrorWrongUser": "邀請不是該用戶的", + "inviteErrorUserNotExists": "用戶不存在。請先創建帳戶。", + "inviteErrorLoginRequired": "您必須登錄才能接受邀請", + "inviteErrorExpired": "邀請可能已過期", + "inviteErrorRevoked": "邀請可能已被吊銷了", + "inviteErrorTypo": "邀請連結中可能有一個類型", + "pangolinSetup": "認證 - Pangolin", + "orgNameRequired": "組織名稱是必需的", + "orgIdRequired": "組織ID是必需的", + "orgErrorCreate": "創建組織時出錯", + "pageNotFound": "找不到頁面", + "pageNotFoundDescription": "哎呀!您正在尋找的頁面不存在。", + "overview": "概覽", + "home": "首頁", + "accessControl": "訪問控制", + "settings": "設置", + "usersAll": "所有用戶", + "license": "許可協議", + "pangolinDashboard": "儀錶板 - Pangolin", + "noResults": "未找到任何結果。", + "terabytes": "{count} TB", + "gigabytes": "{count} GB", + "megabytes": "{count} MB", + "tagsEntered": "已輸入的標籤", + "tagsEnteredDescription": "這些是您輸入的標籤。", + "tagsWarnCannotBeLessThanZero": "最大標籤和最小標籤不能小於 0", + "tagsWarnNotAllowedAutocompleteOptions": "標記不允許為每個自動完成選項", + "tagsWarnInvalid": "無效的標籤,每個有效標籤", + "tagWarnTooShort": "標籤 {tagText} 太短", + "tagWarnTooLong": "標籤 {tagText} 太長", + "tagsWarnReachedMaxNumber": "已達到允許標籤的最大數量", + "tagWarnDuplicate": "未添加重複標籤 {tagText}", + "supportKeyInvalid": "無效金鑰", + "supportKeyInvalidDescription": "您的支持者金鑰無效。", + "supportKeyValid": "有效的金鑰", + "supportKeyValidDescription": "您的支持者金鑰已被驗證。感謝您的支持!", + "supportKeyErrorValidationDescription": "驗證支持者金鑰失敗。", + "supportKey": "支持開發和通過一個 Pangolin !", + "supportKeyDescription": "購買支持者鑰匙,幫助我們繼續為社區發展 Pangolin 。 您的貢獻使我們能夠投入更多的時間來維護和添加所有人的新功能。 我們永遠不會用這個來支付牆上的功能。這與任何商業版是分開的。", + "supportKeyPet": "您還可以領養並見到屬於自己的 Pangolin!", + "supportKeyPurchase": "付款通過 GitHub 進行處理,之後您可以在以下位置獲取您的金鑰:", + "supportKeyPurchaseLink": "我們的網站", + "supportKeyPurchase2": "並在這裡兌換。", + "supportKeyLearnMore": "了解更多。", + "supportKeyOptions": "請選擇最適合您的選項。", + "supportKetOptionFull": "完全支持者", + "forWholeServer": "適用於整個伺服器", + "lifetimePurchase": "終身購買", + "supporterStatus": "支持者狀態", + "buy": "購買", + "supportKeyOptionLimited": "有限支持者", + "forFiveUsers": "適用於 5 或更少用戶", + "supportKeyRedeem": "兌換支持者金鑰", + "supportKeyHideSevenDays": "隱藏 7 天", + "supportKeyEnter": "輸入支持者金鑰", + "supportKeyEnterDescription": "見到你自己的 Pangolin!", + "githubUsername": "GitHub 使用者名稱", + "supportKeyInput": "支持者金鑰", + "supportKeyBuy": "購買支持者金鑰", + "logoutError": "註銷錯誤", + "signingAs": "登錄為", + "serverAdmin": "伺服器管理員", + "managedSelfhosted": "託管自託管", + "otpEnable": "啟用雙因子認證", + "otpDisable": "禁用雙因子認證", + "logout": "登出", + "licenseTierProfessionalRequired": "需要專業版", + "licenseTierProfessionalRequiredDescription": "此功能僅在專業版可用。", + "actionGetOrg": "獲取組織", + "updateOrgUser": "更新組織用戶", + "createOrgUser": "創建組織用戶", + "actionUpdateOrg": "更新組織", + "actionRemoveInvitation": "移除邀請", + "actionUpdateUser": "更新用戶", + "actionGetUser": "獲取用戶", + "actionGetOrgUser": "獲取組織用戶", + "actionListOrgDomains": "列出組織域", + "actionCreateSite": "創建站點", + "actionDeleteSite": "刪除站點", + "actionGetSite": "獲取站點", + "actionListSites": "站點列表", + "actionApplyBlueprint": "應用藍圖", + "actionListBlueprints": "藍圖列表", + "actionGetBlueprint": "獲取藍圖", + "setupToken": "設置令牌", + "setupTokenDescription": "從伺服器控制台輸入設定令牌。", + "setupTokenRequired": "需要設置令牌", + "actionUpdateSite": "更新站點", + "actionListSiteRoles": "允許站點角色列表", + "actionCreateResource": "創建資源", + "actionDeleteResource": "刪除資源", + "actionGetResource": "獲取資源", + "actionListResource": "列出資源", + "actionUpdateResource": "更新資源", + "actionListResourceUsers": "列出資源用戶", + "actionSetResourceUsers": "設置資源用戶", + "actionSetAllowedResourceRoles": "設置允許的資源角色", + "actionListAllowedResourceRoles": "列出允許的資源角色", + "actionSetResourcePassword": "設置資源密碼", + "actionSetResourcePincode": "設置資源粉碼", + "actionSetResourceEmailWhitelist": "設置資源電子郵件白名單", + "actionGetResourceEmailWhitelist": "獲取資源電子郵件白名單", + "actionCreateTarget": "創建目標", + "actionDeleteTarget": "刪除目標", + "actionGetTarget": "獲取目標", + "actionListTargets": "列表目標", + "actionUpdateTarget": "更新目標", + "actionCreateRole": "創建角色", + "actionDeleteRole": "刪除角色", + "actionGetRole": "獲取角色", + "actionListRole": "角色列表", + "actionUpdateRole": "更新角色", + "actionListAllowedRoleResources": "列表允許的角色資源", + "actionInviteUser": "邀請用戶", + "actionRemoveUser": "刪除用戶", + "actionListUsers": "列出用戶", + "actionAddUserRole": "添加用戶角色", + "actionSetUserOrgRoles": "Set User Roles", + "actionGenerateAccessToken": "生成訪問令牌", + "actionDeleteAccessToken": "刪除訪問令牌", + "actionListAccessTokens": "訪問令牌", + "actionCreateResourceRule": "創建資源規則", + "actionDeleteResourceRule": "刪除資源規則", + "actionListResourceRules": "列出資源規則", + "actionUpdateResourceRule": "更新資源規則", + "actionListOrgs": "列出組織", + "actionCheckOrgId": "檢查組織ID", + "actionCreateOrg": "創建組織", + "actionDeleteOrg": "刪除組織", + "actionListApiKeys": "列出 API 金鑰", + "actionListApiKeyActions": "列出 API 金鑰動作", + "actionSetApiKeyActions": "設置 API 金鑰允許的操作", + "actionCreateApiKey": "創建 API 金鑰", + "actionDeleteApiKey": "刪除 API 金鑰", + "actionCreateIdp": "創建 IDP", + "actionUpdateIdp": "更新 IDP", + "actionDeleteIdp": "刪除 IDP", + "actionListIdps": "列出 IDP", + "actionGetIdp": "獲取 IDP", + "actionCreateIdpOrg": "創建 IDP 組織策略", + "actionDeleteIdpOrg": "刪除 IDP 組織策略", + "actionListIdpOrgs": "列出 IDP 組織", + "actionUpdateIdpOrg": "更新 IDP 組織", + "actionCreateClient": "創建用戶端", + "actionDeleteClient": "刪除用戶端", + "actionUpdateClient": "更新用戶端", + "actionListClients": "列出用戶端", + "actionGetClient": "獲取用戶端", + "actionCreateSiteResource": "創建站點資源", + "actionDeleteSiteResource": "刪除站點資源", + "actionGetSiteResource": "獲取站點資源", + "actionListSiteResources": "列出站點資源", + "actionUpdateSiteResource": "更新站點資源", + "actionListInvitations": "邀請列表", + "actionExportLogs": "匯出日誌", + "actionViewLogs": "查看日誌", + "noneSelected": "未選擇", + "orgNotFound2": "未找到組織。", + "searchProgress": "搜索中...", + "create": "創建", + "orgs": "組織", + "loginError": "登錄時出錯", + "loginRequiredForDevice": "需要登入以驗證您的裝置。", + "passwordForgot": "忘記密碼?", + "otpAuth": "兩步驗證", + "otpAuthDescription": "從您的身份驗證程序中輸入代碼或您的單次備份代碼。", + "otpAuthSubmit": "提交代碼", + "idpContinue": "或者繼續", + "otpAuthBack": "返回登錄", + "navbar": "導航菜單", + "navbarDescription": "應用程式的主導航菜單", + "navbarDocsLink": "文件", + "otpErrorEnable": "無法啟用 2FA", + "otpErrorEnableDescription": "啟用 2FA 時出錯", + "otpSetupCheckCode": "請輸入您的 6 位數字代碼", + "otpSetupCheckCodeRetry": "無效的代碼。請重試。", + "otpSetup": "啟用兩步驗證", + "otpSetupDescription": "用額外的保護層來保護您的帳戶", + "otpSetupScanQr": "用您的身份驗證程序掃描此二維碼或手動輸入金鑰:", + "otpSetupSecretCode": "驗證器代碼", + "otpSetupSuccess": "啟用兩步驗證", + "otpSetupSuccessStoreBackupCodes": "您的帳戶現在更加安全。不要忘記保存您的備份代碼。", + "otpErrorDisable": "無法禁用 2FA", + "otpErrorDisableDescription": "禁用 2FA 時出錯", + "otpRemove": "禁用兩步驗證", + "otpRemoveDescription": "為您的帳戶禁用兩步驗證", + "otpRemoveSuccess": "雙重身份驗證已禁用", + "otpRemoveSuccessMessage": "您的帳戶已禁用雙重身份驗證。您可以隨時再次啟用它。", + "otpRemoveSubmit": "禁用兩步驗證", + "paginator": "第 {current} 頁,共 {last} 頁", + "paginatorToFirst": "轉到第一頁", + "paginatorToPrevious": "轉到上一頁", + "paginatorToNext": "轉到下一頁", + "paginatorToLast": "轉到最後一頁", + "copyText": "複製文本", + "copyTextFailed": "複製文本失敗: ", + "copyTextClipboard": "複製到剪貼簿", + "inviteErrorInvalidConfirmation": "無效確認", + "passwordRequired": "必須填寫密碼", + "allowAll": "允許所有", + "permissionsAllowAll": "允許所有權限", + "githubUsernameRequired": "必須填寫 GitHub 使用者名稱", + "supportKeyRequired": "必須填寫支持者金鑰", + "passwordRequirementsChars": "密碼至少需要 8 個字元", + "language": "語言", + "verificationCodeRequired": "必須輸入代碼", + "userErrorNoUpdate": "沒有要更新的用戶", + "siteErrorNoUpdate": "沒有要更新的站點", + "resourceErrorNoUpdate": "沒有可更新的資源", + "authErrorNoUpdate": "沒有要更新的身份驗證資訊", + "orgErrorNoUpdate": "沒有要更新的組織", + "orgErrorNoProvided": "未提供組織", + "apiKeysErrorNoUpdate": "沒有要更新的 API 金鑰", + "sidebarOverview": "概覽", + "sidebarHome": "首頁", + "sidebarSites": "站點", + "sidebarResources": "資源", + "sidebarProxyResources": "公開", + "sidebarClientResources": "私有", + "sidebarAccessControl": "訪問控制", + "sidebarLogsAndAnalytics": "日誌與分析", + "sidebarUsers": "用戶", + "sidebarAdmin": "管理員", + "sidebarInvitations": "邀請", + "sidebarRoles": "角色", + "sidebarShareableLinks": "分享連結", + "sidebarApiKeys": "API 金鑰", + "sidebarSettings": "設置", + "sidebarAllUsers": "所有用戶", + "sidebarIdentityProviders": "身份提供商", + "sidebarLicense": "證書", + "sidebarClients": "用戶端", + "sidebarUserDevices": "使用者", + "sidebarMachineClients": "機器", + "sidebarDomains": "域", + "sidebarGeneral": "管理", + "sidebarLogAndAnalytics": "日誌與分析", + "sidebarBluePrints": "藍圖", + "sidebarOrganization": "組織", + "sidebarLogsAnalytics": "分析", + "blueprints": "藍圖", + "blueprintsDescription": "應用聲明配置並查看先前運行的", + "blueprintAdd": "添加藍圖", + "blueprintGoBack": "查看所有藍圖", + "blueprintCreate": "創建藍圖", + "blueprintCreateDescription2": "按照下面的步驟創建和應用新的藍圖", + "blueprintDetails": "藍圖詳細資訊", + "blueprintDetailsDescription": "查看應用藍圖的結果和發生的任何錯誤", + "blueprintInfo": "藍圖資訊", + "message": "留言", + "blueprintContentsDescription": "定義描述您基礎設施的 YAML 內容", + "blueprintErrorCreateDescription": "應用藍圖時出錯", + "blueprintErrorCreate": "創建藍圖時出錯", + "searchBlueprintProgress": "搜索藍圖...", + "appliedAt": "應用於", + "source": "來源", + "contents": "目錄", + "parsedContents": "解析內容 (只讀)", + "enableDockerSocket": "啟用 Docker 藍圖", + "enableDockerSocketDescription": "啟用 Docker Socket 標籤擦除藍圖標籤。套接字路徑必須提供給新的。", + "enableDockerSocketLink": "了解更多", + "viewDockerContainers": "查看停靠容器", + "containersIn": "{siteName} 中的容器", + "selectContainerDescription": "選擇任何容器作為目標的主機名。點擊埠使用埠。", + "containerName": "名稱", + "containerImage": "圖片", + "containerState": "狀態", + "containerNetworks": "網路", + "containerHostnameIp": "主機名/IP", + "containerLabels": "標籤", + "containerLabelsCount": "{count, plural, other {# 標籤}}", + "containerLabelsTitle": "容器標籤", + "containerLabelEmpty": "<為空>", + "containerPorts": "埠", + "containerPortsMore": "+{count} 更多", + "containerActions": "行動", + "select": "選擇", + "noContainersMatchingFilters": "沒有找到匹配當前過濾器的容器。", + "showContainersWithoutPorts": "顯示沒有埠的容器", + "showStoppedContainers": "顯示已停止的容器", + "noContainersFound": "未找到容器。請確保 Docker 容器正在運行。", + "searchContainersPlaceholder": "在 {count} 個容器中搜索...", + "searchResultsCount": "{count, plural, other {# 個結果}}", + "filters": "篩選器", + "filterOptions": "過濾器選項", + "filterPorts": "埠", + "filterStopped": "已停止", + "clearAllFilters": "清除所有過濾器", + "columns": "列", + "toggleColumns": "切換列", + "refreshContainersList": "刷新容器列表", + "searching": "搜索中...", + "noContainersFoundMatching": "未找到與 \"{filter}\" 匹配的容器。", + "light": "淺色", + "dark": "深色", + "system": "系統", + "theme": "主題", + "subnetRequired": "子網是必填項", + "initialSetupTitle": "初始伺服器設置", + "initialSetupDescription": "創建初始伺服器管理員帳戶。 只能存在一個伺服器管理員。 您可以隨時更改這些憑據。", + "createAdminAccount": "創建管理員帳戶", + "setupErrorCreateAdmin": "創建伺服器管理員帳戶時發生錯誤。", + "certificateStatus": "證書狀態", + "loading": "載入中", + "restart": "重啟", + "domains": "域", + "domainsDescription": "管理您的組織域", + "domainsSearch": "搜索域...", + "domainAdd": "添加域", + "domainAddDescription": "在您的組織中註冊新域", + "domainCreate": "創建域", + "domainCreatedDescription": "域創建成功", + "domainDeletedDescription": "成功刪除域", + "domainQuestionRemove": "您確定要從您的帳戶中刪除域名嗎?", + "domainMessageRemove": "移除後,該域將不再與您的帳戶關聯。", + "domainConfirmDelete": "確認刪除域", + "domainDelete": "刪除域", + "domain": "域", + "selectDomainTypeNsName": "域委派(NS)", + "selectDomainTypeNsDescription": "此域及其所有子域。當您希望控制整個域區域時使用此選項。", + "selectDomainTypeCnameName": "單個域(CNAME)", + "selectDomainTypeCnameDescription": "僅此特定域。用於單個子域或特定域條目。", + "selectDomainTypeWildcardName": "通配符域", + "selectDomainTypeWildcardDescription": "此域名及其子域名。", + "domainDelegation": "單個域", + "selectType": "選擇一個類型", + "actions": "操作", + "refresh": "刷新", + "refreshError": "刷新數據失敗", + "verified": "已驗證", + "pending": "待定", + "sidebarBilling": "計費", + "billing": "計費", + "orgBillingDescription": "管理您的帳單資訊和訂閱", + "github": "GitHub", + "pangolinHosted": "Pangolin 託管", + "fossorial": "Fossorial", + "completeAccountSetup": "完成帳戶設定", + "completeAccountSetupDescription": "設置您的密碼以開始", + "accountSetupSent": "我們將發送帳號設定代碼到該電子郵件地址。", + "accountSetupCode": "設置代碼", + "accountSetupCodeDescription": "請檢查您的信箱以獲取設置代碼。", + "passwordCreate": "創建密碼", + "passwordCreateConfirm": "確認密碼", + "accountSetupSubmit": "發送設置代碼", + "completeSetup": "完成設置", + "accountSetupSuccess": "帳號設定完成!歡迎來到 Pangolin!", + "documentation": "文件", + "saveAllSettings": "保存所有設置", + "saveResourceTargets": "儲存目標", + "saveResourceHttp": "儲存代理設定", + "saveProxyProtocol": "儲存代理協定設定", + "settingsUpdated": "設置已更新", + "settingsUpdatedDescription": "所有設置已成功更新", + "settingsErrorUpdate": "設置更新失敗", + "settingsErrorUpdateDescription": "更新設置時發生錯誤", + "sidebarCollapse": "摺疊", + "sidebarExpand": "展開", + "productUpdateMoreInfo": "還有 {noOfUpdates} 項更新", + "productUpdateInfo": "{noOfUpdates} 項更新", + "productUpdateWhatsNew": "新功能", + "productUpdateTitle": "產品更新", + "productUpdateEmpty": "沒有更新", + "dismissAll": "全部關閉", + "pangolinUpdateAvailable": "有可用更新", + "pangolinUpdateAvailableInfo": "版本 {version} 已準備好安裝", + "pangolinUpdateAvailableReleaseNotes": "查看發行說明", + "newtUpdateAvailable": "更新可用", + "newtUpdateAvailableInfo": "新版本的 Newt 已可用。請更新到最新版本以獲得最佳體驗。", + "domainPickerEnterDomain": "域名", + "domainPickerPlaceholder": "example.com", + "domainPickerDescription": "輸入資源的完整域名以查看可用選項。", + "domainPickerDescriptionSaas": "輸入完整域名、子域或名稱以查看可用選項。", + "domainPickerTabAll": "所有", + "domainPickerTabOrganization": "組織", + "domainPickerTabProvided": "提供的", + "domainPickerSortAsc": "A-Z", + "domainPickerSortDesc": "Z-A", + "domainPickerCheckingAvailability": "檢查可用性...", + "domainPickerNoMatchingDomains": "未找到匹配的域名。嘗試不同的域名或檢查您組織的域名設置。", + "domainPickerOrganizationDomains": "組織域", + "domainPickerProvidedDomains": "提供的域", + "domainPickerSubdomain": "子域:{subdomain}", + "domainPickerNamespace": "命名空間:{namespace}", + "domainPickerShowMore": "顯示更多", + "regionSelectorTitle": "選擇區域", + "regionSelectorInfo": "選擇區域以幫助提升您所在地的性能。您不必與伺服器在相同的區域。", + "regionSelectorPlaceholder": "選擇一個區域", + "regionSelectorComingSoon": "即將推出", + "billingLoadingSubscription": "正在載入訂閱...", + "billingFreeTier": "免費層", + "billingWarningOverLimit": "警告:您已超出一個或多個使用限制。在您修改訂閱或調整使用情況之前,您的站點將無法連接。", + "billingUsageLimitsOverview": "使用限制概覽", + "billingMonitorUsage": "監控您的使用情況以對比已配置的限制。如需提高限制請聯絡我們 support@pangolin.net。", + "billingDataUsage": "數據使用情況", + "billingOnlineTime": "站點在線時間", + "billingUsers": "活躍用戶", + "billingDomains": "活躍域", + "billingRemoteExitNodes": "活躍自託管節點", + "billingNoLimitConfigured": "未配置限制", + "billingEstimatedPeriod": "估計結算週期", + "billingIncludedUsage": "包含的使用量", + "billingIncludedUsageDescription": "您當前訂閱計劃中包含的使用量", + "billingFreeTierIncludedUsage": "免費層使用額度", + "billingIncluded": "包含", + "billingEstimatedTotal": "預計總額:", + "billingNotes": "備註", + "billingEstimateNote": "這是根據您當前使用情況的估算。", + "billingActualChargesMayVary": "實際費用可能會有變化。", + "billingBilledAtEnd": "您將在結算週期結束時被計費。", + "billingModifySubscription": "修改訂閱", + "billingStartSubscription": "開始訂閱", + "billingRecurringCharge": "週期性收費", + "billingManageSubscriptionSettings": "管理您的訂閱設置和偏好", + "billingNoActiveSubscription": "您沒有活躍的訂閱。開始訂閱以增加使用限制。", + "billingFailedToLoadSubscription": "無法載入訂閱", + "billingFailedToLoadUsage": "無法載入使用情況", + "billingFailedToGetCheckoutUrl": "無法獲取結帳網址", + "billingPleaseTryAgainLater": "請稍後再試。", + "billingCheckoutError": "結帳錯誤", + "billingFailedToGetPortalUrl": "無法獲取門戶網址", + "billingPortalError": "門戶錯誤", + "billingDataUsageInfo": "當連接到雲端時,您將為透過安全隧道傳輸的所有數據收取費用。 這包括您所有站點的進出流量。 當您達到上限時,您的站點將斷開連接,直到您升級計劃或減少使用。使用節點時不收取數據。", + "billingOnlineTimeInfo": "您要根據您的網站連接到雲端的時間長短收取費用。 例如,44,640 分鐘等於一個 24/7 全月運行的網站。 當您達到上限時,您的站點將斷開連接,直到您升級計劃或減少使用。使用節點時不收取費用。", + "billingUsersInfo": "根據您組織中的活躍用戶數量收費。按日計算帳單。", + "billingDomainInfo": "根據組織中活躍域的數量收費。按日計算帳單。", + "billingRemoteExitNodesInfo": "根據您組織中已管理節點的數量收費。按日計算帳單。", + "domainNotFound": "域未找到", + "domainNotFoundDescription": "此資源已禁用,因為該域不再在我們的系統中存在。請為此資源設置一個新域。", + "failed": "失敗", + "createNewOrgDescription": "創建一個新組織", + "organization": "組織", + "port": "埠", + "securityKeyManage": "管理安全金鑰", + "securityKeyDescription": "添加或刪除用於無密碼認證的安全金鑰", + "securityKeyRegister": "註冊新的安全金鑰", + "securityKeyList": "您的安全金鑰", + "securityKeyNone": "尚未註冊安全金鑰", + "securityKeyNameRequired": "名稱為必填項", + "securityKeyRemove": "刪除", + "securityKeyLastUsed": "上次使用:{date}", + "securityKeyNameLabel": "名稱", + "securityKeyRegisterSuccess": "安全金鑰註冊成功", + "securityKeyRegisterError": "註冊安全金鑰失敗", + "securityKeyRemoveSuccess": "安全金鑰刪除成功", + "securityKeyRemoveError": "刪除安全金鑰失敗", + "securityKeyLoadError": "載入安全金鑰失敗", + "securityKeyLogin": "使用安全金鑰繼續", + "securityKeyAuthError": "使用安全金鑰認證失敗", + "securityKeyRecommendation": "考慮在其他設備上註冊另一個安全金鑰,以確保不會被鎖定在您的帳戶之外。", + "registering": "註冊中...", + "securityKeyPrompt": "請使用您的安全金鑰驗證身份。確保您的安全金鑰已連接並準備好。", + "securityKeyBrowserNotSupported": "您的瀏覽器不支持安全金鑰。請使用像 Chrome、Firefox 或 Safari 這樣的現代瀏覽器。", + "securityKeyPermissionDenied": "請允許訪問您的安全金鑰以繼續登錄。", + "securityKeyRemovedTooQuickly": "請保持您的安全金鑰連接,直到登錄過程完成。", + "securityKeyNotSupported": "您的安全金鑰可能不相容。請嘗試不同的安全金鑰。", + "securityKeyUnknownError": "使用安全金鑰時出現問題。請再試一次。", + "twoFactorRequired": "註冊安全金鑰需要兩步驗證。", + "twoFactor": "兩步驗證", + "twoFactorAuthentication": "兩步驗證", + "twoFactorDescription": "這個組織需要雙重身份驗證。", + "enableTwoFactor": "啟用兩步驗證", + "organizationSecurityPolicy": "組織安全政策", + "organizationSecurityPolicyDescription": "此機構擁有安全要求,您必須先滿足才能訪問", + "securityRequirements": "安全要求", + "allRequirementsMet": "已滿足所有要求", + "completeRequirementsToContinue": "完成下面的要求以繼續訪問此組織", + "youCanNowAccessOrganization": "您現在可以訪問此組織", + "reauthenticationRequired": "會話長度", + "reauthenticationDescription": "該機構要求您每 {maxDays} 天登錄一次。", + "reauthenticationDescriptionHours": "該機構要求您每 {maxHours} 小時登錄一次。", + "reauthenticateNow": "再次登錄", + "adminEnabled2FaOnYourAccount": "管理員已為 {email} 啟用兩步驗證。請完成設置以繼續。", + "securityKeyAdd": "添加安全金鑰", + "securityKeyRegisterTitle": "註冊新安全金鑰", + "securityKeyRegisterDescription": "連接您的安全金鑰並輸入名稱以便識別", + "securityKeyTwoFactorRequired": "要求兩步驗證", + "securityKeyTwoFactorDescription": "請輸入你的兩步驗證代碼以註冊安全金鑰", + "securityKeyTwoFactorRemoveDescription": "請輸入你的兩步驗證代碼以移除安全金鑰", + "securityKeyTwoFactorCode": "雙因素代碼", + "securityKeyRemoveTitle": "移除安全金鑰", + "securityKeyRemoveDescription": "輸入您的密碼以移除安全金鑰 \"{name}\"", + "securityKeyNoKeysRegistered": "沒有註冊安全金鑰", + "securityKeyNoKeysDescription": "添加安全金鑰以加強您的帳戶安全", + "createDomainRequired": "必須輸入域", + "createDomainAddDnsRecords": "添加 DNS 記錄", + "createDomainAddDnsRecordsDescription": "將以下 DNS 記錄添加到您的域名提供商以完成設置。", + "createDomainNsRecords": "NS 記錄", + "createDomainRecord": "記錄", + "createDomainType": "類型:", + "createDomainName": "名稱:", + "createDomainValue": "值:", + "createDomainCnameRecords": "CNAME 記錄", + "createDomainARecords": "A記錄", + "createDomainRecordNumber": "記錄 {number}", + "createDomainTxtRecords": "TXT 記錄", + "createDomainSaveTheseRecords": "保存這些記錄", + "createDomainSaveTheseRecordsDescription": "務必保存這些 DNS 記錄,因為您將無法再次查看它們。", + "createDomainDnsPropagation": "DNS 傳播", + "createDomainDnsPropagationDescription": "DNS 更改可能需要一些時間才能在網路上傳播。這可能需要從幾分鐘到 48 小時,具體取決於您的 DNS 提供商和 TTL 設置。", + "resourcePortRequired": "非 HTTP 資源必須輸入埠號", + "resourcePortNotAllowed": "HTTP 資源不應設置埠號", + "billingPricingCalculatorLink": "價格計算機", + "signUpTerms": { + "IAgreeToThe": "我同意", + "termsOfService": "服務條款", + "and": "和", + "privacyPolicy": "隱私政策" + }, + "signUpMarketing": { + "keepMeInTheLoop": "透過電子郵件接收新聞、更新和新功能通知。" + }, + "siteRequired": "需要站點。", + "olmTunnel": "Olm 隧道", + "olmTunnelDescription": "使用 Olm 進行用戶端連接", + "errorCreatingClient": "創建用戶端出錯", + "clientDefaultsNotFound": "未找到用戶端預設值", + "createClient": "創建用戶端", + "createClientDescription": "創建一個新用戶端來連接您的站點", + "seeAllClients": "查看所有用戶端", + "clientInformation": "用戶端資訊", + "clientNamePlaceholder": "用戶端名稱", + "address": "地址", + "subnetPlaceholder": "子網", + "addressDescription": "此用戶端將用於連接的地址", + "selectSites": "選擇站點", + "sitesDescription": "用戶端將與所選站點進行連接", + "clientInstallOlm": "安裝 Olm", + "clientInstallOlmDescription": "在您的系統上運行 Olm", + "clientOlmCredentials": "Olm 憑據", + "clientOlmCredentialsDescription": "這是 Olm 伺服器的身份驗證方式", + "olmEndpoint": "Olm 端點", + "olmId": "Olm ID", + "olmSecretKey": "Olm 私鑰", + "clientCredentialsSave": "保存您的憑據", + "clientCredentialsSaveDescription": "該資訊僅會顯示一次,請確保將其複製到安全位置。", + "generalSettingsDescription": "配置此用戶端的常規設置", + "clientUpdated": "用戶端已更新", + "clientUpdatedDescription": "用戶端已更新。", + "clientUpdateFailed": "更新用戶端失敗", + "clientUpdateError": "更新用戶端時出錯。", + "sitesFetchFailed": "獲取站點失敗", + "sitesFetchError": "獲取站點時出錯。", + "olmErrorFetchReleases": "獲取 Olm 發布版本時出錯。", + "olmErrorFetchLatest": "獲取最新 Olm 發布版本時出錯。", + "enterCidrRange": "輸入 CIDR 範圍", + "resourceEnableProxy": "啟用公共代理", + "resourceEnableProxyDescription": "啟用到此資源的公共代理。這允許外部網路通過開放埠訪問資源。需要 Traefik 配置。", + "externalProxyEnabled": "外部代理已啟用", + "addNewTarget": "添加新目標", + "targetsList": "目標列表", + "advancedMode": "高級模式", + "advancedSettings": "進階設定", + "targetErrorDuplicateTargetFound": "找到重複的目標", + "healthCheckHealthy": "正常", + "healthCheckUnhealthy": "不正常", + "healthCheckUnknown": "未知", + "healthCheck": "健康檢查", + "configureHealthCheck": "配置健康檢查", + "configureHealthCheckDescription": "為 {target} 設置健康監控", + "enableHealthChecks": "啟用健康檢查", + "enableHealthChecksDescription": "監視此目標的健康狀況。如果需要,您可以監視一個不同的終點。", + "healthScheme": "方法", + "healthSelectScheme": "選擇方法", + "healthCheckPortInvalid": "健康檢查連接埠必須介於 1 到 65535 之間", + "healthCheckPath": "路徑", + "healthHostname": "IP / 主機", + "healthPort": "埠", + "healthCheckPathDescription": "用於檢查健康狀態的路徑。", + "healthyIntervalSeconds": "正常間隔", + "unhealthyIntervalSeconds": "不正常間隔", + "IntervalSeconds": "正常間隔", + "timeoutSeconds": "超時", + "timeIsInSeconds": "時間以秒為單位", + "retryAttempts": "重試次數", + "expectedResponseCodes": "期望響應代碼", + "expectedResponseCodesDescription": "HTTP 狀態碼表示健康狀態。如留空,200-300 被視為健康。", + "customHeaders": "自訂 Headers", + "customHeadersDescription": "Header 斷行分隔:Header 名稱:值", + "headersValidationError": "Header 必須是格式:Header 名稱:值。", + "saveHealthCheck": "保存健康檢查", + "healthCheckSaved": "健康檢查已保存", + "healthCheckSavedDescription": "健康檢查配置已成功保存。", + "healthCheckError": "健康檢查錯誤", + "healthCheckErrorDescription": "保存健康檢查配置時出錯", + "healthCheckPathRequired": "健康檢查路徑為必填項", + "healthCheckMethodRequired": "HTTP 方法為必填項", + "healthCheckIntervalMin": "檢查間隔必須至少為 5 秒", + "healthCheckTimeoutMin": "超時必須至少為 1 秒", + "healthCheckRetryMin": "重試次數必須至少為 1 次", + "httpMethod": "HTTP 方法", + "selectHttpMethod": "選擇 HTTP 方法", + "domainPickerSubdomainLabel": "子域名", + "domainPickerBaseDomainLabel": "根域名", + "domainPickerSearchDomains": "搜索域名...", + "domainPickerNoDomainsFound": "未找到域名", + "domainPickerLoadingDomains": "載入域名...", + "domainPickerSelectBaseDomain": "選擇根域名...", + "domainPickerNotAvailableForCname": "不適用於 CNAME 域", + "domainPickerEnterSubdomainOrLeaveBlank": "輸入子域名或留空以使用根域名。", + "domainPickerEnterSubdomainToSearch": "輸入一個子域名以搜索並從可用免費域名中選擇。", + "domainPickerFreeDomains": "免費域名", + "domainPickerSearchForAvailableDomains": "搜索可用域名", + "domainPickerNotWorkSelfHosted": "注意:自託管實例當前不提供免費的域名。", + "resourceDomain": "域名", + "resourceEditDomain": "編輯域名", + "siteName": "站點名稱", + "proxyPort": "埠", + "resourcesTableProxyResources": "代理資源", + "resourcesTableClientResources": "用戶端資源", + "resourcesTableNoProxyResourcesFound": "未找到代理資源。", + "resourcesTableNoInternalResourcesFound": "未找到內部資源。", + "resourcesTableDestination": "目標", + "resourcesTableAlias": "別名", + "resourcesTableClients": "用戶端", + "resourcesTableAndOnlyAccessibleInternally": "且僅在與用戶端連接時可內部訪問。", + "resourcesTableNoTargets": "無目標", + "resourcesTableHealthy": "健康", + "resourcesTableDegraded": "降級", + "resourcesTableOffline": "離線", + "resourcesTableUnknown": "未知", + "resourcesTableNotMonitored": "未監控", + "editInternalResourceDialogEditClientResource": "編輯用戶端資源", + "editInternalResourceDialogUpdateResourceProperties": "更新 {resourceName} 的資源屬性和目標配置。", + "editInternalResourceDialogResourceProperties": "資源屬性", + "editInternalResourceDialogName": "名稱", + "editInternalResourceDialogProtocol": "協議", + "editInternalResourceDialogSitePort": "站點埠", + "editInternalResourceDialogTargetConfiguration": "目標配置", + "editInternalResourceDialogCancel": "取消", + "editInternalResourceDialogSaveResource": "保存資源", + "editInternalResourceDialogSuccess": "成功", + "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "內部資源更新成功", + "editInternalResourceDialogError": "錯誤", + "editInternalResourceDialogFailedToUpdateInternalResource": "更新內部資源失敗", + "editInternalResourceDialogNameRequired": "名稱為必填項", + "editInternalResourceDialogNameMaxLength": "名稱長度必須小於 255 個字元", + "editInternalResourceDialogProxyPortMin": "代理埠必須至少為 1", + "editInternalResourceDialogProxyPortMax": "代理埠必須小於 65536", + "editInternalResourceDialogInvalidIPAddressFormat": "無效的 IP 位址格式", + "editInternalResourceDialogDestinationPortMin": "目標埠必須至少為 1", + "editInternalResourceDialogDestinationPortMax": "目標埠必須小於 65536", + "editInternalResourceDialogPortModeRequired": "連接埠模式需要協定、代理連接埠和目標連接埠", + "editInternalResourceDialogMode": "模式", + "editInternalResourceDialogModePort": "連接埠", + "editInternalResourceDialogModeHost": "主機", + "editInternalResourceDialogModeCidr": "CIDR", + "editInternalResourceDialogDestination": "目的地", + "editInternalResourceDialogDestinationHostDescription": "站點網路上資源的 IP 位址或主機名稱。", + "editInternalResourceDialogDestinationIPDescription": "站點網路上資源的 IP 或主機名稱位址。", + "editInternalResourceDialogDestinationCidrDescription": "站點網路上資源的 CIDR 範圍。", + "editInternalResourceDialogAlias": "別名", + "editInternalResourceDialogAliasDescription": "此資源的可選內部 DNS 別名。", + "createInternalResourceDialogNoSitesAvailable": "暫無可用站點", + "createInternalResourceDialogNoSitesAvailableDescription": "您需要至少配置一個子網的 Newt 站點來創建內部資源。", + "createInternalResourceDialogClose": "關閉", + "createInternalResourceDialogCreateClientResource": "創建用戶端資源", + "createInternalResourceDialogCreateClientResourceDescription": "創建一個新資源,該資源將可供連接到所選站點的用戶端訪問。", + "createInternalResourceDialogResourceProperties": "資源屬性", + "createInternalResourceDialogName": "名稱", + "createInternalResourceDialogSite": "站點", + "selectSite": "選擇站點...", + "noSitesFound": "找不到站點。", + "createInternalResourceDialogProtocol": "協議", + "createInternalResourceDialogTcp": "TCP", + "createInternalResourceDialogUdp": "UDP", + "createInternalResourceDialogSitePort": "站點埠", + "createInternalResourceDialogSitePortDescription": "使用此埠在連接到用戶端時訪問站點上的資源。", + "createInternalResourceDialogTargetConfiguration": "目標配置", + "createInternalResourceDialogDestinationIPDescription": "站點網路上資源的 IP 或主機名地址。", + "createInternalResourceDialogDestinationPortDescription": "資源在目標 IP 上可訪問的埠。", + "createInternalResourceDialogCancel": "取消", + "createInternalResourceDialogCreateResource": "創建資源", + "createInternalResourceDialogSuccess": "成功", + "createInternalResourceDialogInternalResourceCreatedSuccessfully": "內部資源創建成功", + "createInternalResourceDialogError": "錯誤", + "createInternalResourceDialogFailedToCreateInternalResource": "創建內部資源失敗", + "createInternalResourceDialogNameRequired": "名稱為必填項", + "createInternalResourceDialogNameMaxLength": "名稱長度必須小於 255 個字元", + "createInternalResourceDialogPleaseSelectSite": "請選擇一個站點", + "createInternalResourceDialogProxyPortMin": "代理埠必須至少為 1", + "createInternalResourceDialogProxyPortMax": "代理埠必須小於 65536", + "createInternalResourceDialogInvalidIPAddressFormat": "無效的 IP 位址格式", + "createInternalResourceDialogDestinationPortMin": "目標埠必須至少為 1", + "createInternalResourceDialogDestinationPortMax": "目標埠必須小於 65536", + "createInternalResourceDialogPortModeRequired": "連接埠模式需要協定、代理連接埠和目標連接埠", + "createInternalResourceDialogMode": "模式", + "createInternalResourceDialogModePort": "連接埠", + "createInternalResourceDialogModeHost": "主機", + "createInternalResourceDialogModeCidr": "CIDR", + "createInternalResourceDialogDestination": "目的地", + "createInternalResourceDialogDestinationHostDescription": "站點網路上資源的 IP 位址或主機名稱。", + "createInternalResourceDialogDestinationCidrDescription": "站點網路上資源的 CIDR 範圍。", + "createInternalResourceDialogAlias": "別名", + "createInternalResourceDialogAliasDescription": "此資源的可選內部 DNS 別名。", + "siteConfiguration": "配置", + "siteAcceptClientConnections": "接受用戶端連接", + "siteAcceptClientConnectionsDescription": "允許其他設備透過此 Newt 實例使用用戶端作為閘道器連接。", + "siteAddress": "站點地址", + "siteAddressDescription": "指定主機的 IP 位址以供用戶端連接。這是 Pangolin 網路中站點的內部地址,供用戶端訪問。必須在 Org 子網內。", + "siteNameDescription": "站點的顯示名稱,可以稍後更改。", + "autoLoginExternalIdp": "自動使用外部 IDP 登錄", + "autoLoginExternalIdpDescription": "立即將用戶重定向到外部 IDP 進行身份驗證。", + "selectIdp": "選擇 IDP", + "selectIdpPlaceholder": "選擇一個 IDP...", + "selectIdpRequired": "在啟用自動登錄時,請選擇一個 IDP。", + "autoLoginTitle": "重定向中", + "autoLoginDescription": "正在將您重定向到外部身份提供商進行身份驗證。", + "autoLoginProcessing": "準備身份驗證...", + "autoLoginRedirecting": "重定向到登錄...", + "autoLoginError": "自動登錄錯誤", + "autoLoginErrorNoRedirectUrl": "未從身份提供商收到重定向 URL。", + "autoLoginErrorGeneratingUrl": "生成身份驗證 URL 失敗。", + "remoteExitNodeManageRemoteExitNodes": "遠程節點", + "remoteExitNodeDescription": "自我主機一個或多個遠程節點來擴展您的網路連接並減少對雲的依賴性", + "remoteExitNodes": "節點", + "searchRemoteExitNodes": "搜索節點...", + "remoteExitNodeAdd": "添加節點", + "remoteExitNodeErrorDelete": "刪除節點時出錯", + "remoteExitNodeQuestionRemove": "您確定要從組織中刪除該節點嗎?", + "remoteExitNodeMessageRemove": "一旦刪除,該節點將不再能夠訪問。", + "remoteExitNodeConfirmDelete": "確認刪除節點", + "remoteExitNodeDelete": "刪除節點", + "sidebarRemoteExitNodes": "遠程節點", + "remoteExitNodeId": "ID", + "remoteExitNodeSecretKey": "密鑰", + "remoteExitNodeCreate": { + "title": "創建節點", + "description": "創建一個新節點來擴展您的網路連接", + "viewAllButton": "查看所有節點", + "strategy": { + "title": "創建策略", + "description": "選擇此選項以手動配置您的節點或生成新憑據。", + "adopt": { + "title": "採納節點", + "description": "如果您已經擁有該節點的憑據,請選擇此項。" + }, + "generate": { + "title": "生成金鑰", + "description": "如果您想為節點生成新金鑰,請選擇此選項" + } }, - "siteRequired": "需要站點。", - "olmTunnel": "Olm 隧道", - "olmTunnelDescription": "使用 Olm 進行用戶端連接", - "errorCreatingClient": "創建用戶端出錯", - "clientDefaultsNotFound": "未找到用戶端預設值", - "createClient": "創建用戶端", - "createClientDescription": "創建一個新用戶端來連接您的站點", - "seeAllClients": "查看所有用戶端", - "clientInformation": "用戶端資訊", - "clientNamePlaceholder": "用戶端名稱", - "address": "地址", - "subnetPlaceholder": "子網", - "addressDescription": "此用戶端將用於連接的地址", - "selectSites": "選擇站點", - "sitesDescription": "用戶端將與所選站點進行連接", - "clientInstallOlm": "安裝 Olm", - "clientInstallOlmDescription": "在您的系統上運行 Olm", - "clientOlmCredentials": "Olm 憑據", - "clientOlmCredentialsDescription": "這是 Olm 伺服器的身份驗證方式", - "olmEndpoint": "Olm 端點", - "olmId": "Olm ID", - "olmSecretKey": "Olm 私鑰", - "clientCredentialsSave": "保存您的憑據", - "clientCredentialsSaveDescription": "該資訊僅會顯示一次,請確保將其複製到安全位置。", - "generalSettingsDescription": "配置此用戶端的常規設置", - "clientUpdated": "用戶端已更新", - "clientUpdatedDescription": "用戶端已更新。", - "clientUpdateFailed": "更新用戶端失敗", - "clientUpdateError": "更新用戶端時出錯。", - "sitesFetchFailed": "獲取站點失敗", - "sitesFetchError": "獲取站點時出錯。", - "olmErrorFetchReleases": "獲取 Olm 發布版本時出錯。", - "olmErrorFetchLatest": "獲取最新 Olm 發布版本時出錯。", - "remoteSubnets": "遠程子網", - "enterCidrRange": "輸入 CIDR 範圍", - "remoteSubnetsDescription": "添加可以通過用戶端遠端存取該站點的 CIDR 範圍。使用類似 10.0.0.0/24 的格式。這僅適用於 VPN 用戶端連接。", - "resourceEnableProxy": "啟用公共代理", - "resourceEnableProxyDescription": "啟用到此資源的公共代理。這允許外部網路通過開放埠訪問資源。需要 Traefik 配置。", - "externalProxyEnabled": "外部代理已啟用", - "addNewTarget": "添加新目標", - "targetsList": "目標列表", - "advancedMode": "高級模式", - "targetErrorDuplicateTargetFound": "找到重複的目標", - "healthCheckHealthy": "正常", - "healthCheckUnhealthy": "不正常", - "healthCheckUnknown": "未知", - "healthCheck": "健康檢查", - "configureHealthCheck": "配置健康檢查", - "configureHealthCheckDescription": "為 {target} 設置健康監控", - "enableHealthChecks": "啟用健康檢查", - "enableHealthChecksDescription": "監視此目標的健康狀況。如果需要,您可以監視一個不同的終點。", - "healthScheme": "方法", - "healthSelectScheme": "選擇方法", - "healthCheckPath": "路徑", - "healthHostname": "IP / 主機", - "healthPort": "埠", - "healthCheckPathDescription": "用於檢查健康狀態的路徑。", - "healthyIntervalSeconds": "正常間隔", - "unhealthyIntervalSeconds": "不正常間隔", - "IntervalSeconds": "正常間隔", - "timeoutSeconds": "超時", - "timeIsInSeconds": "時間以秒為單位", - "retryAttempts": "重試次數", - "expectedResponseCodes": "期望響應代碼", - "expectedResponseCodesDescription": "HTTP 狀態碼表示健康狀態。如留空,200-300 被視為健康。", - "customHeaders": "自訂 Headers", - "customHeadersDescription": "Header 斷行分隔:Header 名稱:值", - "headersValidationError": "Header 必須是格式:Header 名稱:值。", - "saveHealthCheck": "保存健康檢查", - "healthCheckSaved": "健康檢查已保存", - "healthCheckSavedDescription": "健康檢查配置已成功保存。", - "healthCheckError": "健康檢查錯誤", - "healthCheckErrorDescription": "保存健康檢查配置時出錯", - "healthCheckPathRequired": "健康檢查路徑為必填項", - "healthCheckMethodRequired": "HTTP 方法為必填項", - "healthCheckIntervalMin": "檢查間隔必須至少為 5 秒", - "healthCheckTimeoutMin": "超時必須至少為 1 秒", - "healthCheckRetryMin": "重試次數必須至少為 1 次", - "httpMethod": "HTTP 方法", - "selectHttpMethod": "選擇 HTTP 方法", - "domainPickerSubdomainLabel": "子域名", - "domainPickerBaseDomainLabel": "根域名", - "domainPickerSearchDomains": "搜索域名...", - "domainPickerNoDomainsFound": "未找到域名", - "domainPickerLoadingDomains": "載入域名...", - "domainPickerSelectBaseDomain": "選擇根域名...", - "domainPickerNotAvailableForCname": "不適用於 CNAME 域", - "domainPickerEnterSubdomainOrLeaveBlank": "輸入子域名或留空以使用根域名。", - "domainPickerEnterSubdomainToSearch": "輸入一個子域名以搜索並從可用免費域名中選擇。", - "domainPickerFreeDomains": "免費域名", - "domainPickerSearchForAvailableDomains": "搜索可用域名", - "domainPickerNotWorkSelfHosted": "注意:自託管實例當前不提供免費的域名。", - "resourceDomain": "域名", - "resourceEditDomain": "編輯域名", - "siteName": "站點名稱", - "proxyPort": "埠", - "resourcesTableProxyResources": "代理資源", - "resourcesTableClientResources": "用戶端資源", - "resourcesTableNoProxyResourcesFound": "未找到代理資源。", - "resourcesTableNoInternalResourcesFound": "未找到內部資源。", - "resourcesTableDestination": "目標", - "resourcesTableTheseResourcesForUseWith": "這些資源供...使用", - "resourcesTableClients": "用戶端", - "resourcesTableAndOnlyAccessibleInternally": "且僅在與用戶端連接時可內部訪問。", - "editInternalResourceDialogEditClientResource": "編輯用戶端資源", - "editInternalResourceDialogUpdateResourceProperties": "更新 {resourceName} 的資源屬性和目標配置。", - "editInternalResourceDialogResourceProperties": "資源屬性", - "editInternalResourceDialogName": "名稱", - "editInternalResourceDialogProtocol": "協議", - "editInternalResourceDialogSitePort": "站點埠", - "editInternalResourceDialogTargetConfiguration": "目標配置", - "editInternalResourceDialogCancel": "取消", - "editInternalResourceDialogSaveResource": "保存資源", - "editInternalResourceDialogSuccess": "成功", - "editInternalResourceDialogInternalResourceUpdatedSuccessfully": "內部資源更新成功", - "editInternalResourceDialogError": "錯誤", - "editInternalResourceDialogFailedToUpdateInternalResource": "更新內部資源失敗", - "editInternalResourceDialogNameRequired": "名稱為必填項", - "editInternalResourceDialogNameMaxLength": "名稱長度必須小於 255 個字元", - "editInternalResourceDialogProxyPortMin": "代理埠必須至少為 1", - "editInternalResourceDialogProxyPortMax": "代理埠必須小於 65536", - "editInternalResourceDialogInvalidIPAddressFormat": "無效的 IP 位址格式", - "editInternalResourceDialogDestinationPortMin": "目標埠必須至少為 1", - "editInternalResourceDialogDestinationPortMax": "目標埠必須小於 65536", - "createInternalResourceDialogNoSitesAvailable": "暫無可用站點", - "createInternalResourceDialogNoSitesAvailableDescription": "您需要至少配置一個子網的 Newt 站點來創建內部資源。", - "createInternalResourceDialogClose": "關閉", - "createInternalResourceDialogCreateClientResource": "創建用戶端資源", - "createInternalResourceDialogCreateClientResourceDescription": "創建一個新資源,該資源將可供連接到所選站點的用戶端訪問。", - "createInternalResourceDialogResourceProperties": "資源屬性", - "createInternalResourceDialogName": "名稱", - "createInternalResourceDialogSite": "站點", - "createInternalResourceDialogSelectSite": "選擇站點...", - "createInternalResourceDialogSearchSites": "搜索站點...", - "createInternalResourceDialogNoSitesFound": "未找到站點。", - "createInternalResourceDialogProtocol": "協議", - "createInternalResourceDialogTcp": "TCP", - "createInternalResourceDialogUdp": "UDP", - "createInternalResourceDialogSitePort": "站點埠", - "createInternalResourceDialogSitePortDescription": "使用此埠在連接到用戶端時訪問站點上的資源。", - "createInternalResourceDialogTargetConfiguration": "目標配置", - "createInternalResourceDialogDestinationIPDescription": "站點網路上資源的 IP 或主機名地址。", - "createInternalResourceDialogDestinationPortDescription": "資源在目標 IP 上可訪問的埠。", - "createInternalResourceDialogCancel": "取消", - "createInternalResourceDialogCreateResource": "創建資源", - "createInternalResourceDialogSuccess": "成功", - "createInternalResourceDialogInternalResourceCreatedSuccessfully": "內部資源創建成功", - "createInternalResourceDialogError": "錯誤", - "createInternalResourceDialogFailedToCreateInternalResource": "創建內部資源失敗", - "createInternalResourceDialogNameRequired": "名稱為必填項", - "createInternalResourceDialogNameMaxLength": "名稱長度必須小於 255 個字元", - "createInternalResourceDialogPleaseSelectSite": "請選擇一個站點", - "createInternalResourceDialogProxyPortMin": "代理埠必須至少為 1", - "createInternalResourceDialogProxyPortMax": "代理埠必須小於 65536", - "createInternalResourceDialogInvalidIPAddressFormat": "無效的 IP 位址格式", - "createInternalResourceDialogDestinationPortMin": "目標埠必須至少為 1", - "createInternalResourceDialogDestinationPortMax": "目標埠必須小於 65536", - "siteConfiguration": "配置", - "siteAcceptClientConnections": "接受用戶端連接", - "siteAcceptClientConnectionsDescription": "允許其他設備透過此 Newt 實例使用用戶端作為閘道器連接。", - "siteAddress": "站點地址", - "siteAddressDescription": "指定主機的 IP 位址以供用戶端連接。這是 Pangolin 網路中站點的內部地址,供用戶端訪問。必須在 Org 子網內。", - "autoLoginExternalIdp": "自動使用外部 IDP 登錄", - "autoLoginExternalIdpDescription": "立即將用戶重定向到外部 IDP 進行身份驗證。", - "selectIdp": "選擇 IDP", - "selectIdpPlaceholder": "選擇一個 IDP...", - "selectIdpRequired": "在啟用自動登錄時,請選擇一個 IDP。", - "autoLoginTitle": "重定向中", - "autoLoginDescription": "正在將您重定向到外部身份提供商進行身份驗證。", - "autoLoginProcessing": "準備身份驗證...", - "autoLoginRedirecting": "重定向到登錄...", - "autoLoginError": "自動登錄錯誤", - "autoLoginErrorNoRedirectUrl": "未從身份提供商收到重定向 URL。", - "autoLoginErrorGeneratingUrl": "生成身份驗證 URL 失敗。", - "remoteExitNodeManageRemoteExitNodes": "遠程節點", - "remoteExitNodeDescription": "自我主機一個或多個遠程節點來擴展您的網路連接並減少對雲的依賴性", - "remoteExitNodes": "節點", - "searchRemoteExitNodes": "搜索節點...", - "remoteExitNodeAdd": "添加節點", - "remoteExitNodeErrorDelete": "刪除節點時出錯", - "remoteExitNodeQuestionRemove": "您確定要從組織中刪除該節點嗎?", - "remoteExitNodeMessageRemove": "一旦刪除,該節點將不再能夠訪問。", - "remoteExitNodeConfirmDelete": "確認刪除節點", - "remoteExitNodeDelete": "刪除節點", - "sidebarRemoteExitNodes": "遠程節點", - "remoteExitNodeCreate": { - "title": "創建節點", - "description": "創建一個新節點來擴展您的網路連接", - "viewAllButton": "查看所有節點", - "strategy": { - "title": "創建策略", - "description": "選擇此選項以手動配置您的節點或生成新憑據。", - "adopt": { - "title": "採納節點", - "description": "如果您已經擁有該節點的憑據,請選擇此項。" - }, - "generate": { - "title": "生成金鑰", - "description": "如果您想為節點生成新金鑰,請選擇此選項" - } - }, - "adopt": { - "title": "採納現有節點", - "description": "輸入您想要採用的現有節點的憑據", - "nodeIdLabel": "節點 ID", - "nodeIdDescription": "您想要採用的現有節點的 ID", - "secretLabel": "金鑰", - "secretDescription": "現有節點的秘密金鑰", - "submitButton": "採用節點" - }, - "generate": { - "title": "生成的憑據", - "description": "使用這些生成的憑據來配置您的節點", - "nodeIdTitle": "節點 ID", - "secretTitle": "金鑰", - "saveCredentialsTitle": "將憑據添加到配置中", - "saveCredentialsDescription": "將這些憑據添加到您的自託管 Pangolin 節點設定檔中以完成連接。", - "submitButton": "創建節點" - }, - "validation": { - "adoptRequired": "在通過現有節點時需要節點ID和金鑰" - }, - "errors": { - "loadDefaultsFailed": "無法載入預設值", - "defaultsNotLoaded": "預設值未載入", - "createFailed": "創建節點失敗" - }, - "success": { - "created": "節點創建成功" - } + "adopt": { + "title": "採納現有節點", + "description": "輸入您想要採用的現有節點的憑據", + "nodeIdLabel": "節點 ID", + "nodeIdDescription": "您想要採用的現有節點的 ID", + "secretLabel": "金鑰", + "secretDescription": "現有節點的秘密金鑰", + "submitButton": "採用節點" }, - "remoteExitNodeSelection": "節點選擇", - "remoteExitNodeSelectionDescription": "為此本地站點選擇要路由流量的節點", - "remoteExitNodeRequired": "必須為本地站點選擇節點", - "noRemoteExitNodesAvailable": "無可用節點", - "noRemoteExitNodesAvailableDescription": "此組織沒有可用的節點。首先創建一個節點來使用本地站點。", - "exitNode": "出口節點", - "country": "國家", - "rulesMatchCountry": "當前基於源 IP", - "managedSelfHosted": { - "title": "託管自託管", - "description": "更可靠、維護成本更低的自架 Pangolin 伺服器,並附帶額外的附加功能", - "introTitle": "託管式自架 Pangolin", - "introDescription": "這是一種部署選擇,為那些希望簡潔和額外可靠的人設計,同時仍然保持他們的數據的私密性和自我託管性。", - "introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 — — 您的隧道、SSL 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:", - "benefitSimplerOperations": { - "title": "簡單的操作", - "description": "無需運行您自己的郵件伺服器或設置複雜的警報。您將從方框中獲得健康檢查和下限提醒。" - }, - "benefitAutomaticUpdates": { - "title": "自動更新", - "description": "雲儀錶板快速演化,所以您可以獲得新的功能和錯誤修復,而不必每次手動拉取新的容器。" - }, - "benefitLessMaintenance": { - "title": "減少維護時間", - "description": "沒有要管理的資料庫遷移、備份或額外的基礎設施。我們在雲端處理這個問題。" - }, - "benefitCloudFailover": { - "title": "雲端故障轉移", - "description": "如果您的節點發生故障,您的隧道可以暫時故障轉移到我們的雲端存取點,直到您將節點恢復線上狀態。" - }, - "benefitHighAvailability": { - "title": "高可用率(PoPs)", - "description": "您還可以將多個節點添加到您的帳戶中以獲取冗餘和更好的性能。" - }, - "benefitFutureEnhancements": { - "title": "將來的改進", - "description": "我們正在計劃添加更多的分析、警報和管理工具,使你的部署更加有力。" - }, - "docsAlert": { - "text": "在我們中更多地了解管理下的自託管選項", - "documentation": "文件" - }, - "convertButton": "將此節點轉換為管理自託管的" + "generate": { + "title": "生成的憑據", + "description": "使用這些生成的憑據來配置您的節點", + "nodeIdTitle": "節點 ID", + "secretTitle": "金鑰", + "saveCredentialsTitle": "將憑據添加到配置中", + "saveCredentialsDescription": "將這些憑據添加到您的自託管 Pangolin 節點設定檔中以完成連接。", + "submitButton": "創建節點" }, - "internationaldomaindetected": "檢測到國際域", - "willbestoredas": "儲存為:", - "roleMappingDescription": "確定當用戶啟用自動配送時如何分配他們的角色。", - "selectRole": "選擇角色", - "roleMappingExpression": "表達式", - "selectRolePlaceholder": "選擇角色", - "selectRoleDescription": "選擇一個角色,從此身份提供商分配給所有用戶", - "roleMappingExpressionDescription": "輸入一個 JMESPath 表達式來從 ID 令牌提取角色資訊", - "idpTenantIdRequired": "租戶 ID 是必需的", - "invalidValue": "無效的值", - "idpTypeLabel": "身份提供者類型", - "roleMappingExpressionPlaceholder": "例如: contains(group, 'admin' &'Admin' || 'Member'", - "idpGoogleConfiguration": "Google 配置", - "idpGoogleConfigurationDescription": "配置您的 Google OAuth2 憑據", - "idpGoogleClientIdDescription": "您的 Google OAuth2 用戶端 ID", - "idpGoogleClientSecretDescription": "您的 Google OAuth2 用戶端金鑰", - "idpAzureConfiguration": "Azure Entra ID 配置", - "idpAzureConfigurationDescription": "配置您的 Azure Entra ID OAuth2 憑據", - "idpTenantId": "租戶 ID", - "idpTenantIdPlaceholder": "您的租戶 ID", - "idpAzureTenantIdDescription": "您的 Azure 租戶ID (在 Azure Active Directory 概覽中發現)", - "idpAzureClientIdDescription": "您的 Azure 應用程式註冊用戶端 ID", - "idpAzureClientSecretDescription": "您的 Azure 應用程式註冊用戶端金鑰", - "idpGoogleTitle": "Google", - "idpGoogleAlt": "Google", - "idpAzureTitle": "Azure Entra ID", - "idpAzureAlt": "Azure", - "idpGoogleConfigurationTitle": "Google 配置", - "idpAzureConfigurationTitle": "Azure Entra ID 配置", - "idpTenantIdLabel": "租戶 ID", - "idpAzureClientIdDescription2": "您的 Azure 應用程式註冊用戶端 ID", - "idpAzureClientSecretDescription2": "您的 Azure 應用程式註冊用戶端金鑰", - "idpGoogleDescription": "Google OAuth2/OIDC 提供商", - "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider", - "subnet": "子網", - "subnetDescription": "此組織網路配置的子網。", - "authPage": "認證頁面", - "authPageDescription": "配置您的組織認證頁面", - "authPageDomain": "認證頁面域", - "noDomainSet": "沒有域設置", - "changeDomain": "更改域", - "selectDomain": "選擇域", - "restartCertificate": "重新啟動證書", - "editAuthPageDomain": "編輯認證頁面域", - "setAuthPageDomain": "設置認證頁面域", - "failedToFetchCertificate": "獲取證書失敗", - "failedToRestartCertificate": "重新啟動證書失敗", - "addDomainToEnableCustomAuthPages": "為您的組織添加域名以啟用自訂認證頁面", - "selectDomainForOrgAuthPage": "選擇組織認證頁面的域", - "domainPickerProvidedDomain": "提供的域", - "domainPickerFreeProvidedDomain": "免費提供的域", - "domainPickerVerified": "已驗證", - "domainPickerUnverified": "未驗證", - "domainPickerInvalidSubdomainStructure": "此子域包含無效的字元或結構。當您保存時,它將被自動清除。", - "domainPickerError": "錯誤", - "domainPickerErrorLoadDomains": "載入組織域名失敗", - "domainPickerErrorCheckAvailability": "檢查域可用性失敗", - "domainPickerInvalidSubdomain": "無效的子域", - "domainPickerInvalidSubdomainRemoved": "輸入 \"{sub}\" 已被移除,因為其無效。", - "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" 無法為 {domain} 變為有效。", - "domainPickerSubdomainSanitized": "子域已淨化", - "domainPickerSubdomainCorrected": "\"{sub}\" 已被更正為 \"{sanitized}\"", - "orgAuthSignInTitle": "登錄到您的組織", - "orgAuthChooseIdpDescription": "選擇您的身份提供商以繼續", - "orgAuthNoIdpConfigured": "此機構沒有配置任何身份提供者。您可以使用您的 Pangolin 身份登錄。", - "orgAuthSignInWithPangolin": "使用 Pangolin 登錄", - "subscriptionRequiredToUse": "需要訂閱才能使用此功能。", - "idpDisabled": "身份提供者已禁用。", - "orgAuthPageDisabled": "組織認證頁面已禁用。", - "domainRestartedDescription": "域驗證重新啟動成功", - "resourceAddEntrypointsEditFile": "編輯文件:config/traefik/traefik_config.yml", - "resourceExposePortsEditFile": "編輯文件:docker-compose.yml", - "emailVerificationRequired": "需要電子郵件驗證。 請通過 {dashboardUrl}/auth/login 再次登錄以完成此步驟。 然後,回到這裡。", - "twoFactorSetupRequired": "需要設置雙因素身份驗證。 請通過 {dashboardUrl}/auth/login 再次登錄以完成此步驟。 然後,回到這裡。", - "additionalSecurityRequired": "需要額外的安全", - "organizationRequiresAdditionalSteps": "這個組織需要額外的安全步驟才能訪問資源。", - "completeTheseSteps": "完成這些步驟", - "enableTwoFactorAuthentication": "啟用兩步驗證", - "completeSecuritySteps": "完成安全步驟", - "securitySettings": "安全設定", - "securitySettingsDescription": "配置您組織的安全策略", - "requireTwoFactorForAllUsers": "所有用戶需要兩步驗證", - "requireTwoFactorDescription": "如果啟用,此組織的所有內部用戶必須啟用雙重身份驗證才能訪問組織。", - "requireTwoFactorDisabledDescription": "此功能需要有效的許可證(企業)或活動訂閱(SaS)", - "requireTwoFactorCannotEnableDescription": "您必須為您的帳戶啟用雙重身份驗證才能對所有用戶", - "maxSessionLength": "最大會話長度", - "maxSessionLengthDescription": "設置用戶會話的最長時間。此後用戶需要重新驗證。", - "maxSessionLengthDisabledDescription": "此功能需要有效的許可證(企業)或活動訂閱(SaS)", - "selectSessionLength": "選擇會話長度", - "unenforced": "未執行", - "1Hour": "1 小時", - "3Hours": "3 小時", - "6Hours": "6 小時", - "12Hours": "12 小時", - "1DaySession": "1天", - "3Days": "3 天", - "7Days": "7 天", - "14Days": "14 天", - "30DaysSession": "30 天", - "90DaysSession": "90 天", - "180DaysSession": "180天", - "passwordExpiryDays": "密碼過期", - "editPasswordExpiryDescription": "設置用戶需要更改密碼之前的天數。", - "selectPasswordExpiry": "選擇密碼過期", - "30Days": "30 天", - "1Day": "1天", - "60Days": "60天", - "90Days": "90 天", - "180Days": "180天", - "1Year": "1 年", - "subscriptionBadge": "需要訂閱", - "securityPolicyChangeWarning": "安全政策更改警告", - "securityPolicyChangeDescription": "您即將更改安全政策設置。保存後,您可能需要重新認證以遵守這些政策更新。 所有不符合要求的用戶也需要重新認證。", - "securityPolicyChangeConfirmMessage": "我確認", - "securityPolicyChangeWarningText": "這將影響組織中的所有用戶", - "authPageErrorUpdateMessage": "更新身份驗證頁面設置時出錯", - "authPageErrorUpdate": "無法更新認證頁面", - "authPageUpdated": "身份驗證頁面更新成功", - "healthCheckNotAvailable": "本地的", - "rewritePath": "重寫路徑", - "rewritePathDescription": "在轉發到目標之前,可以選擇重寫路徑。", - "continueToApplication": "繼續應用", - "checkingInvite": "正在檢查邀請", - "setResourceHeaderAuth": "設置 ResourceHeaderAuth", - "resourceHeaderAuthRemove": "移除 Header 身份驗證", - "resourceHeaderAuthRemoveDescription": "已成功刪除 Header 身份驗證。", - "resourceErrorHeaderAuthRemove": "刪除 Header 身份驗證失敗", - "resourceErrorHeaderAuthRemoveDescription": "無法刪除資源的 Header 身份驗證。", - "resourceHeaderAuthProtectionEnabled": "Header 認證已啟用", - "resourceHeaderAuthProtectionDisabled": "Header 身份驗證已禁用", - "headerAuthRemove": "刪除 Header 認證", - "headerAuthAdd": "添加頁首認證", - "resourceErrorHeaderAuthSetup": "設置頁首認證失敗", - "resourceErrorHeaderAuthSetupDescription": "無法設置資源的 Header 身份驗證。", - "resourceHeaderAuthSetup": "Header 認證設置成功", - "resourceHeaderAuthSetupDescription": "Header 認證已成功設置。", - "resourceHeaderAuthSetupTitle": "設置 Header 身份驗證", - "resourceHeaderAuthSetupTitleDescription": "使用 HTTP 頭身份驗證來設置基本身份驗證資訊(使用者名稱和密碼)。使用 https://username:password@resource.example.com 訪問它", - "resourceHeaderAuthSubmit": "設置 Header 身份驗證", - "actionSetResourceHeaderAuth": "設置 Header 身份驗證", - "enterpriseEdition": "企業版", - "unlicensed": "未授權", - "beta": "測試版", - "manageClients": "管理用戶端", - "manageClientsDescription": "用戶端是可以連接到您的站點的設備", - "licenseTableValidUntil": "有效期至", - "saasLicenseKeysSettingsTitle": "企業許可證", - "saasLicenseKeysSettingsDescription": "為自我託管的 Pangolin 實例生成和管理企業許可證金鑰", - "sidebarEnterpriseLicenses": "許可協議", - "generateLicenseKey": "生成許可證金鑰", - "generateLicenseKeyForm": { - "validation": { - "emailRequired": "請輸入一個有效的電子郵件地址", - "useCaseTypeRequired": "請選擇一個使用的案例類型", - "firstNameRequired": "必填名", - "lastNameRequired": "姓氏是必填項", - "primaryUseRequired": "請描述您的主要使用", - "jobTitleRequiredBusiness": "企業使用必須有職位頭銜。", - "industryRequiredBusiness": "商業使用需要工業", - "stateProvinceRegionRequired": "州/省/地區是必填項", - "postalZipCodeRequired": "郵政編碼是必需的", - "companyNameRequiredBusiness": "企業使用需要公司名稱", - "countryOfResidenceRequiredBusiness": "商業使用必須是居住國", - "countryRequiredPersonal": "國家需要個人使用", - "agreeToTermsRequired": "您必須同意條款", - "complianceConfirmationRequired": "您必須確認遵守 Fossorial Commercial License" - }, - "useCaseOptions": { - "personal": { - "title": "個人使用", - "description": "個人非商業用途,如學習、個人項目或實驗。" - }, - "business": { - "title": "商業使用", - "description": "供組織、公司或商業或創收活動使用。" - } - }, - "steps": { - "emailLicenseType": { - "title": "電子郵件和許可證類型", - "description": "輸入您的電子郵件並選擇您的許可證類型" - }, - "personalInformation": { - "title": "個人資訊", - "description": "告訴我們自己的資訊" - }, - "contactInformation": { - "title": "聯繫資訊", - "description": "您的聯繫資訊" - }, - "termsGenerate": { - "title": "條款並生成", - "description": "審閱並接受條款生成您的許可證" - } - }, - "alerts": { - "commercialUseDisclosure": { - "title": "使用情況披露", - "description": "選擇能準確反映您預定用途的許可等級。 個人許可證允許對個人、非商業性或小型商業活動免費使用軟體,年收入毛額不到 100,000 美元。 超出這些限度的任何用途,包括在企業、組織內的用途。 或其他創收環境——需要有效的企業許可證和支付適用的許可證費用。 所有用戶,不論是個人還是企業,都必須遵守寄養商業許可證條款。" - }, - "trialPeriodInformation": { - "title": "試用期資訊", - "description": "此許可證金鑰使企業特性能夠持續 7 天的評價。 在評估期過後繼續訪問付費功能需要在有效的個人或企業許可證下啟用。對於企業許可證,請聯絡 Sales@pangolin.net。" - } - }, - "form": { - "useCaseQuestion": "您是否正在使用 Pangolin 進行個人或商業使用?", - "firstName": "名字", - "lastName": "名字", - "jobTitle": "工作頭銜:", - "primaryUseQuestion": "您主要計劃使用 Pangolin 嗎?", - "industryQuestion": "您的行業是什麼?", - "prospectiveUsersQuestion": "您期望有多少預期用戶?", - "prospectiveSitesQuestion": "您期望有多少站點(隧道)?", - "companyName": "公司名稱", - "countryOfResidence": "居住國", - "stateProvinceRegion": "州/省/地區", - "postalZipCode": "郵政編碼", - "companyWebsite": "公司網站", - "companyPhoneNumber": "公司電話號碼", - "country": "國家", - "phoneNumberOptional": "電話號碼 (可選)", - "complianceConfirmation": "我確認我提供的資料是準確的,我遵守了寄養商業許可證。 報告不準確的資訊或錯誤的產品使用是違反許可證的行為,可能導致您的金鑰被撤銷。" - }, - "buttons": { - "close": "關閉", - "previous": "上一個", - "next": "下一個", - "generateLicenseKey": "生成許可證金鑰" - }, - "toasts": { - "success": { - "title": "許可證金鑰生成成功", - "description": "您的許可證金鑰已經生成並準備使用。" - }, - "error": { - "title": "生成許可證金鑰失敗", - "description": "生成許可證金鑰時出錯。" - } - } + "validation": { + "adoptRequired": "在通過現有節點時需要節點ID和金鑰" }, - "priority": "優先權", - "priorityDescription": "先評估更高優先度線路。優先度 = 100 意味著自動排序(系統決定). 使用另一個數字強制執行手動優先度。", - "instanceName": "實例名稱", - "pathMatchModalTitle": "配置路徑匹配", - "pathMatchModalDescription": "根據傳入請求的路徑設置匹配方式。", - "pathMatchType": "匹配類型", - "pathMatchPrefix": "前綴", - "pathMatchExact": "精準的", - "pathMatchRegex": "正則表達式", - "pathMatchValue": "路徑值", - "clear": "清空", - "saveChanges": "保存更改", - "pathMatchRegexPlaceholder": "^/api/.*", - "pathMatchDefaultPlaceholder": "/路徑", - "pathMatchPrefixHelp": "範例: /api 匹配/api, /api/users 等。", - "pathMatchExactHelp": "範例:/api 匹配僅限/api", - "pathMatchRegexHelp": "例如:^/api/.* 匹配/api/why", - "pathRewriteModalTitle": "配置路徑重寫", - "pathRewriteModalDescription": "在轉發到目標之前變換匹配的路徑。", - "pathRewriteType": "重寫類型", - "pathRewritePrefixOption": "前綴 - 替換前綴", - "pathRewriteExactOption": "精確-替換整個路徑", - "pathRewriteRegexOption": "正則表達式 - 替換模式", - "pathRewriteStripPrefixOption": "刪除前綴 - 刪除前綴", - "pathRewriteValue": "重寫值", - "pathRewriteRegexPlaceholder": "/new/$1", - "pathRewriteDefaultPlaceholder": "/new-path", - "pathRewritePrefixHelp": "用此值替換匹配的前綴", - "pathRewriteExactHelp": "當路徑匹配時用此值替換整個路徑", - "pathRewriteRegexHelp": "使用抓取組,如$1,$2來替換", - "pathRewriteStripPrefixHelp": "留空以脫離前綴或提供新的前綴", - "pathRewritePrefix": "前綴", - "pathRewriteExact": "精準的", - "pathRewriteRegex": "正則表達式", - "pathRewriteStrip": "帶狀圖", - "pathRewriteStripLabel": "條形圖", - "sidebarEnableEnterpriseLicense": "啟用企業許可證", - "cannotbeUndone": "無法撤消。", - "toConfirm": "確認", - "deleteClientQuestion": "您確定要從站點和組織中刪除客戶嗎?", - "clientMessageRemove": "一旦刪除,用戶端將無法連接到站點。", - "sidebarLogs": "日誌", - "request": "請求", - "logs": "日誌", - "logsSettingsDescription": "監視從此 orginization 中收集的日誌", - "searchLogs": "搜索日誌...", - "action": "行動", - "actor": "執行者", - "timestamp": "時間戳", - "accessLogs": "訪問日誌", - "exportCsv": "導出 CSV", - "actorId": "執行者 ID", - "allowedByRule": "根據規則允許", - "allowedNoAuth": "無認證", - "validAccessToken": "有效訪問令牌", - "validHeaderAuth": "有效的 Header 身份驗證", - "validPincode": "有效的 Pincode", - "validPassword": "有效密碼", - "validEmail": "有效的 email", - "validSSO": "有效的 SSO", - "resourceBlocked": "資源被阻止", - "droppedByRule": "被規則刪除", - "noSessions": "無會話", - "temporaryRequestToken": "臨時請求令牌", - "noMoreAuthMethods": "無有效授權", - "ip": "IP", - "reason": "原因", - "requestLogs": "請求日誌", - "host": "主機", - "location": "地點", - "actionLogs": "操作日誌", - "sidebarLogsRequest": "請求日誌", - "sidebarLogsAccess": "訪問日誌", - "sidebarLogsAction": "操作日誌", - "logRetention": "日誌保留", - "logRetentionDescription": "管理不同類型的日誌為這個機構保留多長時間或禁用這些日誌", - "requestLogsDescription": "查看此機構資源的詳細請求日誌", - "logRetentionRequestLabel": "請求日誌保留", - "logRetentionRequestDescription": "保留請求日誌的時間", - "logRetentionAccessLabel": "訪問日誌保留", - "logRetentionAccessDescription": "保留訪問日誌的時間", - "logRetentionActionLabel": "動作日誌保留", - "logRetentionActionDescription": "保留操作日誌的時間", - "logRetentionDisabled": "已禁用", - "logRetention3Days": "3 天", - "logRetention7Days": "7 天", - "logRetention14Days": "14 天", - "logRetention30Days": "30 天", - "logRetention90Days": "90 天", - "logRetentionForever": "永遠的", - "actionLogsDescription": "查看此機構執行的操作歷史", - "accessLogsDescription": "查看此機構資源的訪問認證請求", - "licenseRequiredToUse": "需要企業許可證才能使用此功能。", - "certResolver": "證書解決器", - "certResolverDescription": "選擇用於此資源的證書解析器。", - "selectCertResolver": "選擇證書解析", - "enterCustomResolver": "輸入自訂解析器", - "preferWildcardCert": "喜歡通配符證書", - "unverified": "未驗證", - "domainSetting": "域設置", - "domainSettingDescription": "配置您的域的設置", - "preferWildcardCertDescription": "嘗試生成通配符證書(需要正確配置的證書解析器)。", - "recordName": "記錄名稱", - "auto": "自動操作", - "TTL": "TTL", - "howToAddRecords": "如何添加記錄", - "dnsRecord": "DNS 記錄", - "required": "必填", - "domainSettingsUpdated": "域設置更新成功", - "orgOrDomainIdMissing": "缺少機構或域 ID", - "loadingDNSRecords": "正在載入 DNS 記錄...", - "olmUpdateAvailableInfo": "有最新版本的 Olm 可用。請更新到最新版本以獲取最佳體驗。", - "client": "用戶端:", - "proxyProtocol": "代理協議設置", - "proxyProtocolDescription": "配置代理協議以保留 TCP/UDP 服務的用戶端 IP 位址。", - "enableProxyProtocol": "啟用代理協議", - "proxyProtocolInfo": "為 TCP/UDP 後端保留用戶端 IP 位址", - "proxyProtocolVersion": "代理協議版本", - "version1": " 版本 1 (推薦)", - "version2": "版本 2", - "versionDescription": "版本 1 是基於文本和廣泛支持的版本。版本 2 是二進制和更有效率但不那麼相容。", - "warning": "警告", - "proxyProtocolWarning": "您的後端應用程式必須配置為接受代理協議連接。如果您的後端不支持代理協議,啟用這將會中斷所有連接。 請務必從 Traefik 配置您的後端到信任代理協議標題。", - "restarting": "正在重啟...", - "manual": "手動模式", - "messageSupport": "消息支持", - "supportNotAvailableTitle": "支持不可用", - "supportNotAvailableDescription": "支持現在不可用。您可以發送電子郵件到 support@pangolin.net。", - "supportRequestSentTitle": "支持請求已發送", - "supportRequestSentDescription": "您的消息已成功發送。", - "supportRequestFailedTitle": "發送請求失敗", - "supportRequestFailedDescription": "發送您的支持請求時出錯。", - "supportSubjectRequired": "主題是必填項", - "supportSubjectMaxLength": "主題必須是 255 個或更少的字元", - "supportMessageRequired": "消息是必填項", - "supportReplyTo": "回復給", - "supportSubject": "議題", - "supportSubjectPlaceholder": "輸入主題", - "supportMessage": "留言", - "supportMessagePlaceholder": "輸入您的消息", - "supportSending": "正在發送...", - "supportSend": "發送", - "supportMessageSent": "消息已發送!", - "supportWillContact": "我們很快就會聯繫起來!", - "selectLogRetention": "選擇保留日誌", - "showColumns": "顯示列", - "hideColumns": "隱藏列", - "columnVisibility": "列可見性", - "toggleColumn": "切換 {columnName} 列", - "allColumns": "全部列", - "defaultColumns": "默認列", - "customizeView": "自訂視圖", - "viewOptions": "查看選項", - "selectAll": "選擇所有", - "selectNone": "沒有選擇", - "selectedResources": "選定的資源", - "enableSelected": "啟用選中的", - "disableSelected": "禁用選中的", - "checkSelectedStatus": "檢查選中的狀態" + "errors": { + "loadDefaultsFailed": "無法載入預設值", + "defaultsNotLoaded": "預設值未載入", + "createFailed": "創建節點失敗" + }, + "success": { + "created": "節點創建成功" + } + }, + "remoteExitNodeSelection": "節點選擇", + "remoteExitNodeSelectionDescription": "為此本地站點選擇要路由流量的節點", + "remoteExitNodeRequired": "必須為本地站點選擇節點", + "noRemoteExitNodesAvailable": "無可用節點", + "noRemoteExitNodesAvailableDescription": "此組織沒有可用的節點。首先創建一個節點來使用本地站點。", + "exitNode": "出口節點", + "country": "國家", + "rulesMatchCountry": "當前基於源 IP", + "managedSelfHosted": { + "title": "託管自託管", + "description": "更可靠、維護成本更低的自架 Pangolin 伺服器,並附帶額外的附加功能", + "introTitle": "託管式自架 Pangolin", + "introDescription": "這是一種部署選擇,為那些希望簡潔和額外可靠的人設計,同時仍然保持他們的數據的私密性和自我託管性。", + "introDetail": "通過此選項,您仍然運行您自己的 Pangolin 節點 — — 您的隧道、SSL 終止,並且流量在您的伺服器上保持所有狀態。 不同之處在於,管理和監測是通過我們的雲層儀錶板進行的,該儀錶板開啟了一些好處:", + "benefitSimplerOperations": { + "title": "簡單的操作", + "description": "無需運行您自己的郵件伺服器或設置複雜的警報。您將從方框中獲得健康檢查和下限提醒。" + }, + "benefitAutomaticUpdates": { + "title": "自動更新", + "description": "雲儀錶板快速演化,所以您可以獲得新的功能和錯誤修復,而不必每次手動拉取新的容器。" + }, + "benefitLessMaintenance": { + "title": "減少維護時間", + "description": "沒有要管理的資料庫遷移、備份或額外的基礎設施。我們在雲端處理這個問題。" + }, + "benefitCloudFailover": { + "title": "雲端故障轉移", + "description": "如果您的節點發生故障,您的隧道可以暫時故障轉移到我們的雲端存取點,直到您將節點恢復線上狀態。" + }, + "benefitHighAvailability": { + "title": "高可用率(PoPs)", + "description": "您還可以將多個節點添加到您的帳戶中以獲取冗餘和更好的性能。" + }, + "benefitFutureEnhancements": { + "title": "將來的改進", + "description": "我們正在計劃添加更多的分析、警報和管理工具,使你的部署更加有力。" + }, + "docsAlert": { + "text": "在我們中更多地了解管理下的自託管選項", + "documentation": "文件" + }, + "convertButton": "將此節點轉換為管理自託管的" + }, + "internationaldomaindetected": "檢測到國際域", + "willbestoredas": "儲存為:", + "roleMappingDescription": "確定當用戶啟用自動配送時如何分配他們的角色。", + "selectRole": "選擇角色", + "roleMappingExpression": "表達式", + "selectRolePlaceholder": "選擇角色", + "selectRoleDescription": "選擇一個角色,從此身份提供商分配給所有用戶", + "roleMappingExpressionDescription": "輸入一個 JMESPath 表達式來從 ID 令牌提取角色資訊", + "idpTenantIdRequired": "租戶 ID 是必需的", + "invalidValue": "無效的值", + "idpTypeLabel": "身份提供者類型", + "roleMappingExpressionPlaceholder": "例如: contains(group, 'admin' &'Admin' || 'Member'", + "idpGoogleConfiguration": "Google 配置", + "idpGoogleConfigurationDescription": "配置您的 Google OAuth2 憑據", + "idpGoogleClientIdDescription": "您的 Google OAuth2 用戶端 ID", + "idpGoogleClientSecretDescription": "您的 Google OAuth2 用戶端金鑰", + "idpAzureConfiguration": "Azure Entra ID 配置", + "idpAzureConfigurationDescription": "配置您的 Azure Entra ID OAuth2 憑據", + "idpTenantId": "租戶 ID", + "idpTenantIdPlaceholder": "您的租戶 ID", + "idpAzureTenantIdDescription": "您的 Azure 租戶ID (在 Azure Active Directory 概覽中發現)", + "idpAzureClientIdDescription": "您的 Azure 應用程式註冊用戶端 ID", + "idpAzureClientSecretDescription": "您的 Azure 應用程式註冊用戶端金鑰", + "idpGoogleTitle": "Google", + "idpGoogleAlt": "Google", + "idpAzureTitle": "Azure Entra ID", + "idpAzureAlt": "Azure", + "idpGoogleConfigurationTitle": "Google 配置", + "idpAzureConfigurationTitle": "Azure Entra ID 配置", + "idpTenantIdLabel": "租戶 ID", + "idpAzureClientIdDescription2": "您的 Azure 應用程式註冊用戶端 ID", + "idpAzureClientSecretDescription2": "您的 Azure 應用程式註冊用戶端金鑰", + "idpGoogleDescription": "Google OAuth2/OIDC 提供商", + "idpAzureDescription": "Microsoft Azure OAuth2/OIDC 提供者", + "subnet": "子網", + "subnetDescription": "此組織網路配置的子網。", + "customDomain": "自訂網域", + "authPage": "認證頁面", + "authPageDescription": "配置您的組織認證頁面", + "authPageDomain": "認證頁面域", + "authPageBranding": "自訂品牌", + "authPageBrandingDescription": "設定此組織驗證頁面上顯示的品牌", + "authPageBrandingUpdated": "驗證頁面品牌更新成功", + "authPageBrandingRemoved": "驗證頁面品牌移除成功", + "authPageBrandingRemoveTitle": "移除驗證頁面品牌", + "authPageBrandingQuestionRemove": "您確定要移除驗證頁面的品牌嗎?", + "authPageBrandingDeleteConfirm": "確認刪除品牌", + "brandingLogoURL": "Logo 網址", + "brandingPrimaryColor": "主要顏色", + "brandingLogoWidth": "寬度 (px)", + "brandingLogoHeight": "高度 (px)", + "brandingOrgTitle": "組織驗證頁面標題", + "brandingOrgDescription": "{orgName} 將被替換為組織名稱", + "brandingOrgSubtitle": "組織驗證頁面副標題", + "brandingResourceTitle": "資源驗證頁面標題", + "brandingResourceSubtitle": "資源驗證頁面副標題", + "brandingResourceDescription": "{resourceName} 將被替換為組織名稱", + "saveAuthPageDomain": "儲存網域", + "saveAuthPageBranding": "儲存品牌", + "removeAuthPageBranding": "移除品牌", + "noDomainSet": "沒有域設置", + "changeDomain": "更改域", + "selectDomain": "選擇域", + "restartCertificate": "重新啟動證書", + "editAuthPageDomain": "編輯認證頁面域", + "setAuthPageDomain": "設置認證頁面域", + "failedToFetchCertificate": "獲取證書失敗", + "failedToRestartCertificate": "重新啟動證書失敗", + "addDomainToEnableCustomAuthPages": "為您的組織添加域名以啟用自訂認證頁面", + "selectDomainForOrgAuthPage": "選擇組織認證頁面的域", + "domainPickerProvidedDomain": "提供的域", + "domainPickerFreeProvidedDomain": "免費提供的域", + "domainPickerVerified": "已驗證", + "domainPickerUnverified": "未驗證", + "domainPickerInvalidSubdomainStructure": "此子域包含無效的字元或結構。當您保存時,它將被自動清除。", + "domainPickerError": "錯誤", + "domainPickerErrorLoadDomains": "載入組織域名失敗", + "domainPickerErrorCheckAvailability": "檢查域可用性失敗", + "domainPickerInvalidSubdomain": "無效的子域", + "domainPickerInvalidSubdomainRemoved": "輸入 \"{sub}\" 已被移除,因為其無效。", + "domainPickerInvalidSubdomainCannotMakeValid": "\"{sub}\" 無法為 {domain} 變為有效。", + "domainPickerSubdomainSanitized": "子域已淨化", + "domainPickerSubdomainCorrected": "\"{sub}\" 已被更正為 \"{sanitized}\"", + "orgAuthSignInTitle": "登錄到您的組織", + "orgAuthChooseIdpDescription": "選擇您的身份提供商以繼續", + "orgAuthNoIdpConfigured": "此機構沒有配置任何身份提供者。您可以使用您的 Pangolin 身份登錄。", + "orgAuthSignInWithPangolin": "使用 Pangolin 登錄", + "orgAuthSignInToOrg": "登入組織", + "orgAuthSelectOrgTitle": "組織登入", + "orgAuthSelectOrgDescription": "輸入您的組織 ID 以繼續", + "orgAuthOrgIdPlaceholder": "your-organization", + "orgAuthOrgIdHelp": "輸入您組織的唯一識別碼", + "orgAuthSelectOrgHelp": "輸入組織 ID 後,您將被導向到組織的登入頁面,在那裡您可以使用 SSO 或組織憑證。", + "orgAuthRememberOrgId": "記住此組織 ID", + "orgAuthBackToSignIn": "返回標準登入", + "orgAuthNoAccount": "沒有帳戶?", + "subscriptionRequiredToUse": "需要訂閱才能使用此功能。", + "idpDisabled": "身份提供者已禁用。", + "orgAuthPageDisabled": "組織認證頁面已禁用。", + "domainRestartedDescription": "域驗證重新啟動成功", + "resourceAddEntrypointsEditFile": "編輯文件:config/traefik/traefik_config.yml", + "resourceExposePortsEditFile": "編輯文件:docker-compose.yml", + "emailVerificationRequired": "需要電子郵件驗證。 請通過 {dashboardUrl}/auth/login 再次登錄以完成此步驟。 然後,回到這裡。", + "twoFactorSetupRequired": "需要設置雙因素身份驗證。 請通過 {dashboardUrl}/auth/login 再次登錄以完成此步驟。 然後,回到這裡。", + "additionalSecurityRequired": "需要額外的安全", + "organizationRequiresAdditionalSteps": "這個組織需要額外的安全步驟才能訪問資源。", + "completeTheseSteps": "完成這些步驟", + "enableTwoFactorAuthentication": "啟用兩步驗證", + "completeSecuritySteps": "完成安全步驟", + "securitySettings": "安全設定", + "dangerSection": "危險區域", + "dangerSectionDescription": "永久刪除與此組織相關的所有資料", + "securitySettingsDescription": "配置您組織的安全策略", + "requireTwoFactorForAllUsers": "所有用戶需要兩步驗證", + "requireTwoFactorDescription": "如果啟用,此組織的所有內部用戶必須啟用雙重身份驗證才能訪問組織。", + "requireTwoFactorDisabledDescription": "此功能需要有效的許可證(企業)或活動訂閱(SaS)", + "requireTwoFactorCannotEnableDescription": "您必須為您的帳戶啟用雙重身份驗證才能對所有用戶", + "maxSessionLength": "最大會話長度", + "maxSessionLengthDescription": "設置用戶會話的最長時間。此後用戶需要重新驗證。", + "maxSessionLengthDisabledDescription": "此功能需要有效的許可證(企業)或活動訂閱(SaS)", + "selectSessionLength": "選擇會話長度", + "unenforced": "未執行", + "1Hour": "1 小時", + "3Hours": "3 小時", + "6Hours": "6 小時", + "12Hours": "12 小時", + "1DaySession": "1天", + "3Days": "3 天", + "7Days": "7 天", + "14Days": "14 天", + "30DaysSession": "30 天", + "90DaysSession": "90 天", + "180DaysSession": "180天", + "passwordExpiryDays": "密碼過期", + "editPasswordExpiryDescription": "設置用戶需要更改密碼之前的天數。", + "selectPasswordExpiry": "選擇密碼過期", + "30Days": "30 天", + "1Day": "1天", + "60Days": "60天", + "90Days": "90 天", + "180Days": "180天", + "1Year": "1 年", + "subscriptionBadge": "需要訂閱", + "securityPolicyChangeWarning": "安全政策更改警告", + "securityPolicyChangeDescription": "您即將更改安全政策設置。保存後,您可能需要重新認證以遵守這些政策更新。 所有不符合要求的用戶也需要重新認證。", + "securityPolicyChangeConfirmMessage": "我確認", + "securityPolicyChangeWarningText": "這將影響組織中的所有用戶", + "authPageErrorUpdateMessage": "更新身份驗證頁面設置時出錯", + "authPageErrorUpdate": "無法更新認證頁面", + "authPageDomainUpdated": "驗證頁面網域更新成功", + "healthCheckNotAvailable": "本地的", + "rewritePath": "重寫路徑", + "rewritePathDescription": "在轉發到目標之前,可以選擇重寫路徑。", + "continueToApplication": "繼續應用", + "checkingInvite": "正在檢查邀請", + "setResourceHeaderAuth": "設置 ResourceHeaderAuth", + "resourceHeaderAuthRemove": "移除 Header 身份驗證", + "resourceHeaderAuthRemoveDescription": "已成功刪除 Header 身份驗證。", + "resourceErrorHeaderAuthRemove": "刪除 Header 身份驗證失敗", + "resourceErrorHeaderAuthRemoveDescription": "無法刪除資源的 Header 身份驗證。", + "resourceHeaderAuthProtectionEnabled": "Header 認證已啟用", + "resourceHeaderAuthProtectionDisabled": "Header 身份驗證已禁用", + "headerAuthRemove": "刪除 Header 認證", + "headerAuthAdd": "添加頁首認證", + "resourceErrorHeaderAuthSetup": "設置頁首認證失敗", + "resourceErrorHeaderAuthSetupDescription": "無法設置資源的 Header 身份驗證。", + "resourceHeaderAuthSetup": "Header 認證設置成功", + "resourceHeaderAuthSetupDescription": "Header 認證已成功設置。", + "resourceHeaderAuthSetupTitle": "設置 Header 身份驗證", + "resourceHeaderAuthSetupTitleDescription": "使用 HTTP 頭身份驗證來設置基本身份驗證資訊(使用者名稱和密碼)。使用 https://username:password@resource.example.com 訪問它", + "resourceHeaderAuthSubmit": "設置 Header 身份驗證", + "actionSetResourceHeaderAuth": "設置 Header 身份驗證", + "enterpriseEdition": "企業版", + "unlicensed": "未授權", + "beta": "測試版", + "manageUserDevices": "使用者裝置", + "manageUserDevicesDescription": "查看和管理使用者用於私密連接資源的裝置", + "downloadClientBannerTitle": "下載 Pangolin 客戶端", + "downloadClientBannerDescription": "下載適用於您系統的 Pangolin 客戶端,以連接到 Pangolin 網路並私密存取資源。", + "manageMachineClients": "管理機器客戶端", + "manageMachineClientsDescription": "建立和管理伺服器和系統用於私密連接資源的客戶端", + "machineClientsBannerTitle": "伺服器與自動化系統", + "machineClientsBannerDescription": "機器客戶端適用於與特定使用者無關的伺服器和自動化系統。它們使用 ID 和密鑰進行驗證,可以透過 Pangolin CLI、Olm CLI 或 Olm 容器執行。", + "machineClientsBannerPangolinCLI": "Pangolin CLI", + "machineClientsBannerOlmCLI": "Olm CLI", + "machineClientsBannerOlmContainer": "Olm 容器", + "clientsTableUserClients": "使用者", + "clientsTableMachineClients": "機器", + "licenseTableValidUntil": "有效期至", + "saasLicenseKeysSettingsTitle": "企業許可證", + "saasLicenseKeysSettingsDescription": "為自我託管的 Pangolin 實例生成和管理企業許可證金鑰", + "sidebarEnterpriseLicenses": "許可協議", + "generateLicenseKey": "生成許可證金鑰", + "generateLicenseKeyForm": { + "validation": { + "emailRequired": "請輸入一個有效的電子郵件地址", + "useCaseTypeRequired": "請選擇一個使用的案例類型", + "firstNameRequired": "必填名", + "lastNameRequired": "姓氏是必填項", + "primaryUseRequired": "請描述您的主要使用", + "jobTitleRequiredBusiness": "企業使用必須有職位頭銜。", + "industryRequiredBusiness": "商業使用需要工業", + "stateProvinceRegionRequired": "州/省/地區是必填項", + "postalZipCodeRequired": "郵政編碼是必需的", + "companyNameRequiredBusiness": "企業使用需要公司名稱", + "countryOfResidenceRequiredBusiness": "商業使用必須是居住國", + "countryRequiredPersonal": "國家需要個人使用", + "agreeToTermsRequired": "您必須同意條款", + "complianceConfirmationRequired": "您必須確認遵守 Fossorial Commercial License" + }, + "useCaseOptions": { + "personal": { + "title": "個人使用", + "description": "個人非商業用途,如學習、個人項目或實驗。" + }, + "business": { + "title": "商業使用", + "description": "供組織、公司或商業或創收活動使用。" + } + }, + "steps": { + "emailLicenseType": { + "title": "電子郵件和許可證類型", + "description": "輸入您的電子郵件並選擇您的許可證類型" + }, + "personalInformation": { + "title": "個人資訊", + "description": "告訴我們自己的資訊" + }, + "contactInformation": { + "title": "聯繫資訊", + "description": "您的聯繫資訊" + }, + "termsGenerate": { + "title": "條款並生成", + "description": "審閱並接受條款生成您的許可證" + } + }, + "alerts": { + "commercialUseDisclosure": { + "title": "使用情況披露", + "description": "選擇能準確反映您預定用途的許可等級。 個人許可證允許對個人、非商業性或小型商業活動免費使用軟體,年收入毛額不到 100,000 美元。 超出這些限度的任何用途,包括在企業、組織內的用途。 或其他創收環境——需要有效的企業許可證和支付適用的許可證費用。 所有用戶,不論是個人還是企業,都必須遵守寄養商業許可證條款。" + }, + "trialPeriodInformation": { + "title": "試用期資訊", + "description": "此許可證金鑰使企業特性能夠持續 7 天的評價。 在評估期過後繼續訪問付費功能需要在有效的個人或企業許可證下啟用。對於企業許可證,請聯絡 Sales@pangolin.net。" + } + }, + "form": { + "useCaseQuestion": "您是否正在使用 Pangolin 進行個人或商業使用?", + "firstName": "名字", + "lastName": "名字", + "jobTitle": "工作頭銜:", + "primaryUseQuestion": "您主要計劃使用 Pangolin 嗎?", + "industryQuestion": "您的行業是什麼?", + "prospectiveUsersQuestion": "您期望有多少預期用戶?", + "prospectiveSitesQuestion": "您期望有多少站點(隧道)?", + "companyName": "公司名稱", + "countryOfResidence": "居住國", + "stateProvinceRegion": "州/省/地區", + "postalZipCode": "郵政編碼", + "companyWebsite": "公司網站", + "companyPhoneNumber": "公司電話號碼", + "country": "國家", + "phoneNumberOptional": "電話號碼 (可選)", + "complianceConfirmation": "我確認我提供的資料是準確的,我遵守了寄養商業許可證。 報告不準確的資訊或錯誤的產品使用是違反許可證的行為,可能導致您的金鑰被撤銷。" + }, + "buttons": { + "close": "關閉", + "previous": "上一個", + "next": "下一個", + "generateLicenseKey": "生成許可證金鑰" + }, + "toasts": { + "success": { + "title": "許可證金鑰生成成功", + "description": "您的許可證金鑰已經生成並準備使用。" + }, + "error": { + "title": "生成許可證金鑰失敗", + "description": "生成許可證金鑰時出錯。" + } + } + }, + "priority": "優先權", + "priorityDescription": "先評估更高優先度線路。優先度 = 100 意味著自動排序(系統決定). 使用另一個數字強制執行手動優先度。", + "instanceName": "實例名稱", + "pathMatchModalTitle": "配置路徑匹配", + "pathMatchModalDescription": "根據傳入請求的路徑設置匹配方式。", + "pathMatchType": "匹配類型", + "pathMatchPrefix": "前綴", + "pathMatchExact": "精準的", + "pathMatchRegex": "正則表達式", + "pathMatchValue": "路徑值", + "clear": "清空", + "saveChanges": "保存更改", + "pathMatchRegexPlaceholder": "^/api/.*", + "pathMatchDefaultPlaceholder": "/路徑", + "pathMatchPrefixHelp": "範例: /api 匹配/api, /api/users 等。", + "pathMatchExactHelp": "範例:/api 匹配僅限/api", + "pathMatchRegexHelp": "例如:^/api/.* 匹配/api/why", + "pathRewriteModalTitle": "配置路徑重寫", + "pathRewriteModalDescription": "在轉發到目標之前變換匹配的路徑。", + "pathRewriteType": "重寫類型", + "pathRewritePrefixOption": "前綴 - 替換前綴", + "pathRewriteExactOption": "精確-替換整個路徑", + "pathRewriteRegexOption": "正則表達式 - 替換模式", + "pathRewriteStripPrefixOption": "刪除前綴 - 刪除前綴", + "pathRewriteValue": "重寫值", + "pathRewriteRegexPlaceholder": "/new/$1", + "pathRewriteDefaultPlaceholder": "/new-path", + "pathRewritePrefixHelp": "用此值替換匹配的前綴", + "pathRewriteExactHelp": "當路徑匹配時用此值替換整個路徑", + "pathRewriteRegexHelp": "使用抓取組,如$1,$2來替換", + "pathRewriteStripPrefixHelp": "留空以脫離前綴或提供新的前綴", + "pathRewritePrefix": "前綴", + "pathRewriteExact": "精準的", + "pathRewriteRegex": "正則表達式", + "pathRewriteStrip": "帶狀圖", + "pathRewriteStripLabel": "條形圖", + "sidebarEnableEnterpriseLicense": "啟用企業許可證", + "cannotbeUndone": "無法撤消。", + "toConfirm": "確認", + "deleteClientQuestion": "您確定要從站點和組織中刪除客戶嗎?", + "clientMessageRemove": "一旦刪除,用戶端將無法連接到站點。", + "sidebarLogs": "日誌", + "request": "請求", + "requests": "請求", + "logs": "日誌", + "logsSettingsDescription": "監視從此 orginization 中收集的日誌", + "searchLogs": "搜索日誌...", + "action": "行動", + "actor": "執行者", + "timestamp": "時間戳", + "accessLogs": "訪問日誌", + "exportCsv": "導出 CSV", + "exportError": "匯出 CSV 時發生未知錯誤", + "exportCsvTooltip": "在時間範圍內", + "actorId": "執行者 ID", + "allowedByRule": "根據規則允許", + "allowedNoAuth": "無認證", + "validAccessToken": "有效訪問令牌", + "validHeaderAuth": "有效的 Header 身份驗證", + "validPincode": "有效的 Pincode", + "validPassword": "有效密碼", + "validEmail": "有效的 email", + "validSSO": "有效的 SSO", + "resourceBlocked": "資源被阻止", + "droppedByRule": "被規則刪除", + "noSessions": "無會話", + "temporaryRequestToken": "臨時請求令牌", + "noMoreAuthMethods": "無有效授權", + "ip": "IP", + "reason": "原因", + "requestLogs": "請求日誌", + "requestAnalytics": "請求分析", + "host": "主機", + "location": "地點", + "actionLogs": "操作日誌", + "sidebarLogsRequest": "請求日誌", + "sidebarLogsAccess": "訪問日誌", + "sidebarLogsAction": "操作日誌", + "logRetention": "日誌保留", + "logRetentionDescription": "管理不同類型的日誌為這個機構保留多長時間或禁用這些日誌", + "requestLogsDescription": "查看此機構資源的詳細請求日誌", + "requestAnalyticsDescription": "查看此組織資源的詳細請求分析", + "logRetentionRequestLabel": "請求日誌保留", + "logRetentionRequestDescription": "保留請求日誌的時間", + "logRetentionAccessLabel": "訪問日誌保留", + "logRetentionAccessDescription": "保留訪問日誌的時間", + "logRetentionActionLabel": "動作日誌保留", + "logRetentionActionDescription": "保留操作日誌的時間", + "logRetentionDisabled": "已禁用", + "logRetention3Days": "3 天", + "logRetention7Days": "7 天", + "logRetention14Days": "14 天", + "logRetention30Days": "30 天", + "logRetention90Days": "90 天", + "logRetentionForever": "永遠的", + "logRetentionEndOfFollowingYear": "次年年底", + "actionLogsDescription": "查看此機構執行的操作歷史", + "accessLogsDescription": "查看此機構資源的訪問認證請求", + "licenseRequiredToUse": "需要企業許可證才能使用此功能。", + "certResolver": "證書解決器", + "certResolverDescription": "選擇用於此資源的證書解析器。", + "selectCertResolver": "選擇證書解析", + "enterCustomResolver": "輸入自訂解析器", + "preferWildcardCert": "喜歡通配符證書", + "unverified": "未驗證", + "domainSetting": "域設置", + "domainSettingDescription": "配置您的域的設置", + "preferWildcardCertDescription": "嘗試生成通配符證書(需要正確配置的證書解析器)。", + "recordName": "記錄名稱", + "auto": "自動操作", + "TTL": "TTL", + "howToAddRecords": "如何添加記錄", + "dnsRecord": "DNS 記錄", + "required": "必填", + "domainSettingsUpdated": "域設置更新成功", + "orgOrDomainIdMissing": "缺少機構或域 ID", + "loadingDNSRecords": "正在載入 DNS 記錄...", + "olmUpdateAvailableInfo": "有最新版本的 Olm 可用。請更新到最新版本以獲取最佳體驗。", + "client": "用戶端:", + "proxyProtocol": "代理協議設置", + "proxyProtocolDescription": "配置代理協議以保留 TCP/UDP 服務的用戶端 IP 位址。", + "enableProxyProtocol": "啟用代理協議", + "proxyProtocolInfo": "為 TCP/UDP 後端保留用戶端 IP 位址", + "proxyProtocolVersion": "代理協議版本", + "version1": " 版本 1 (推薦)", + "version2": "版本 2", + "versionDescription": "版本 1 是基於文本和廣泛支持的版本。版本 2 是二進制和更有效率但不那麼相容。", + "warning": "警告", + "proxyProtocolWarning": "您的後端應用程式必須配置為接受代理協議連接。如果您的後端不支持代理協議,啟用這將會中斷所有連接。 請務必從 Traefik 配置您的後端到信任代理協議標題。", + "restarting": "正在重啟...", + "manual": "手動模式", + "messageSupport": "消息支持", + "supportNotAvailableTitle": "支持不可用", + "supportNotAvailableDescription": "支持現在不可用。您可以發送電子郵件到 support@pangolin.net。", + "supportRequestSentTitle": "支持請求已發送", + "supportRequestSentDescription": "您的消息已成功發送。", + "supportRequestFailedTitle": "發送請求失敗", + "supportRequestFailedDescription": "發送您的支持請求時出錯。", + "supportSubjectRequired": "主題是必填項", + "supportSubjectMaxLength": "主題必須是 255 個或更少的字元", + "supportMessageRequired": "消息是必填項", + "supportReplyTo": "回復給", + "supportSubject": "議題", + "supportSubjectPlaceholder": "輸入主題", + "supportMessage": "留言", + "supportMessagePlaceholder": "輸入您的消息", + "supportSending": "正在發送...", + "supportSend": "發送", + "supportMessageSent": "消息已發送!", + "supportWillContact": "我們很快就會聯繫起來!", + "selectLogRetention": "選擇保留日誌", + "terms": "條款", + "privacy": "隱私權", + "security": "安全性", + "docs": "文件", + "deviceActivation": "裝置啟用", + "deviceCodeInvalidFormat": "代碼必須為 9 個字元(例如:A1AJ-N5JD)", + "deviceCodeInvalidOrExpired": "代碼無效或已過期", + "deviceCodeVerifyFailed": "驗證裝置代碼失敗", + "signedInAs": "已登入為", + "deviceCodeEnterPrompt": "輸入裝置上顯示的代碼", + "continue": "繼續", + "deviceUnknownLocation": "未知位置", + "deviceAuthorizationRequested": "此授權請求來自 {location},時間為 {date}。請確保您信任此裝置,因為它將獲得帳戶存取權限。", + "deviceLabel": "裝置:{deviceName}", + "deviceWantsAccess": "想要存取您的帳戶", + "deviceExistingAccess": "現有存取權限:", + "deviceFullAccess": "完整帳戶存取權限", + "deviceOrganizationsAccess": "存取您帳戶有權限的所有組織", + "deviceAuthorize": "授權 {applicationName}", + "deviceConnected": "裝置已連接!", + "deviceAuthorizedMessage": "裝置已獲授權存取您的帳戶。請返回客戶端應用程式。", + "pangolinCloud": "Pangolin 雲端", + "viewDevices": "查看裝置", + "viewDevicesDescription": "管理您已連接的裝置", + "noDevices": "找不到裝置", + "dateCreated": "建立日期", + "unnamedDevice": "未命名裝置", + "deviceQuestionRemove": "您確定要刪除此裝置嗎?", + "deviceMessageRemove": "此操作無法復原。", + "deviceDeleteConfirm": "刪除裝置", + "deleteDevice": "刪除裝置", + "errorLoadingDevices": "載入裝置時發生錯誤", + "failedToLoadDevices": "載入裝置失敗", + "deviceDeleted": "裝置已刪除", + "deviceDeletedDescription": "裝置已成功刪除。", + "errorDeletingDevice": "刪除裝置時發生錯誤", + "failedToDeleteDevice": "刪除裝置失敗", + "showColumns": "顯示列", + "hideColumns": "隱藏列", + "columnVisibility": "列可見性", + "toggleColumn": "切換 {columnName} 列", + "allColumns": "全部列", + "defaultColumns": "默認列", + "customizeView": "自訂視圖", + "viewOptions": "查看選項", + "selectAll": "選擇所有", + "selectNone": "沒有選擇", + "selectedResources": "選定的資源", + "enableSelected": "啟用選中的", + "disableSelected": "禁用選中的", + "checkSelectedStatus": "檢查選中的狀態", + "clients": "客戶端", + "accessClientSelect": "選擇機器客戶端", + "resourceClientDescription": "可以存取此資源的機器客戶端", + "regenerate": "重新產生", + "credentials": "憑證", + "savecredentials": "儲存憑證", + "regenerateCredentialsButton": "重新產生憑證", + "regenerateCredentials": "重新產生憑證", + "generatedcredentials": "已產生的憑證", + "copyandsavethesecredentials": "複製並儲存這些憑證", + "copyandsavethesecredentialsdescription": "離開此頁面後將不會再顯示這些憑證。請立即安全儲存。", + "credentialsSaved": "憑證已儲存", + "credentialsSavedDescription": "憑證已成功重新產生並儲存。", + "credentialsSaveError": "憑證儲存錯誤", + "credentialsSaveErrorDescription": "重新產生和儲存憑證時發生錯誤。", + "regenerateCredentialsWarning": "重新產生憑證將使先前的憑證失效並導致斷線。請確保更新任何使用這些憑證的設定。", + "confirm": "確認", + "regenerateCredentialsConfirmation": "您確定要重新產生憑證嗎?", + "endpoint": "端點", + "Id": "ID", + "SecretKey": "密鑰", + "niceId": "友善 ID", + "niceIdUpdated": "友善 ID 已更新", + "niceIdUpdatedSuccessfully": "友善 ID 更新成功", + "niceIdUpdateError": "更新友善 ID 時發生錯誤", + "niceIdUpdateErrorDescription": "更新友善 ID 時發生錯誤。", + "niceIdCannotBeEmpty": "友善 ID 不能為空", + "enterIdentifier": "輸入識別碼", + "identifier": "識別碼", + "deviceLoginUseDifferentAccount": "不是您嗎?使用其他帳戶。", + "deviceLoginDeviceRequestingAccessToAccount": "有裝置正在請求存取此帳戶。", + "noData": "無資料", + "machineClients": "機器客戶端", + "install": "安裝", + "run": "執行", + "clientNameDescription": "客戶端的顯示名稱,可以稍後更改。", + "clientAddress": "客戶端位址(進階)", + "setupFailedToFetchSubnet": "取得預設子網路失敗", + "setupSubnetAdvanced": "子網路(進階)", + "setupSubnetDescription": "此組織內部網路的子網路。", + "setupUtilitySubnet": "工具子網路(進階)", + "setupUtilitySubnetDescription": "此組織別名位址和 DNS 伺服器的子網路。", + "siteRegenerateAndDisconnect": "重新產生並斷開連接", + "siteRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此站點的連接嗎?", + "siteRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開站點連接。站點需要使用新憑證重新啟動。", + "siteRegenerateCredentialsConfirmation": "您確定要重新產生此站點的憑證嗎?", + "siteRegenerateCredentialsWarning": "這將重新產生憑證。站點將保持連接,直到您手動重新啟動並使用新憑證。", + "clientRegenerateAndDisconnect": "重新產生並斷開連接", + "clientRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此客戶端的連接嗎?", + "clientRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開客戶端連接。客戶端需要使用新憑證重新啟動。", + "clientRegenerateCredentialsConfirmation": "您確定要重新產生此客戶端的憑證嗎?", + "clientRegenerateCredentialsWarning": "這將重新產生憑證。客戶端將保持連接,直到您手動重新啟動並使用新憑證。", + "remoteExitNodeRegenerateAndDisconnect": "重新產生並斷開連接", + "remoteExitNodeRegenerateAndDisconnectConfirmation": "您確定要重新產生憑證並斷開此遠端出口節點的連接嗎?", + "remoteExitNodeRegenerateAndDisconnectWarning": "這將重新產生憑證並立即斷開遠端出口節點連接。遠端出口節點需要使用新憑證重新啟動。", + "remoteExitNodeRegenerateCredentialsConfirmation": "您確定要重新產生此遠端出口節點的憑證嗎?", + "remoteExitNodeRegenerateCredentialsWarning": "這將重新產生憑證。遠端出口節點將保持連接,直到您手動重新啟動並使用新憑證。", + "agent": "代理", + "personalUseOnly": "僅限個人使用", + "loginPageLicenseWatermark": "此實例僅授權個人使用。", + "instanceIsUnlicensed": "此實例未授權。", + "portRestrictions": "連接埠限制", + "allPorts": "全部", + "custom": "自訂", + "allPortsAllowed": "允許所有連接埠", + "allPortsBlocked": "阻擋所有連接埠", + "tcpPortsDescription": "指定此資源允許的 TCP 連接埠。使用「*」表示所有連接埠,留空表示阻擋全部,或輸入以逗號分隔的連接埠和範圍(例如:80,443,8000-9000)。", + "udpPortsDescription": "指定此資源允許的 UDP 連接埠。使用「*」表示所有連接埠,留空表示阻擋全部,或輸入以逗號分隔的連接埠和範圍(例如:53,123,500-600)。", + "organizationLoginPageTitle": "組織登入頁面", + "organizationLoginPageDescription": "自訂此組織的登入頁面", + "resourceLoginPageTitle": "資源登入頁面", + "resourceLoginPageDescription": "自訂個別資源的登入頁面", + "enterConfirmation": "輸入確認", + "blueprintViewDetails": "詳細資訊", + "defaultIdentityProvider": "預設身份提供者", + "defaultIdentityProviderDescription": "當選擇預設身份提供者時,使用者將自動被重新導向到該提供者進行驗證。", + "editInternalResourceDialogNetworkSettings": "網路設定", + "editInternalResourceDialogAccessPolicy": "存取策略", + "editInternalResourceDialogAddRoles": "新增角色", + "editInternalResourceDialogAddUsers": "新增使用者", + "editInternalResourceDialogAddClients": "新增客戶端", + "editInternalResourceDialogDestinationLabel": "目的地", + "editInternalResourceDialogDestinationDescription": "指定內部資源的目的地位址。根據所選模式,這可以是主機名稱、IP 位址或 CIDR 範圍。可選擇設定內部 DNS 別名以便識別。", + "editInternalResourceDialogPortRestrictionsDescription": "限制對特定 TCP/UDP 連接埠的存取,或允許/阻擋所有連接埠。", + "editInternalResourceDialogTcp": "TCP", + "editInternalResourceDialogUdp": "UDP", + "editInternalResourceDialogIcmp": "ICMP", + "editInternalResourceDialogAccessControl": "存取控制", + "editInternalResourceDialogAccessControlDescription": "控制哪些角色、使用者和機器客戶端在連接時可以存取此資源。管理員始終擁有存取權限。", + "editInternalResourceDialogPortRangeValidationError": "連接埠範圍必須是「*」表示所有連接埠,或以逗號分隔的連接埠和範圍列表(例如:「80,443,8000-9000」)。連接埠必須介於 1 到 65535 之間。", + "orgAuthWhatsThis": "我在哪裡可以找到我的組織 ID?", + "learnMore": "了解更多", + "backToHome": "返回首頁", + "needToSignInToOrg": "需要使用您組織的身份提供者嗎?", + "maintenanceMode": "維護模式", + "maintenanceModeDescription": "向訪客顯示維護頁面", + "maintenanceModeType": "維護模式類型", + "showMaintenancePage": "向訪客顯示維護頁面", + "enableMaintenanceMode": "啟用維護模式", + "automatic": "自動", + "automaticModeDescription": "僅在所有後端目標都關閉或不健康時顯示維護頁面。只要至少有一個目標健康,您的資源就會正常運作。", + "forced": "強制", + "forcedModeDescription": "無論後端健康狀況如何,始終顯示維護頁面。當您想要阻止所有存取時,用於計劃維護。", + "warning:": "警告:", + "forcedeModeWarning": "所有流量將被導向維護頁面。您的後端資源將不會收到任何請求。", + "pageTitle": "頁面標題", + "pageTitleDescription": "維護頁面上顯示的主標題", + "maintenancePageMessage": "維護訊息", + "maintenancePageMessagePlaceholder": "我們很快就會回來!我們的網站目前正在進行預定維護。", + "maintenancePageMessageDescription": "說明維護的詳細訊息", + "maintenancePageTimeTitle": "預計完成時間(可選)", + "maintenanceTime": "例如:2 小時、11 月 1 日下午 5:00", + "maintenanceEstimatedTimeDescription": "您預計何時完成維護", + "editDomain": "編輯網域", + "editDomainDescription": "為您的資源選擇網域", + "maintenanceModeDisabledTooltip": "此功能需要有效的授權才能啟用。", + "maintenanceScreenTitle": "服務暫時無法使用", + "maintenanceScreenMessage": "我們目前遇到技術問題。請稍後再試。", + "maintenanceScreenEstimatedCompletion": "預計完成時間:", + "createInternalResourceDialogDestinationRequired": "目的地為必填欄位" } \ No newline at end of file diff --git a/next.config.ts b/next.config.ts index 05ed8e620..630a3416f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,6 +4,7 @@ import createNextIntlPlugin from "next-intl/plugin"; const withNextIntl = createNextIntlPlugin(); const nextConfig: NextConfig = { + reactStrictMode: false, eslint: { ignoreDuringBuilds: true }, diff --git a/package-lock.json b/package-lock.json index acd89ed50..7b63f1691 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,9 @@ "version": "0.0.0", "license": "SEE LICENSE IN LICENSE AND README.md", "dependencies": { - "@asteasolutions/zod-to-openapi": "8.2.0", - "@aws-sdk/client-s3": "3.955.0", - "@faker-js/faker": "10.1.0", + "@asteasolutions/zod-to-openapi": "8.4.1", + "@aws-sdk/client-s3": "3.1011.0", + "@faker-js/faker": "10.3.0", "@headlessui/react": "2.2.9", "@hookform/resolvers": "5.2.2", "@monaco-editor/react": "4.7.0", @@ -36,90 +36,83 @@ "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-tooltip": "1.2.8", - "@react-email/components": "1.0.2", - "@react-email/render": "2.0.0", - "@react-email/tailwind": "2.0.2", - "@simplewebauthn/browser": "13.2.2", - "@simplewebauthn/server": "13.2.2", + "@react-email/components": "1.0.8", + "@react-email/render": "2.0.4", + "@react-email/tailwind": "2.0.5", + "@simplewebauthn/browser": "13.3.0", + "@simplewebauthn/server": "13.3.0", "@tailwindcss/forms": "0.5.11", - "@tanstack/react-query": "5.90.12", + "@tanstack/react-query": "5.90.21", "@tanstack/react-table": "8.21.3", "arctic": "3.7.0", - "axios": "1.13.2", + "axios": "1.13.5", "better-sqlite3": "11.9.1", "canvas-confetti": "1.9.4", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", - "cookie": "1.1.1", "cookie-parser": "1.4.7", - "cookies": "0.9.1", - "cors": "2.8.5", + "cors": "2.8.6", "crypto-js": "4.2.0", "d3": "7.9.0", - "date-fns": "4.1.0", "drizzle-orm": "0.45.1", - "eslint": "9.39.2", - "eslint-config-next": "16.1.0", "express": "5.2.1", - "express-rate-limit": "8.2.1", - "glob": "13.0.0", + "express-rate-limit": "8.3.0", + "glob": "13.0.6", "helmet": "8.1.0", "http-errors": "2.0.1", - "i": "0.3.7", "input-otp": "1.4.2", - "ioredis": "5.8.2", + "ioredis": "5.10.0", "jmespath": "0.16.0", "js-yaml": "4.1.1", "jsonwebtoken": "9.0.3", - "lucide-react": "0.562.0", - "maxmind": "5.0.1", + "lucide-react": "0.577.0", + "maxmind": "5.0.5", "moment": "2.30.1", - "next": "15.5.9", - "next-intl": "4.6.1", + "next": "15.5.12", + "next-intl": "4.8.3", "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.7.0", - "nprogress": "0.2.0", + "nodemailer": "8.0.1", "oslo": "1.2.1", - "pg": "8.16.3", - "posthog-node": "5.17.4", + "pg": "8.20.0", + "posthog-node": "5.28.0", "qrcode.react": "4.2.0", - "react": "19.2.3", - "react-day-picker": "9.13.0", - "react-dom": "19.2.3", + "react": "19.2.4", + "react-day-picker": "9.14.0", + "react-dom": "19.2.4", "react-easy-sort": "1.8.0", - "react-hook-form": "7.68.0", - "react-icons": "5.5.0", - "rebuild": "0.1.2", - "recharts": "3.5.1", - "reodotdev": "1.0.0", - "resend": "6.6.0", - "semver": "7.7.3", - "stripe": "20.1.0", + "react-hook-form": "7.71.2", + "react-icons": "5.6.0", + "recharts": "2.15.4", + "reodotdev": "1.1.0", + "resend": "6.9.2", + "semver": "7.7.4", + "sshpk": "^1.18.0", + "stripe": "20.4.1", "swagger-ui-express": "5.0.1", - "tailwind-merge": "3.4.0", + "tailwind-merge": "3.5.0", "topojson-client": "3.1.0", "tw-animate-css": "1.4.0", + "use-debounce": "^10.1.0", "uuid": "13.0.0", "vaul": "1.1.2", "visionscarto-world-atlas": "1.0.0", "winston": "3.19.0", "winston-daily-rotate-file": "5.0.0", - "ws": "8.18.3", + "ws": "8.19.0", "yaml": "2.8.2", "yargs": "18.0.0", - "zod": "4.2.1", + "zod": "4.3.6", "zod-validation-error": "5.0.0" }, "devDependencies": { - "@dotenvx/dotenvx": "1.51.2", + "@dotenvx/dotenvx": "1.54.1", "@esbuild-plugins/tsconfig-paths": "0.1.2", - "@tailwindcss/postcss": "4.1.18", - "@tanstack/react-query-devtools": "5.91.1", + "@react-email/preview-server": "5.2.10", + "@tailwindcss/postcss": "4.2.1", + "@tanstack/react-query-devtools": "5.91.3", "@types/better-sqlite3": "7.6.13", "@types/cookie-parser": "1.4.10", "@types/cors": "2.8.19", @@ -130,29 +123,32 @@ "@types/jmespath": "0.15.2", "@types/js-yaml": "4.0.9", "@types/jsonwebtoken": "9.0.10", - "@types/node": "24.10.2", - "@types/nodemailer": "7.0.4", + "@types/node": "25.3.5", + "@types/nodemailer": "7.0.11", "@types/nprogress": "0.2.3", - "@types/pg": "8.16.0", - "@types/react": "19.2.7", + "@types/pg": "8.18.0", + "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@types/semver": "7.7.1", + "@types/sshpk": "^1.17.4", "@types/swagger-ui-express": "4.1.8", "@types/topojson-client": "3.1.5", "@types/ws": "8.18.1", "@types/yargs": "17.0.35", "babel-plugin-react-compiler": "1.0.0", - "drizzle-kit": "0.31.8", - "esbuild": "0.27.2", + "drizzle-kit": "0.31.10", + "esbuild": "0.27.3", "esbuild-node-externals": "1.20.1", - "postcss": "8.5.6", - "prettier": "3.7.4", - "react-email": "5.0.7", - "tailwindcss": "4.1.18", + "eslint": "10.0.3", + "eslint-config-next": "16.1.7", + "postcss": "8.5.8", + "prettier": "3.8.1", + "react-email": "5.2.10", + "tailwindcss": "4.2.1", "tsc-alias": "1.8.16", "tsx": "4.21.0", "typescript": "5.9.3", - "typescript-eslint": "8.49.0" + "typescript-eslint": "8.56.1" } }, "node_modules/@alloc/quick-lru": { @@ -168,23 +164,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@asteasolutions/zod-to-openapi": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.2.0.tgz", - "integrity": "sha512-u05zNUirlukJAf9oEHmxSF31L1XQhz9XdpVILt7+xhrz65oQqBpiOWFkGvRWL0IpjOUJ878idKoNmYPxrFnkeg==", + "version": "8.4.1", + "resolved": "https://registry.npmjs.org/@asteasolutions/zod-to-openapi/-/zod-to-openapi-8.4.1.tgz", + "integrity": "sha512-WmJUsFINbnWxGvHSd16aOjgKf+5GsfdxruO2YDLcgplsidakCauik1lhlk83YDH06265Yd1XtUyF24o09uygpw==", "license": "MIT", "dependencies": { "openapi3-ts": "^4.1.2" @@ -396,1312 +379,580 @@ } }, "node_modules/@aws-sdk/client-s3": { - "version": "3.955.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.955.0.tgz", - "integrity": "sha512-bFvSM6UB0R5hpWfXzHI3BlKwT2qYHto9JoDtzSr5FxVguTMzJyr+an11VT1Hi5wgO03luXEeXeloURFvaMs6TQ==", + "version": "3.1011.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1011.0.tgz", + "integrity": "sha512-jY7CGX+vfM/DSi4K8UwaZKoXnhqchmAbKFB1kIuHMfPPqW7l3jC/fUVDb95/njMsB2ymYOTusZEzoCTeUB/4qA==", "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.954.0", - "@aws-sdk/credential-provider-node": "3.955.0", - "@aws-sdk/middleware-bucket-endpoint": "3.953.0", - "@aws-sdk/middleware-expect-continue": "3.953.0", - "@aws-sdk/middleware-flexible-checksums": "3.954.0", - "@aws-sdk/middleware-host-header": "3.953.0", - "@aws-sdk/middleware-location-constraint": "3.953.0", - "@aws-sdk/middleware-logger": "3.953.0", - "@aws-sdk/middleware-recursion-detection": "3.953.0", - "@aws-sdk/middleware-sdk-s3": "3.954.0", - "@aws-sdk/middleware-ssec": "3.953.0", - "@aws-sdk/middleware-user-agent": "3.954.0", - "@aws-sdk/region-config-resolver": "3.953.0", - "@aws-sdk/signature-v4-multi-region": "3.954.0", - "@aws-sdk/types": "3.953.0", - "@aws-sdk/util-endpoints": "3.953.0", - "@aws-sdk/util-user-agent-browser": "3.953.0", - "@aws-sdk/util-user-agent-node": "3.954.0", - "@smithy/config-resolver": "^4.4.4", - "@smithy/core": "^3.19.0", - "@smithy/eventstream-serde-browser": "^4.2.6", - "@smithy/eventstream-serde-config-resolver": "^4.3.6", - "@smithy/eventstream-serde-node": "^4.2.6", - "@smithy/fetch-http-handler": "^5.3.7", - "@smithy/hash-blob-browser": "^4.2.7", - "@smithy/hash-node": "^4.2.6", - "@smithy/hash-stream-node": "^4.2.6", - "@smithy/invalid-dependency": "^4.2.6", - "@smithy/md5-js": "^4.2.6", - "@smithy/middleware-content-length": "^4.2.6", - "@smithy/middleware-endpoint": "^4.4.0", - "@smithy/middleware-retry": "^4.4.16", - "@smithy/middleware-serde": "^4.2.7", - "@smithy/middleware-stack": "^4.2.6", - "@smithy/node-config-provider": "^4.3.6", - "@smithy/node-http-handler": "^4.4.6", - "@smithy/protocol-http": "^5.3.6", - "@smithy/smithy-client": "^4.10.1", - "@smithy/types": "^4.10.0", - "@smithy/url-parser": "^4.2.6", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.15", - "@smithy/util-defaults-mode-node": "^4.2.18", - "@smithy/util-endpoints": "^3.2.6", - "@smithy/util-middleware": "^4.2.6", - "@smithy/util-retry": "^4.2.6", - "@smithy/util-stream": "^4.5.7", - "@smithy/util-utf8": "^4.2.0", - "@smithy/util-waiter": "^4.2.6", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/credential-provider-node": "^3.972.21", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.8", + "@aws-sdk/middleware-expect-continue": "^3.972.8", + "@aws-sdk/middleware-flexible-checksums": "^3.974.0", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-location-constraint": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-sdk-s3": "^3.972.20", + "@aws-sdk/middleware-ssec": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.21", + "@aws-sdk/region-config-resolver": "^3.972.8", + "@aws-sdk/signature-v4-multi-region": "^3.996.8", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.7", + "@smithy/config-resolver": "^4.4.11", + "@smithy/core": "^3.23.11", + "@smithy/eventstream-serde-browser": "^4.2.12", + "@smithy/eventstream-serde-config-resolver": "^4.3.12", + "@smithy/eventstream-serde-node": "^4.2.12", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-blob-browser": "^4.2.13", + "@smithy/hash-node": "^4.2.12", + "@smithy/hash-stream-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/md5-js": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.25", + "@smithy/middleware-retry": "^4.4.42", + "@smithy/middleware-serde": "^4.2.14", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.4.16", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.41", + "@smithy/util-defaults-mode-node": "^4.2.44", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-stream": "^4.5.19", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.13", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/client-sso": { - "version": "3.955.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.955.0.tgz", - "integrity": "sha512-+nym5boDFt2ksba0fElocMKxCFJbJcd31PI3502hoI1N5VK7HyxkQeBtQJ64JYomvw8eARjWWC13hkB0LtZILw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.954.0", - "@aws-sdk/middleware-host-header": "3.953.0", - "@aws-sdk/middleware-logger": "3.953.0", - "@aws-sdk/middleware-recursion-detection": "3.953.0", - "@aws-sdk/middleware-user-agent": "3.954.0", - "@aws-sdk/region-config-resolver": "3.953.0", - "@aws-sdk/types": "3.953.0", - "@aws-sdk/util-endpoints": "3.953.0", - "@aws-sdk/util-user-agent-browser": "3.953.0", - "@aws-sdk/util-user-agent-node": "3.954.0", - "@smithy/config-resolver": "^4.4.4", - "@smithy/core": "^3.19.0", - "@smithy/fetch-http-handler": "^5.3.7", - "@smithy/hash-node": "^4.2.6", - "@smithy/invalid-dependency": "^4.2.6", - "@smithy/middleware-content-length": "^4.2.6", - "@smithy/middleware-endpoint": "^4.4.0", - "@smithy/middleware-retry": "^4.4.16", - "@smithy/middleware-serde": "^4.2.7", - "@smithy/middleware-stack": "^4.2.6", - "@smithy/node-config-provider": "^4.3.6", - "@smithy/node-http-handler": "^4.4.6", - "@smithy/protocol-http": "^5.3.6", - "@smithy/smithy-client": "^4.10.1", - "@smithy/types": "^4.10.0", - "@smithy/url-parser": "^4.2.6", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.15", - "@smithy/util-defaults-mode-node": "^4.2.18", - "@smithy/util-endpoints": "^3.2.6", - "@smithy/util-middleware": "^4.2.6", - "@smithy/util-retry": "^4.2.6", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/core": { - "version": "3.954.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.954.0.tgz", - "integrity": "sha512-5oYO5RP+mvCNXNj8XnF9jZo0EP0LTseYOJVNQYcii1D9DJqzHL3HJWurYh7cXxz7G7eDyvVYA01O9Xpt34TdoA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.953.0", - "@aws-sdk/xml-builder": "3.953.0", - "@smithy/core": "^3.19.0", - "@smithy/node-config-provider": "^4.3.6", - "@smithy/property-provider": "^4.2.6", - "@smithy/protocol-http": "^5.3.6", - "@smithy/signature-v4": "^5.3.6", - "@smithy/smithy-client": "^4.10.1", - "@smithy/types": "^4.10.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-middleware": "^4.2.6", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-env": { - "version": "3.954.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.954.0.tgz", - "integrity": "sha512-2HNkqBjfsvyoRuPAiFh86JBFMFyaCNhL4VyH6XqwTGKZffjG7hdBmzXPy7AT7G3oFh1k/1Zc27v0qxaKoK7mBA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.954.0", - "@aws-sdk/types": "3.953.0", - "@smithy/property-provider": "^4.2.6", - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-http": { - "version": "3.954.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.954.0.tgz", - "integrity": "sha512-CrWD5300+NE1OYRnSVDxoG7G0b5cLIZb7yp+rNQ5Jq/kqnTmyJXpVAsivq+bQIDaGzPXhadzpAMIoo7K/aHaag==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.954.0", - "@aws-sdk/types": "3.953.0", - "@smithy/fetch-http-handler": "^5.3.7", - "@smithy/node-http-handler": "^4.4.6", - "@smithy/property-provider": "^4.2.6", - "@smithy/protocol-http": "^5.3.6", - "@smithy/smithy-client": "^4.10.1", - "@smithy/types": "^4.10.0", - "@smithy/util-stream": "^4.5.7", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.955.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.955.0.tgz", - "integrity": "sha512-90isLovxsPzaaSx3IIUZuxym6VXrsRetnQ3AuHr2kiTFk2pIzyIwmi+gDcUaLXQ5nNBoSj1Z/4+i1vhxa1n2DQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.954.0", - "@aws-sdk/credential-provider-env": "3.954.0", - "@aws-sdk/credential-provider-http": "3.954.0", - "@aws-sdk/credential-provider-login": "3.955.0", - "@aws-sdk/credential-provider-process": "3.954.0", - "@aws-sdk/credential-provider-sso": "3.955.0", - "@aws-sdk/credential-provider-web-identity": "3.955.0", - "@aws-sdk/nested-clients": "3.955.0", - "@aws-sdk/types": "3.953.0", - "@smithy/credential-provider-imds": "^4.2.6", - "@smithy/property-provider": "^4.2.6", - "@smithy/shared-ini-file-loader": "^4.4.1", - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-login": { - "version": "3.955.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.955.0.tgz", - "integrity": "sha512-xlkmSvg8oDN5LIxLAq3N1QWK8F8gUAsBWZlp1IX8Lr5XhcKI3GVarIIUcZrvCy1NjzCd/LDXYdNL6MRlNP4bAw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.954.0", - "@aws-sdk/nested-clients": "3.955.0", - "@aws-sdk/types": "3.953.0", - "@smithy/property-provider": "^4.2.6", - "@smithy/protocol-http": "^5.3.6", - "@smithy/shared-ini-file-loader": "^4.4.1", - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-node": { - "version": "3.955.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.955.0.tgz", - "integrity": "sha512-XIL4QB+dPOJA6DRTmYZL52wFcLTslb7V1ydS4FCNT2DVLhkO4ExkPP+pe5YmIpzt/Our1ugS+XxAs3e6BtyFjA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/credential-provider-env": "3.954.0", - "@aws-sdk/credential-provider-http": "3.954.0", - "@aws-sdk/credential-provider-ini": "3.955.0", - "@aws-sdk/credential-provider-process": "3.954.0", - "@aws-sdk/credential-provider-sso": "3.955.0", - "@aws-sdk/credential-provider-web-identity": "3.955.0", - "@aws-sdk/types": "3.953.0", - "@smithy/credential-provider-imds": "^4.2.6", - "@smithy/property-provider": "^4.2.6", - "@smithy/shared-ini-file-loader": "^4.4.1", - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-process": { - "version": "3.954.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.954.0.tgz", - "integrity": "sha512-Y1/0O2LgbKM8iIgcVj/GNEQW6p90LVTCOzF2CI1pouoKqxmZ/1F7F66WHoa6XUOfKaCRj/R6nuMR3om9ThaM5A==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.954.0", - "@aws-sdk/types": "3.953.0", - "@smithy/property-provider": "^4.2.6", - "@smithy/shared-ini-file-loader": "^4.4.1", - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.955.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.955.0.tgz", - "integrity": "sha512-Y99KI73Fn8JnB4RY5Ls6j7rd5jmFFwnY9WLHIWeJdc+vfwL6Bb1uWKW3+m/B9+RC4Xoz2nQgtefBcdWq5Xx8iw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/client-sso": "3.955.0", - "@aws-sdk/core": "3.954.0", - "@aws-sdk/token-providers": "3.955.0", - "@aws-sdk/types": "3.953.0", - "@smithy/property-provider": "^4.2.6", - "@smithy/shared-ini-file-loader": "^4.4.1", - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.955.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.955.0.tgz", - "integrity": "sha512-+lFxkZ2Vz3qp/T68ZONKzWVTQvomTu7E6tts1dfAbEcDt62Y/nPCByq/C2hQj+TiN05HrUx+yTJaGHBklhkbqA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.954.0", - "@aws-sdk/nested-clients": "3.955.0", - "@aws-sdk/types": "3.953.0", - "@smithy/property-provider": "^4.2.6", - "@smithy/shared-ini-file-loader": "^4.4.1", - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-host-header": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.953.0.tgz", - "integrity": "sha512-jTGhfkONav+r4E6HLOrl5SzBqDmPByUYCkyB/c/3TVb8jX3wAZx8/q9bphKpCh+G5ARi3IdbSisgkZrJYqQ19Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.953.0", - "@smithy/protocol-http": "^5.3.6", - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-logger": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.953.0.tgz", - "integrity": "sha512-PlWdVYgcuptkIC0ZKqVUhWNtSHXJSx7U9V8J7dJjRmsXC40X7zpEycvrkzDMJjeTDGcCceYbyYAg/4X1lkcIMw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.953.0", - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.953.0.tgz", - "integrity": "sha512-cmIJx0gWeesUKK4YwgE+VQL3mpACr3/J24fbwnc1Z5tntC86b+HQFzU5vsBDw6lLwyD46dBgWdsXFh1jL+ZaFw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.953.0", - "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.6", - "@smithy/types": "^4.10.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.954.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.954.0.tgz", - "integrity": "sha512-274CNmnRjknmfFb2o0Azxic54fnujaA8AYSeRUOho3lN48TVzx85eAFWj2kLgvUJO88pE3jBDPWboKQiQdXeUQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.954.0", - "@aws-sdk/types": "3.953.0", - "@aws-sdk/util-arn-parser": "3.953.0", - "@smithy/core": "^3.19.0", - "@smithy/node-config-provider": "^4.3.6", - "@smithy/protocol-http": "^5.3.6", - "@smithy/signature-v4": "^5.3.6", - "@smithy/smithy-client": "^4.10.1", - "@smithy/types": "^4.10.0", - "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-middleware": "^4.2.6", - "@smithy/util-stream": "^4.5.7", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.954.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.954.0.tgz", - "integrity": "sha512-5PX8JDe3dB2+MqXeGIhmgFnm2rbVsSxhz+Xyuu1oxLtbOn+a9UDA+sNBufEBjt3UxWy5qwEEY1fxdbXXayjlGg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.954.0", - "@aws-sdk/types": "3.953.0", - "@aws-sdk/util-endpoints": "3.953.0", - "@smithy/core": "^3.19.0", - "@smithy/protocol-http": "^5.3.6", - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/nested-clients": { - "version": "3.955.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.955.0.tgz", - "integrity": "sha512-RBi6CQHbPF09kqXAoiEOOPkVnSoU5YppKoOt/cgsWfoMHwC+7itIrEv+yRD62h14jIjF3KngVIQIrBRbX3o3/Q==", - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.954.0", - "@aws-sdk/middleware-host-header": "3.953.0", - "@aws-sdk/middleware-logger": "3.953.0", - "@aws-sdk/middleware-recursion-detection": "3.953.0", - "@aws-sdk/middleware-user-agent": "3.954.0", - "@aws-sdk/region-config-resolver": "3.953.0", - "@aws-sdk/types": "3.953.0", - "@aws-sdk/util-endpoints": "3.953.0", - "@aws-sdk/util-user-agent-browser": "3.953.0", - "@aws-sdk/util-user-agent-node": "3.954.0", - "@smithy/config-resolver": "^4.4.4", - "@smithy/core": "^3.19.0", - "@smithy/fetch-http-handler": "^5.3.7", - "@smithy/hash-node": "^4.2.6", - "@smithy/invalid-dependency": "^4.2.6", - "@smithy/middleware-content-length": "^4.2.6", - "@smithy/middleware-endpoint": "^4.4.0", - "@smithy/middleware-retry": "^4.4.16", - "@smithy/middleware-serde": "^4.2.7", - "@smithy/middleware-stack": "^4.2.6", - "@smithy/node-config-provider": "^4.3.6", - "@smithy/node-http-handler": "^4.4.6", - "@smithy/protocol-http": "^5.3.6", - "@smithy/smithy-client": "^4.10.1", - "@smithy/types": "^4.10.0", - "@smithy/url-parser": "^4.2.6", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.15", - "@smithy/util-defaults-mode-node": "^4.2.18", - "@smithy/util-endpoints": "^3.2.6", - "@smithy/util-middleware": "^4.2.6", - "@smithy/util-retry": "^4.2.6", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/region-config-resolver": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.953.0.tgz", - "integrity": "sha512-5MJgnsc+HLO+le0EK1cy92yrC7kyhGZSpaq8PcQvKs9qtXCXT5Tb6tMdkr5Y07JxYsYOV1omWBynvL6PWh08tQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.953.0", - "@smithy/config-resolver": "^4.4.4", - "@smithy/node-config-provider": "^4.3.6", - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.954.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.954.0.tgz", - "integrity": "sha512-GJJbUaSlGrMSRWui3Oz8ByygpQlzDGm195yTKirgGyu4tfYrFr/QWrWT42EUktY/L4Irev1pdHTuLS+AGHO1gw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.954.0", - "@aws-sdk/types": "3.953.0", - "@smithy/protocol-http": "^5.3.6", - "@smithy/signature-v4": "^5.3.6", - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/token-providers": { - "version": "3.955.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.955.0.tgz", - "integrity": "sha512-LVpWkxXvMPgZofP2Gc8XBfQhsyecBMVARDHWMvks6vPbCLSTM7dw6H1HI9qbGNCurYcyc2xBRAkEDhChQlbPPg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "3.954.0", - "@aws-sdk/nested-clients": "3.955.0", - "@aws-sdk/types": "3.953.0", - "@smithy/property-provider": "^4.2.6", - "@smithy/shared-ini-file-loader": "^4.4.1", - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/types": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.953.0.tgz", - "integrity": "sha512-M9Iwg9kTyqTErI0vOTVVpcnTHWzS3VplQppy8MuL02EE+mJ0BIwpWfsaAPQW+/XnVpdNpWZTsHcNE29f1+hR8g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-arn-parser": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.953.0.tgz", - "integrity": "sha512-9hqdKkn4OvYzzaLryq2xnwcrPc8ziY34i9szUdgBfSqEC6pBxbY9/lLXmrgzfwMSL2Z7/v2go4Od0p5eukKLMQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-endpoints": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.953.0.tgz", - "integrity": "sha512-rjaS6jrFksopXvNg6YeN+D1lYwhcByORNlFuYesFvaQNtPOufbE5tJL4GJ3TMXyaY0uFR28N5BHHITPyWWfH/g==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.953.0", - "@smithy/types": "^4.10.0", - "@smithy/url-parser": "^4.2.6", - "@smithy/util-endpoints": "^3.2.6", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.953.0.tgz", - "integrity": "sha512-UF5NeqYesWuFao+u7LJvpV1SJCaLml5BtFZKUdTnNNMeN6jvV+dW/eQoFGpXF94RCqguX0XESmRuRRPQp+/rzQ==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.953.0", - "@smithy/types": "^4.10.0", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.954.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.954.0.tgz", - "integrity": "sha512-fB5S5VOu7OFkeNzcblQlez4AjO5hgDFaa7phYt7716YWisY3RjAaQPlxgv+G3GltHHDJIfzEC5aRxdf62B9zMg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/middleware-user-agent": "3.954.0", - "@aws-sdk/types": "3.953.0", - "@smithy/node-config-provider": "^4.3.6", - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } - } - }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/xml-builder": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.953.0.tgz", - "integrity": "sha512-Zmrj21jQ2OeOJGr9spPiN00aQvXa/WUqRXcTVENhrMt+OFoSOfDFpYhUj9NQ09QmQ8KMWFoWuWW6iKurNqLvAA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.10.0", - "fast-xml-parser": "5.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-sesv2": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sesv2/-/client-sesv2-3.946.0.tgz", - "integrity": "sha512-JYj3BPqgyRXgBjZ3Xvo4Abd+vLxcsHe4gb0TvwiSM/k7e6MRgBZoYwDOnwbNDs/62X1sn7MPHqqB3miuO4nR5g==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.946.0", - "@aws-sdk/credential-provider-node": "3.946.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-user-agent": "3.946.0", - "@aws-sdk/region-config-resolver": "3.936.0", - "@aws-sdk/signature-v4-multi-region": "3.946.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@aws-sdk/util-user-agent-browser": "3.936.0", - "@aws-sdk/util-user-agent-node": "3.946.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/core": "^3.18.7", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/hash-node": "^4.2.5", - "@smithy/invalid-dependency": "^4.2.5", - "@smithy/middleware-content-length": "^4.2.5", - "@smithy/middleware-endpoint": "^4.3.14", - "@smithy/middleware-retry": "^4.4.14", - "@smithy/middleware-serde": "^4.2.6", - "@smithy/middleware-stack": "^4.2.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.13", - "@smithy/util-defaults-mode-node": "^4.2.16", - "@smithy/util-endpoints": "^3.2.5", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-retry": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-sso": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.946.0.tgz", - "integrity": "sha512-kGAs5iIVyUz4p6TX3pzG5q3cNxXnVpC4pwRC6DCSaSv9ozyPjc2d74FsK4fZ+J+ejtvCdJk72uiuQtWJc86Wuw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.946.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-user-agent": "3.946.0", - "@aws-sdk/region-config-resolver": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@aws-sdk/util-user-agent-browser": "3.936.0", - "@aws-sdk/util-user-agent-node": "3.946.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/core": "^3.18.7", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/hash-node": "^4.2.5", - "@smithy/invalid-dependency": "^4.2.5", - "@smithy/middleware-content-length": "^4.2.5", - "@smithy/middleware-endpoint": "^4.3.14", - "@smithy/middleware-retry": "^4.4.14", - "@smithy/middleware-serde": "^4.2.6", - "@smithy/middleware-stack": "^4.2.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.13", - "@smithy/util-defaults-mode-node": "^4.2.16", - "@smithy/util-endpoints": "^3.2.5", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-retry": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/core": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.946.0.tgz", - "integrity": "sha512-u2BkbLLVbMFrEiXrko2+S6ih5sUZPlbVyRPtXOqMHlCyzr70sE8kIiD6ba223rQeIFPcYfW/wHc6k4ihW2xxVg==", - "dev": true, + "version": "3.973.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.20.tgz", + "integrity": "sha512-i3GuX+lowD892F3IuJf8o6AbyDupMTdyTxQrCJGcn71ni5hTZ82L4nQhcdumxZ7XPJRJJVHS/CR3uYOIIs0PVA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@aws-sdk/xml-builder": "3.930.0", - "@smithy/core": "^3.18.7", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/signature-v4": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/xml-builder": "^3.972.11", + "@smithy/core": "^3.23.11", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.5.tgz", + "integrity": "sha512-2VbTstbjKdT+yKi8m7b3a9CiVac+pL/IY2PHJwsaGkkHmuuqkJZIErPck1h6P3T9ghQMLSdMPyW6Qp7Di5swFg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.13.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.946.0.tgz", - "integrity": "sha512-P4l+K6wX1tf8LmWUvZofdQ+BgCNyk6Tb9u1H10npvqpuCD+dCM4pXIBq3PQcv/juUBOvLGGREo+Govuh3lfD0Q==", - "dev": true, + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.18.tgz", + "integrity": "sha512-X0B8AlQY507i5DwjLByeU2Af4ARsl9Vr84koDcXCbAkplmU+1xBFWxEPrWRAoh56waBne/yJqEloSwvRf4x6XA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.946.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.946.0.tgz", - "integrity": "sha512-/zeOJ6E7dGZQ/l2k7KytEoPJX0APIhwt0A79hPf/bUpMF4dDs2P6JmchDrotk0a0Y/MIdNF8sBQ/MEOPnBiYoQ==", - "dev": true, + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.20.tgz", + "integrity": "sha512-ey9Lelj001+oOfrbKmS6R2CJAiXX7QKY4Vj9VJv6L2eE6/VjD8DocHIoYqztTm70xDLR4E1jYPTKfIui+eRNDA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.946.0", - "@aws-sdk/types": "3.936.0", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/util-stream": "^4.5.6", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/types": "^3.973.6", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.4.16", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.19", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.946.0.tgz", - "integrity": "sha512-Pdgcra3RivWj/TuZmfFaHbqsvvgnSKO0CxlRUMMr0PgBiCnUhyl+zBktdNOeGsOPH2fUzQpYhcUjYUgVSdcSDQ==", - "dev": true, + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.20.tgz", + "integrity": "sha512-5flXSnKHMloObNF+9N0cupKegnH1Z37cdVlpETVgx8/rAhCe+VNlkcZH3HDg2SDn9bI765S+rhNPXGDJJPfbtA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.946.0", - "@aws-sdk/credential-provider-env": "3.946.0", - "@aws-sdk/credential-provider-http": "3.946.0", - "@aws-sdk/credential-provider-login": "3.946.0", - "@aws-sdk/credential-provider-process": "3.946.0", - "@aws-sdk/credential-provider-sso": "3.946.0", - "@aws-sdk/credential-provider-web-identity": "3.946.0", - "@aws-sdk/nested-clients": "3.946.0", - "@aws-sdk/types": "3.936.0", - "@smithy/credential-provider-imds": "^4.2.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/credential-provider-env": "^3.972.18", + "@aws-sdk/credential-provider-http": "^3.972.20", + "@aws-sdk/credential-provider-login": "^3.972.20", + "@aws-sdk/credential-provider-process": "^3.972.18", + "@aws-sdk/credential-provider-sso": "^3.972.20", + "@aws-sdk/credential-provider-web-identity": "^3.972.20", + "@aws-sdk/nested-clients": "^3.996.10", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.946.0.tgz", - "integrity": "sha512-5iqLNc15u2Zx+7jOdQkIbP62N7n2031tw5hkmIG0DLnozhnk64osOh2CliiOE9x3c4P9Pf4frAwgyy9GzNTk2g==", - "dev": true, + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.20.tgz", + "integrity": "sha512-gEWo54nfqp2jABMu6HNsjVC4hDLpg9HC8IKSJnp0kqWtxIJYHTmiLSsIfI4ScQjxEwpB+jOOH8dOLax1+hy/Hw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.946.0", - "@aws-sdk/nested-clients": "3.946.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/nested-clients": "^3.996.10", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.946.0.tgz", - "integrity": "sha512-I7URUqnBPng1a5y81OImxrwERysZqMBREG6svhhGeZgxmqcpAZ8z5ywILeQXdEOCuuES8phUp/ojzxFjPXp/eA==", - "dev": true, + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.21.tgz", + "integrity": "sha512-hah8if3/B/Q+LBYN5FukyQ1Mym6PLPDsBOBsIgNEYD6wLyZg0UmUF/OKIVC3nX9XH8TfTPuITK+7N/jenVACWA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "3.946.0", - "@aws-sdk/credential-provider-http": "3.946.0", - "@aws-sdk/credential-provider-ini": "3.946.0", - "@aws-sdk/credential-provider-process": "3.946.0", - "@aws-sdk/credential-provider-sso": "3.946.0", - "@aws-sdk/credential-provider-web-identity": "3.946.0", - "@aws-sdk/types": "3.936.0", - "@smithy/credential-provider-imds": "^4.2.5", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/credential-provider-env": "^3.972.18", + "@aws-sdk/credential-provider-http": "^3.972.20", + "@aws-sdk/credential-provider-ini": "^3.972.20", + "@aws-sdk/credential-provider-process": "^3.972.18", + "@aws-sdk/credential-provider-sso": "^3.972.20", + "@aws-sdk/credential-provider-web-identity": "^3.972.20", + "@aws-sdk/types": "^3.973.6", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.946.0.tgz", - "integrity": "sha512-GtGHX7OGqIeVQ3DlVm5RRF43Qmf3S1+PLJv9svrdvAhAdy2bUb044FdXXqrtSsIfpzTKlHgQUiRo5MWLd35Ntw==", - "dev": true, + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.18.tgz", + "integrity": "sha512-Tpl7SRaPoOLT32jbTWchPsn52hYYgJ0kpiFgnwk8pxTANQdUymVSZkzFvv1+oOgZm1CrbQUP9MBeoMZ9IzLZjA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.946.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.946.0.tgz", - "integrity": "sha512-LeGSSt2V5iwYey1ENGY75RmoDP3bA2iE/py8QBKW8EDA8hn74XBLkprhrK5iccOvU3UGWY8WrEKFAFGNjJOL9g==", - "dev": true, + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.20.tgz", + "integrity": "sha512-p+R+PYR5Z7Gjqf/6pvbCnzEHcqPCpLzR7Yf127HjJ6EAb4hUcD+qsNRnuww1sB/RmSeCLxyay8FMyqREw4p1RA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-sso": "3.946.0", - "@aws-sdk/core": "3.946.0", - "@aws-sdk/token-providers": "3.946.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/nested-clients": "^3.996.10", + "@aws-sdk/token-providers": "3.1009.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.946.0.tgz", - "integrity": "sha512-ocBCvjWfkbjxElBI1QUxOnHldsNhoU0uOICFvuRDAZAoxvypJHN3m5BJkqb7gqorBbcv3LRgmBdEnWXOAvq+7Q==", - "dev": true, + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.20.tgz", + "integrity": "sha512-rWCmh8o7QY4CsUj63qopzMzkDq/yPpkrpb+CnjBEFSOg/02T/we7sSTVg4QsDiVS9uwZ8VyONhq98qt+pIh3KA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.946.0", - "@aws-sdk/nested-clients": "3.946.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/nested-clients": "^3.996.10", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/middleware-bucket-endpoint": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.953.0.tgz", - "integrity": "sha512-YHVRIOowtGIl/L2WuS83FgRlm31tU0aL1yryWaFtF+AFjA5BIeiFkxIZqaRGxJpJvFEBdohsyq6Ipv5mgWfezg==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.8.tgz", + "integrity": "sha512-WR525Rr2QJSETa9a050isktyWi/4yIGcmY3BQ1kpHqb0LqUglQHCS8R27dTJxxWNZvQ0RVGtEZjTCbZJpyF3Aw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.953.0", - "@aws-sdk/util-arn-parser": "3.953.0", - "@smithy/node-config-provider": "^4.3.6", - "@smithy/protocol-http": "^5.3.6", - "@smithy/types": "^4.10.0", - "@smithy/util-config-provider": "^4.2.0", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-bucket-endpoint/node_modules/@aws-sdk/types": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.953.0.tgz", - "integrity": "sha512-M9Iwg9kTyqTErI0vOTVVpcnTHWzS3VplQppy8MuL02EE+mJ0BIwpWfsaAPQW+/XnVpdNpWZTsHcNE29f1+hR8g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-bucket-endpoint/node_modules/@aws-sdk/util-arn-parser": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.953.0.tgz", - "integrity": "sha512-9hqdKkn4OvYzzaLryq2xnwcrPc8ziY34i9szUdgBfSqEC6pBxbY9/lLXmrgzfwMSL2Z7/v2go4Od0p5eukKLMQ==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/middleware-expect-continue": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.953.0.tgz", - "integrity": "sha512-BQTVXrypQ0rbb7au/Hk4IS5GaJZlwk6O44Rjk6Kxb0IvGQhSurNTuesFiJx1sLbf+w+T31saPtODcfQQERqhCQ==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.8.tgz", + "integrity": "sha512-5DTBTiotEES1e2jOHAq//zyzCjeMB78lEHd35u15qnrid4Nxm7diqIf9fQQ3Ov0ChH1V3Vvt13thOnrACmfGVQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.953.0", - "@smithy/protocol-http": "^5.3.6", - "@smithy/types": "^4.10.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-expect-continue/node_modules/@aws-sdk/types": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.953.0.tgz", - "integrity": "sha512-M9Iwg9kTyqTErI0vOTVVpcnTHWzS3VplQppy8MuL02EE+mJ0BIwpWfsaAPQW+/XnVpdNpWZTsHcNE29f1+hR8g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/middleware-flexible-checksums": { - "version": "3.954.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.954.0.tgz", - "integrity": "sha512-hHOPDJyxucNodkgapLhA0VdwDBwVYN9DX20aA6j+3nwutAlZ5skaV7Bw0W3YC7Fh/ieDKKhcSZulONd4lVTwMg==", + "version": "3.974.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.0.tgz", + "integrity": "sha512-BmdDjqvnuYaC4SY7ypHLXfCSsGYGUZkjCLSZyUAAYn1YT28vbNMJNDwhlfkvvE+hQHG5RJDlEmYuvBxcB9jX1g==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", "@aws-crypto/crc32c": "5.2.0", "@aws-crypto/util": "5.2.0", - "@aws-sdk/core": "3.954.0", - "@aws-sdk/types": "3.953.0", - "@smithy/is-array-buffer": "^4.2.0", - "@smithy/node-config-provider": "^4.3.6", - "@smithy/protocol-http": "^5.3.6", - "@smithy/types": "^4.10.0", - "@smithy/util-middleware": "^4.2.6", - "@smithy/util-stream": "^4.5.7", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/crc64-nvme": "^3.972.5", + "@aws-sdk/types": "^3.973.6", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.19", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-flexible-checksums/node_modules/@aws-sdk/core": { - "version": "3.954.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.954.0.tgz", - "integrity": "sha512-5oYO5RP+mvCNXNj8XnF9jZo0EP0LTseYOJVNQYcii1D9DJqzHL3HJWurYh7cXxz7G7eDyvVYA01O9Xpt34TdoA==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/types": "3.953.0", - "@aws-sdk/xml-builder": "3.953.0", - "@smithy/core": "^3.19.0", - "@smithy/node-config-provider": "^4.3.6", - "@smithy/property-provider": "^4.2.6", - "@smithy/protocol-http": "^5.3.6", - "@smithy/signature-v4": "^5.3.6", - "@smithy/smithy-client": "^4.10.1", - "@smithy/types": "^4.10.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-middleware": "^4.2.6", - "@smithy/util-utf8": "^4.2.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-flexible-checksums/node_modules/@aws-sdk/types": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.953.0.tgz", - "integrity": "sha512-M9Iwg9kTyqTErI0vOTVVpcnTHWzS3VplQppy8MuL02EE+mJ0BIwpWfsaAPQW+/XnVpdNpWZTsHcNE29f1+hR8g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-flexible-checksums/node_modules/@aws-sdk/xml-builder": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.953.0.tgz", - "integrity": "sha512-Zmrj21jQ2OeOJGr9spPiN00aQvXa/WUqRXcTVENhrMt+OFoSOfDFpYhUj9NQ09QmQ8KMWFoWuWW6iKurNqLvAA==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.10.0", - "fast-xml-parser": "5.2.5", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.936.0.tgz", - "integrity": "sha512-tAaObaAnsP1XnLGndfkGWFuzrJYuk9W0b/nLvol66t8FZExIAf/WdkT2NNAWOYxljVs++oHnyHBCxIlaHrzSiw==", - "dev": true, + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.8.tgz", + "integrity": "sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/middleware-location-constraint": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.953.0.tgz", - "integrity": "sha512-h0urrbteIQEybyIISaJfQLZ/+/lJPRzPWAQT4epvzfgv/4MKZI7K83dK7SfTwAooVKFBHiCMok2Cf0iHDt07Kw==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.8.tgz", + "integrity": "sha512-KaUoFuoFPziIa98DSQsTPeke1gvGXlc5ZGMhy+b+nLxZ4A7jmJgLzjEF95l8aOQN2T/qlPP3MrAyELm8ExXucw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.953.0", - "@smithy/types": "^4.10.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-location-constraint/node_modules/@aws-sdk/types": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.953.0.tgz", - "integrity": "sha512-M9Iwg9kTyqTErI0vOTVVpcnTHWzS3VplQppy8MuL02EE+mJ0BIwpWfsaAPQW+/XnVpdNpWZTsHcNE29f1+hR8g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.936.0.tgz", - "integrity": "sha512-aPSJ12d3a3Ea5nyEnLbijCaaYJT2QjQ9iW+zGh5QcZYXmOGWbKVyPSxmVOboZQG+c1M8t6d2O7tqrwzIq8L8qw==", - "dev": true, + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.8.tgz", + "integrity": "sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "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, + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.8.tgz", + "integrity": "sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@aws/lambda-invoke-store": "^0.2.0", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", + "@aws-sdk/types": "^3.973.6", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/middleware-sdk-s3": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.946.0.tgz", - "integrity": "sha512-0UTFmFd8PX2k/jLu/DBmR+mmLQWAtUGHYps9Rjx3dcXNwaMLaa/39NoV3qn7Dwzfpqc6JZlZzBk+NDOCJIHW9g==", - "dev": true, + "version": "3.972.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.20.tgz", + "integrity": "sha512-yhva/xL5H4tWQgsBjwV+RRD0ByCzg0TcByDCLp3GXdn/wlyRNfy8zsswDtCvr1WSKQkSQYlyEzPuWkJG0f5HvQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.946.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-arn-parser": "3.893.0", - "@smithy/core": "^3.18.7", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/signature-v4": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-stream": "^4.5.6", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-arn-parser": "^3.972.3", + "@smithy/core": "^3.23.11", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.19", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/middleware-ssec": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.953.0.tgz", - "integrity": "sha512-OrhG1kcQ9zZh3NS3RovR028N0+UndQ957zF1k5HPLeFLwFwQN1uPOufzzPzAyXIIKtR69ARFsQI4mstZS4DMvw==", + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.8.tgz", + "integrity": "sha512-wqlK0yO/TxEC2UsY9wIlqeeutF6jjLe0f96Pbm40XscTo57nImUk9lBcw0dPgsm0sppFtAkSlDrfpK+pC30Wqw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.953.0", - "@smithy/types": "^4.10.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/middleware-ssec/node_modules/@aws-sdk/types": { - "version": "3.953.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.953.0.tgz", - "integrity": "sha512-M9Iwg9kTyqTErI0vOTVVpcnTHWzS3VplQppy8MuL02EE+mJ0BIwpWfsaAPQW+/XnVpdNpWZTsHcNE29f1+hR8g==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/types": "^4.10.0", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.946.0.tgz", - "integrity": "sha512-7QcljCraeaWQNuqmOoAyZs8KpZcuhPiqdeeKoRd397jVGNRehLFsZbIMOvwaluUDFY11oMyXOkQEERe1Zo2fCw==", - "dev": true, + "version": "3.972.21", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.21.tgz", + "integrity": "sha512-62XRl1GDYPpkt7cx1AX1SPy9wgNE9Iw/NPuurJu4lmhCWS7sGKO+kS53TQ8eRmIxy3skmvNInnk0ZbWrU5Dpyg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.946.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@smithy/core": "^3.18.7", - "@smithy/protocol-http": "^5.3.5", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@smithy/core": "^3.23.11", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-retry": "^4.2.12", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.946.0.tgz", - "integrity": "sha512-rjAtEguukeW8mlyEQMQI56vxFoyWlaNwowmz1p1rav948SUjtrzjHAp4TOQWhibb7AR7BUTHBCgIcyCRjBEf4g==", - "dev": true, + "version": "3.996.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.10.tgz", + "integrity": "sha512-SlDol5Z+C7Ivnc2rKGqiqfSUmUZzY1qHfVs9myt/nxVwswgfpjdKahyTzLTx802Zfq0NFRs7AejwKzzzl5Co2w==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.946.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-user-agent": "3.946.0", - "@aws-sdk/region-config-resolver": "3.936.0", - "@aws-sdk/types": "3.936.0", - "@aws-sdk/util-endpoints": "3.936.0", - "@aws-sdk/util-user-agent-browser": "3.936.0", - "@aws-sdk/util-user-agent-node": "3.946.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/core": "^3.18.7", - "@smithy/fetch-http-handler": "^5.3.6", - "@smithy/hash-node": "^4.2.5", - "@smithy/invalid-dependency": "^4.2.5", - "@smithy/middleware-content-length": "^4.2.5", - "@smithy/middleware-endpoint": "^4.3.14", - "@smithy/middleware-retry": "^4.4.14", - "@smithy/middleware-serde": "^4.2.6", - "@smithy/middleware-stack": "^4.2.5", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/node-http-handler": "^4.4.5", - "@smithy/protocol-http": "^5.3.5", - "@smithy/smithy-client": "^4.9.10", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-body-length-node": "^4.2.1", - "@smithy/util-defaults-mode-browser": "^4.3.13", - "@smithy/util-defaults-mode-node": "^4.2.16", - "@smithy/util-endpoints": "^3.2.5", - "@smithy/util-middleware": "^4.2.5", - "@smithy/util-retry": "^4.2.5", - "@smithy/util-utf8": "^4.2.0", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/middleware-host-header": "^3.972.8", + "@aws-sdk/middleware-logger": "^3.972.8", + "@aws-sdk/middleware-recursion-detection": "^3.972.8", + "@aws-sdk/middleware-user-agent": "^3.972.21", + "@aws-sdk/region-config-resolver": "^3.972.8", + "@aws-sdk/types": "^3.973.6", + "@aws-sdk/util-endpoints": "^3.996.5", + "@aws-sdk/util-user-agent-browser": "^3.972.8", + "@aws-sdk/util-user-agent-node": "^3.973.7", + "@smithy/config-resolver": "^4.4.11", + "@smithy/core": "^3.23.11", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/hash-node": "^4.2.12", + "@smithy/invalid-dependency": "^4.2.12", + "@smithy/middleware-content-length": "^4.2.12", + "@smithy/middleware-endpoint": "^4.4.25", + "@smithy/middleware-retry": "^4.4.42", + "@smithy/middleware-serde": "^4.2.14", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/node-http-handler": "^4.4.16", + "@smithy/protocol-http": "^5.3.12", + "@smithy/smithy-client": "^4.12.5", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.41", + "@smithy/util-defaults-mode-node": "^4.2.44", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.936.0.tgz", - "integrity": "sha512-wOKhzzWsshXGduxO4pqSiNyL9oUtk4BEvjWm9aaq6Hmfdoydq6v6t0rAGHWPjFwy9z2haovGRi3C8IxdMB4muw==", - "dev": true, + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.8.tgz", + "integrity": "sha512-1eD4uhTDeambO/PNIDVG19A6+v4NdD7xzwLHDutHsUqz0B+i661MwQB2eYO4/crcCvCiQG4SRm1k81k54FEIvw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/config-resolver": "^4.4.3", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/types": "^4.9.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/config-resolver": "^4.4.11", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.946.0.tgz", - "integrity": "sha512-61FZ685lKiJuQ06g6U7K3PL9EwKCxNm51wNlxyKV57nnl1GrLD0NC8O3/hDNkCQLNBArT9y3IXl2H7TtIxP8Jg==", - "dev": true, + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.8.tgz", + "integrity": "sha512-n1qYFD+tbqZuyskVaxUE+t10AUz9g3qzDw3Tp6QZDKmqsjfDmZBd4GIk2EKJJNtcCBtE5YiUjDYA+3djFAFBBg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-sdk-s3": "3.946.0", - "@aws-sdk/types": "3.936.0", - "@smithy/protocol-http": "^5.3.5", - "@smithy/signature-v4": "^5.3.5", - "@smithy/types": "^4.9.0", + "@aws-sdk/middleware-sdk-s3": "^3.972.20", + "@aws-sdk/types": "^3.973.6", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.946.0.tgz", - "integrity": "sha512-a5c+rM6CUPX2ExmUZ3DlbLlS5rQr4tbdoGcgBsjnAHiYx8MuMNAI+8M7wfjF13i2yvUQj5WEIddvLpayfEZj9g==", - "dev": true, + "version": "3.1009.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1009.0.tgz", + "integrity": "sha512-KCPLuTqN9u0Rr38Arln78fRG9KXpzsPWmof+PZzfAHMMQq2QED6YjQrkrfiH7PDefLWEposY1o4/eGwrmKA4JA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.946.0", - "@aws-sdk/nested-clients": "3.946.0", - "@aws-sdk/types": "3.936.0", - "@smithy/property-provider": "^4.2.5", - "@smithy/shared-ini-file-loader": "^4.4.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/core": "^3.973.20", + "@aws-sdk/nested-clients": "^3.996.10", + "@aws-sdk/types": "^3.973.6", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/types": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.936.0.tgz", - "integrity": "sha512-uz0/VlMd2pP5MepdrHizd+T+OKfyK4r3OA9JI+L/lPKg0YFQosdJNCKisr6o70E3dh8iMpFYxF1UN/4uZsyARg==", + "version": "3.973.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.6.tgz", + "integrity": "sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/util-arn-parser": { - "version": "3.893.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.893.0.tgz", - "integrity": "sha512-u8H4f2Zsi19DGnwj5FSZzDMhytYF/bCh37vAtBsn3cNDL3YG578X5oc+wSX54pM3tOxS+NY7tvOAo52SW7koUA==", - "dev": true, + "version": "3.972.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.972.3.tgz", + "integrity": "sha512-HzSD8PMFrvgi2Kserxuff5VitNq2sgf3w9qxmskKDiDTThWfVteJxuCS9JXiPIPtmCrp+7N9asfIaVhBFORllA==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.936.0.tgz", - "integrity": "sha512-0Zx3Ntdpu+z9Wlm7JKUBOzS9EunwKAb4KdGUQQxDqh5Lc3ta5uBoub+FgmVuzwnmBu9U1Os8UuwVTH0Lgu+P5w==", - "dev": true, + "version": "3.996.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.5.tgz", + "integrity": "sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/types": "^4.9.0", - "@smithy/url-parser": "^4.2.5", - "@smithy/util-endpoints": "^3.2.5", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-endpoints": "^3.3.3", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws-sdk/util-locate-window": { @@ -1717,33 +968,32 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.936.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.936.0.tgz", - "integrity": "sha512-eZ/XF6NxMtu+iCma58GRNRxSq4lHo6zHQLOZRIeL/ghqYJirqHdenMOwrzPettj60KWlv827RVebP9oNVrwZbw==", - "dev": true, + "version": "3.972.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.8.tgz", + "integrity": "sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.936.0", - "@smithy/types": "^4.9.0", + "@aws-sdk/types": "^3.973.6", + "@smithy/types": "^4.13.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.946.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.946.0.tgz", - "integrity": "sha512-a2UwwvzbK5AxHKUBupfg4s7VnkqRAHjYsuezHnKCniczmT4HZfP1NnfwwvLKEH8qaTrwenxjKSfq4UWmWkvG+Q==", - "dev": true, + "version": "3.973.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.7.tgz", + "integrity": "sha512-Hz6EZMUAEzqUd7e+vZ9LE7mn+5gMbxltXy18v+YSFY+9LBJz15wkNZvw5JqfX3z0FS9n3bgUtz3L5rAsfh4YlA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "3.946.0", - "@aws-sdk/types": "3.936.0", - "@smithy/node-config-provider": "^4.3.5", - "@smithy/types": "^4.9.0", + "@aws-sdk/middleware-user-agent": "^3.972.21", + "@aws-sdk/types": "^3.973.6", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "aws-crt": ">=1.0.0" @@ -1755,36 +1005,36 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.930.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.930.0.tgz", - "integrity": "sha512-YIfkD17GocxdmlUVc3ia52QhcWuRIUJonbF8A2CYfcWNV3HzvAqpcPeC0bYUhkK+8e8YO1ARnLKZQE0TlwzorA==", - "dev": true, + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.12.tgz", + "integrity": "sha512-xjyucfn+F+kMf25c+LIUnvX3oyLSlj9T0Vncs5WMQI6G36JdnSwC8g0qf8RajfmSClXr660EpTz7FFKluZ4BqQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.9.0", - "fast-xml-parser": "5.2.5", + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.6", "tslib": "^2.6.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@aws/lambda-invoke-store": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.2.tgz", - "integrity": "sha512-C0NBLsIqzDIae8HFw9YIrIBsbc0xTiOtt7fAukGPnqQ/+zZNaq+4jhuccltK0QuWHBnNm/a6kLIRA6GFiM10eg==", + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" } }, "node_modules/@babel/code-frame": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", - "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -1793,31 +1043,32 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", - "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.10.tgz", - "integrity": "sha512-vMqyb7XCDMPvJFFOaT9kxtiRh42GwlZEg1/uIgtZshS5a/8OaduUfCi7kynKgc3Tw/6Uo2D+db9qBttghhmxwQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.26.10", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helpers": "^7.26.10", - "@babel/parser": "^7.26.10", - "@babel/template": "^7.26.9", - "@babel/traverse": "^7.26.10", - "@babel/types": "^7.26.10", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -1832,23 +1083,60 @@ "url": "https://opencollective.com/babel" } }, + "node_modules/@babel/core/node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/core/node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/core/node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" } }, "node_modules/@babel/generator": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", - "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.28.5", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -1858,12 +1146,13 @@ } }, "node_modules/@babel/generator/node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -1873,12 +1162,13 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", - "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.27.2", + "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", @@ -1892,6 +1182,7 @@ "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -1901,31 +1192,34 @@ "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", - "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.27.1", - "@babel/types": "^7.27.1" + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports/node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -1935,17 +1229,18 @@ } }, "node_modules/@babel/helper-module-imports/node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -1953,14 +1248,15 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", - "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.28.3" + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -1970,12 +1266,13 @@ } }, "node_modules/@babel/helper-module-transforms/node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -1985,17 +1282,18 @@ } }, "node_modules/@babel/helper-module-transforms/node_modules/@babel/traverse": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", - "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.28.5", + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.28.5", - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.5", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", "debug": "^4.3.1" }, "engines": { @@ -2006,6 +1304,7 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2015,6 +1314,7 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2024,19 +1324,21 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", - "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.2", - "@babel/types": "^7.28.4" + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" }, "engines": { "node": ">=6.9.0" @@ -2046,6 +1348,7 @@ "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.27.0" @@ -2057,27 +1360,38 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", - "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.27.1", - "@babel/parser": "^7.27.2", - "@babel/types": "^7.27.1" + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template/node_modules/@babel/parser": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", - "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.28.5" + "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -2090,6 +1404,7 @@ "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz", "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.26.2", @@ -2105,9 +1420,10 @@ } }, "node_modules/@babel/types": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", - "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "devOptional": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -2144,9 +1460,9 @@ "license": "MIT" }, "node_modules/@dotenvx/dotenvx": { - "version": "1.51.2", - "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.51.2.tgz", - "integrity": "sha512-+693mNflujDZxudSEqSNGpn92QgFhJlBn9q2mDQ9yGWyHuz3hZ8B5g3EXCwdAz4DMJAI+OFCIbfEFZS+YRdrEA==", + "version": "1.54.1", + "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.54.1.tgz", + "integrity": "sha512-41gU3q7v05GM92QPuPUf4CmUw+mmF8p4wLUh6MCRlxpCkJ9ByLcY9jUf6MwrMNmiKyG/rIckNxj9SCfmNCmCqw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -2232,418 +1548,6 @@ "source-map-support": "^0.5.21" } }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.18.20.tgz", - "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.18.20.tgz", - "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/android-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.18.20.tgz", - "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.18.20.tgz", - "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/darwin-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.18.20.tgz", - "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.18.20.tgz", - "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/freebsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.18.20.tgz", - "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.18.20.tgz", - "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.18.20.tgz", - "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.18.20.tgz", - "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-loong64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.18.20.tgz", - "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-mips64el": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.18.20.tgz", - "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-ppc64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.18.20.tgz", - "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-riscv64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.18.20.tgz", - "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-s390x": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.18.20.tgz", - "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/linux-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.18.20.tgz", - "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/netbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.18.20.tgz", - "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/openbsd-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.18.20.tgz", - "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/sunos-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.18.20.tgz", - "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-arm64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.18.20.tgz", - "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-ia32": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.18.20.tgz", - "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/@esbuild/win32-x64": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.18.20.tgz", - "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild-kit/core-utils/node_modules/esbuild": { - "version": "0.18.20", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.18.20.tgz", - "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/android-arm": "0.18.20", - "@esbuild/android-arm64": "0.18.20", - "@esbuild/android-x64": "0.18.20", - "@esbuild/darwin-arm64": "0.18.20", - "@esbuild/darwin-x64": "0.18.20", - "@esbuild/freebsd-arm64": "0.18.20", - "@esbuild/freebsd-x64": "0.18.20", - "@esbuild/linux-arm": "0.18.20", - "@esbuild/linux-arm64": "0.18.20", - "@esbuild/linux-ia32": "0.18.20", - "@esbuild/linux-loong64": "0.18.20", - "@esbuild/linux-mips64el": "0.18.20", - "@esbuild/linux-ppc64": "0.18.20", - "@esbuild/linux-riscv64": "0.18.20", - "@esbuild/linux-s390x": "0.18.20", - "@esbuild/linux-x64": "0.18.20", - "@esbuild/netbsd-x64": "0.18.20", - "@esbuild/openbsd-x64": "0.18.20", - "@esbuild/sunos-x64": "0.18.20", - "@esbuild/win32-arm64": "0.18.20", - "@esbuild/win32-ia32": "0.18.20", - "@esbuild/win32-x64": "0.18.20" - } - }, "node_modules/@esbuild-kit/esm-loader": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@esbuild-kit/esm-loader/-/esm-loader-2.6.5.tgz", @@ -2673,9 +1577,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", - "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", "cpu": [ "ppc64" ], @@ -2690,9 +1594,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", - "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", "cpu": [ "arm" ], @@ -2707,9 +1611,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", - "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", "cpu": [ "arm64" ], @@ -2724,9 +1628,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", - "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", "cpu": [ "x64" ], @@ -2741,9 +1645,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", - "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", "cpu": [ "arm64" ], @@ -2758,9 +1662,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", - "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", "cpu": [ "x64" ], @@ -2775,9 +1679,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", - "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", "cpu": [ "arm64" ], @@ -2792,9 +1696,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", - "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", "cpu": [ "x64" ], @@ -2809,9 +1713,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", - "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", "cpu": [ "arm" ], @@ -2826,9 +1730,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", - "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", "cpu": [ "arm64" ], @@ -2843,9 +1747,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", - "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", "cpu": [ "ia32" ], @@ -2860,9 +1764,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", - "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", "cpu": [ "loong64" ], @@ -2877,9 +1781,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", - "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", "cpu": [ "mips64el" ], @@ -2894,9 +1798,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", - "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", "cpu": [ "ppc64" ], @@ -2911,9 +1815,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", - "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", "cpu": [ "riscv64" ], @@ -2928,9 +1832,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", - "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", "cpu": [ "s390x" ], @@ -2945,9 +1849,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", - "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", "cpu": [ "x64" ], @@ -2962,9 +1866,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", - "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", "cpu": [ "arm64" ], @@ -2979,9 +1883,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", - "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", "cpu": [ "x64" ], @@ -2996,9 +1900,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", - "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", "cpu": [ "arm64" ], @@ -3013,9 +1917,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", - "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", "cpu": [ "x64" ], @@ -3030,9 +1934,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", - "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", "cpu": [ "arm64" ], @@ -3047,9 +1951,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", - "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", "cpu": [ "x64" ], @@ -3064,9 +1968,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", - "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", "cpu": [ "arm64" ], @@ -3081,9 +1985,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", - "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", "cpu": [ "ia32" ], @@ -3098,9 +2002,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", - "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", "cpu": [ "x64" ], @@ -3115,9 +2019,10 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", - "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, "license": "MIT", "dependencies": { "eslint-visitor-keys": "^3.4.3" @@ -3136,6 +2041,7 @@ "version": "3.4.3", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -3148,122 +2054,81 @@ "version": "4.12.2", "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, "license": "MIT", "engines": { "node": "^12.0.0 || ^14.0.0 || >=16.0.0" } }, "node_modules/@eslint/config-array": { - "version": "0.21.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", - "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "version": "0.23.3", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", + "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.7", + "@eslint/object-schema": "^3.0.3", "debug": "^4.3.1", - "minimatch": "^3.1.2" + "minimatch": "^10.2.4" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", + "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^1.1.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", + "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", - "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", - "license": "MIT", - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.1", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/object-schema": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", - "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", + "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", + "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^1.1.1", "levn": "^0.4.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@faker-js/faker": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.1.0.tgz", - "integrity": "sha512-C3mrr3b5dRVlKPJdfrAXS8+dq+rq8Qm5SNRazca0JKgw1HQERFmrVb0towvMmw5uu8hHKNiQasMaR/tydf3Zsg==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-10.3.0.tgz", + "integrity": "sha512-It0Sne6P3szg7JIi6CgKbvTZoMjxBZhcv91ZrqrNuaZQfB5WoqYYbzCUOq89YR+VY8juY9M1vDWmDDa2TzfXCw==", "funding": [ { "type": "opencollective", @@ -3330,63 +2195,55 @@ "license": "MIT" }, "node_modules/@formatjs/ecma402-abstract": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-2.3.6.tgz", - "integrity": "sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@formatjs/ecma402-abstract/-/ecma402-abstract-3.1.1.tgz", + "integrity": "sha512-jhZbTwda+2tcNrs4kKvxrPLPjx8QsBCLCUgrrJ/S+G9YrGHWLhAyFMMBHJBnBoOwuLHd7L14FgYudviKaxkO2Q==", "license": "MIT", "dependencies": { - "@formatjs/fast-memoize": "2.2.7", - "@formatjs/intl-localematcher": "0.6.2", - "decimal.js": "^10.4.3", - "tslib": "^2.8.0" - } - }, - "node_modules/@formatjs/ecma402-abstract/node_modules/@formatjs/intl-localematcher": { - "version": "0.6.2", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.6.2.tgz", - "integrity": "sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==", - "license": "MIT", - "dependencies": { - "tslib": "^2.8.0" + "@formatjs/fast-memoize": "3.1.0", + "@formatjs/intl-localematcher": "0.8.1", + "decimal.js": "^10.6.0", + "tslib": "^2.8.1" } }, "node_modules/@formatjs/fast-memoize": { - "version": "2.2.7", - "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-2.2.7.tgz", - "integrity": "sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.0.tgz", + "integrity": "sha512-b5mvSWCI+XVKiz5WhnBCY3RJ4ZwfjAidU0yVlKa3d3MSgKmH1hC3tBGEAtYyN5mqL7N0G5x0BOUYyO8CEupWgg==", "license": "MIT", "dependencies": { - "tslib": "^2.8.0" + "tslib": "^2.8.1" } }, "node_modules/@formatjs/icu-messageformat-parser": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.11.4.tgz", - "integrity": "sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==", + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-3.5.1.tgz", + "integrity": "sha512-sSDmSvmmoVQ92XqWb499KrIhv/vLisJU8ITFrx7T7NZHUmMY7EL9xgRowAosaljhqnj/5iufG24QrdzB6X3ItA==", "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "@formatjs/icu-skeleton-parser": "1.8.16", - "tslib": "^2.8.0" + "@formatjs/ecma402-abstract": "3.1.1", + "@formatjs/icu-skeleton-parser": "2.1.1", + "tslib": "^2.8.1" } }, "node_modules/@formatjs/icu-skeleton-parser": { - "version": "1.8.16", - "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.8.16.tgz", - "integrity": "sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-2.1.1.tgz", + "integrity": "sha512-PSFABlcNefjI6yyk8f7nyX1DC7NHmq6WaCHZLySEXBrXuLOB2f935YsnzuPjlz+ibhb9yWTdPeVX1OVcj24w2Q==", "license": "MIT", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "tslib": "^2.8.0" + "@formatjs/ecma402-abstract": "3.1.1", + "tslib": "^2.8.1" } }, "node_modules/@formatjs/intl-localematcher": { - "version": "0.5.10", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.5.10.tgz", - "integrity": "sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==", + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.1.tgz", + "integrity": "sha512-xwEuwQFdtSq1UKtQnyTZWC+eHdv7Uygoa+H2k/9uzBVQjDyp9r20LNDNKedWXll7FssT3GRHvqsdJGYSUWqYFA==", "license": "MIT", "dependencies": { - "tslib": "2" + "@formatjs/fast-memoize": "3.1.0", + "tslib": "^2.8.1" } }, "node_modules/@headlessui/react": { @@ -3431,6 +2288,7 @@ "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.18.0" @@ -3440,6 +2298,7 @@ "version": "0.16.7", "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", @@ -3453,6 +2312,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=12.22" @@ -3466,6 +2326,7 @@ "version": "0.4.3", "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">=18.18" @@ -3904,54 +2765,16 @@ } }, "node_modules/@ioredis/commands": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.4.0.tgz", - "integrity": "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", "license": "MIT" }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -3973,6 +2796,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -3982,12 +2806,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -4036,28 +2862,27 @@ } }, "node_modules/@next/env": { - "version": "15.5.9", - "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz", - "integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==", - "license": "MIT" + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.12.tgz", + "integrity": "sha512-pUvdJN1on574wQHjaBfNGDt9Mz5utDSZFsIIQkMzPgNS8ZvT4H2mwOrOIClwsQOb6EGx5M76/CZr6G8i6pSpLg==" }, "node_modules/@next/eslint-plugin-next": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.0.tgz", - "integrity": "sha512-sooC/k0LCF4/jLXYHpgfzJot04lZQqsttn8XJpTguP8N3GhqXN3wSkh68no2OcZzS/qeGwKDFTqhZ8WofdXmmQ==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.1.7.tgz", + "integrity": "sha512-v/bRGOJlfRCO+NDKt0bZlIIWjhMKU8xbgEQBo+rV9C8S6czZvs96LZ/v24/GvpEnovZlL4QDpku/RzWHVbmPpA==", + "dev": true, "license": "MIT", "dependencies": { "fast-glob": "3.3.1" } }, "node_modules/@next/swc-darwin-arm64": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.7.tgz", - "integrity": "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.12.tgz", + "integrity": "sha512-RnRjBtH8S8eXCpUNkQ+543DUc7ys8y15VxmFU9HRqlo9BG3CcBUiwNtF8SNoi2xvGCVJq1vl2yYq+3oISBS0Zg==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -4067,13 +2892,12 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.7.tgz", - "integrity": "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.12.tgz", + "integrity": "sha512-nqa9/7iQlboF1EFtNhWxQA0rQstmYRSBGxSM6g3GxvxHxcoeqVXfGNr9stJOme674m2V7r4E3+jEhhGvSQhJRA==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "darwin" @@ -4083,13 +2907,12 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.7.tgz", - "integrity": "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.12.tgz", + "integrity": "sha512-dCzAjqhDHwmoB2M4eYfVKqXs99QdQxNQVpftvP1eGVppamXh/OkDAwV737Zr0KPXEqRUMN4uCjh6mjO+XtF3Mw==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -4099,13 +2922,12 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.7.tgz", - "integrity": "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.12.tgz", + "integrity": "sha512-+fpGWvQiITgf7PUtbWY1H7qUSnBZsPPLyyq03QuAKpVoTy/QUx1JptEDTQMVvQhvizCEuNLEeghrQUyXQOekuw==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -4115,13 +2937,12 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.7.tgz", - "integrity": "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.12.tgz", + "integrity": "sha512-jSLvgdRRL/hrFAPqEjJf1fFguC719kmcptjNVDJl26BnJIpjL3KH5h6mzR4mAweociLQaqvt4UyzfbFjgAdDcw==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -4131,13 +2952,12 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.7.tgz", - "integrity": "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.12.tgz", + "integrity": "sha512-/uaF0WfmYqQgLfPmN6BvULwxY0dufI2mlN2JbOKqqceZh1G4hjREyi7pg03zjfyS6eqNemHAZPSoP84x17vo6w==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "linux" @@ -4147,13 +2967,12 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.7.tgz", - "integrity": "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.12.tgz", + "integrity": "sha512-xhsL1OvQSfGmlL5RbOmU+FV120urrgFpYLq+6U8C6KIym32gZT6XF/SDE92jKzzlPWskkbjOKCpqk5m4i8PEfg==", "cpu": [ "arm64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -4163,13 +2982,12 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "15.5.7", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.7.tgz", - "integrity": "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==", + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.12.tgz", + "integrity": "sha512-Z1Dh6lhFkxvBDH1FoW6OU/L6prYwPSlwjLiZkExIAh8fbP6iI/M7iGTQAJPYJ9YFlWobCZ1PHbchFhFYb2ADkw==", "cpu": [ "x64" ], - "license": "MIT", "optional": true, "os": [ "win32" @@ -4184,7 +3002,6 @@ "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^14.21.3 || >=16" }, @@ -4760,6 +3577,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -4773,6 +3591,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -4782,6 +3601,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -4795,6 +3615,7 @@ "version": "1.0.39", "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.4.0" @@ -5165,92 +3986,92 @@ } }, "node_modules/@peculiar/asn1-cms": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.0.tgz", - "integrity": "sha512-2uZqP+ggSncESeUF/9Su8rWqGclEfEiz1SyU02WX5fUONFfkjzS2Z/F1Li0ofSmf4JqYXIOdCAZqIXAIBAT1OA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.6.1.tgz", + "integrity": "sha512-vdG4fBF6Lkirkcl53q6eOdn3XYKt+kJTG59edgRZORlg/3atWWEReRCx5rYE1ZzTTX6vLK5zDMjHh7vbrcXGtw==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "@peculiar/asn1-x509-attr": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-csr": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.0.tgz", - "integrity": "sha512-BeWIu5VpTIhfRysfEp73SGbwjjoLL/JWXhJ/9mo4vXnz3tRGm+NGm3KNcRzQ9VMVqwYS2RHlolz21svzRXIHPQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.6.1.tgz", + "integrity": "sha512-WRWnKfIocHyzFYQTka8O/tXCiBquAPSrRjXbOkHbO4qdmS6loffCEGs+rby6WxxGdJCuunnhS2duHURhjyio6w==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-ecc": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.0.tgz", - "integrity": "sha512-FF3LMGq6SfAOwUG2sKpPXblibn6XnEIKa+SryvUl5Pik+WR9rmRA3OCiwz8R3lVXnYnyRkSZsSLdml8H3UiOcw==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.6.1.tgz", + "integrity": "sha512-+Vqw8WFxrtDIN5ehUdvlN2m73exS2JVG0UAyfVB31gIfor3zWEAQPD+K9ydCxaj3MLen9k0JhKpu9LqviuCE1g==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pfx": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.0.tgz", - "integrity": "sha512-rtUvtf+tyKGgokHHmZzeUojRZJYPxoD/jaN1+VAB4kKR7tXrnDCA/RAWXAIhMJJC+7W27IIRGe9djvxKgsldCQ==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.6.1.tgz", + "integrity": "sha512-nB5jVQy3MAAWvq0KY0R2JUZG8bO/bTLpnwyOzXyEh/e54ynGTatAR+csOnXkkVD9AFZ2uL8Z7EV918+qB1qDvw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.6.0", - "@peculiar/asn1-pkcs8": "^2.6.0", - "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pkcs8": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.0.tgz", - "integrity": "sha512-KyQ4D8G/NrS7Fw3XCJrngxmjwO/3htnA0lL9gDICvEQ+GJ+EPFqldcJQTwPIdvx98Tua+WjkdKHSC0/Km7T+lA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.6.1.tgz", + "integrity": "sha512-JB5iQ9Izn5yGMw3ZG4Nw3Xn/hb/G38GYF3lf7WmJb8JZUydhVGEjK/ZlFSWhnlB7K/4oqEs8HnfFIKklhR58Tw==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-pkcs9": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.0.tgz", - "integrity": "sha512-b78OQ6OciW0aqZxdzliXGYHASeCvvw5caqidbpQRYW2mBtXIX2WhofNXTEe7NyxTb0P6J62kAAWLwn0HuMF1Fw==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.6.1.tgz", + "integrity": "sha512-5EV8nZoMSxeWmcxWmmcolg22ojZRgJg+Y9MX2fnE2bGRo5KQLqV5IL9kdSQDZxlHz95tHvIq9F//bvL1OeNILw==", "license": "MIT", "dependencies": { - "@peculiar/asn1-cms": "^2.6.0", - "@peculiar/asn1-pfx": "^2.6.0", - "@peculiar/asn1-pkcs8": "^2.6.0", + "@peculiar/asn1-cms": "^2.6.1", + "@peculiar/asn1-pfx": "^2.6.1", + "@peculiar/asn1-pkcs8": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", - "@peculiar/asn1-x509-attr": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/asn1-x509-attr": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/asn1-rsa": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.0.tgz", - "integrity": "sha512-Nu4C19tsrTsCp9fDrH+sdcOKoVfdfoQQ7S3VqjJU6vedR7tY3RLkQ5oguOIB3zFW33USDUuYZnPEQYySlgha4w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.6.1.tgz", + "integrity": "sha512-1nVMEh46SElUt5CB3RUTV4EG/z7iYc7EoaDY5ECwganibQPkZ/Y2eMsTKB/LeyrUJ+W/tKoD9WUqIy8vB+CEdA==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } @@ -5267,9 +4088,9 @@ } }, "node_modules/@peculiar/asn1-x509": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.0.tgz", - "integrity": "sha512-uzYbPEpoQiBoTq0/+jZtpM6Gq6zADBx+JNFP3yqRgziWBxQ/Dt/HcuvRfm9zJTPdRcBqPNdaRHTVwpyiq6iNMA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.6.1.tgz", + "integrity": "sha512-O9jT5F1A2+t3r7C4VT7LYGXqkGLK7Kj1xFpz7U0isPrubwU5PbDoyYtx6MiGst29yq7pXN5vZbQFKRCP+lLZlA==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", @@ -5279,21 +4100,21 @@ } }, "node_modules/@peculiar/asn1-x509-attr": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.0.tgz", - "integrity": "sha512-MuIAXFX3/dc8gmoZBkwJWxUWOSvG4MMDntXhrOZpJVMkYX+MYc/rUAU2uJOved9iJEoiUx7//3D8oG83a78UJA==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.6.1.tgz", + "integrity": "sha512-tlW6cxoHwgcQghnJwv3YS+9OO1737zgPogZ+CgWRUK4roEwIPzRH4JEiG770xe5HX2ATfCpmX60gurfWIF9dcQ==", "license": "MIT", "dependencies": { "@peculiar/asn1-schema": "^2.6.0", - "@peculiar/asn1-x509": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "node_modules/@peculiar/x509": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.2.tgz", - "integrity": "sha512-r2w1Hg6pODDs0zfAKHkSS5HLkOLSeburtcgwvlLLWWCixw+MmW3U6kD5ddyvc2Y2YdbGuVwCF2S2ASoU1cFAag==", + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", "license": "MIT", "dependencies": { "@peculiar/asn1-cms": "^2.6.0", @@ -5309,13 +4130,13 @@ "tsyringe": "^4.10.0" }, "engines": { - "node": ">=22.0.0" + "node": ">=20.0.0" } }, "node_modules/@posthog/core": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.8.1.tgz", - "integrity": "sha512-jfzBtQIk9auRi/biO+G/gumK5KxqsD5wOr7XpYMROE/I3pazjP4zIziinp21iQuIQJMXrDvwt9Af3njgOGwtew==", + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.23.2.tgz", + "integrity": "sha512-zTDdda9NuSHrnwSOfFMxX/pyXiycF4jtU1kTr8DL61dHhV+7LF6XF1ndRZZTuaGGbfbb/GJYkEsjEX9SXfNZeQ==", "license": "MIT", "dependencies": { "cross-spawn": "^7.0.6" @@ -7557,91 +6378,94 @@ } }, "node_modules/@react-email/body": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.2.0.tgz", - "integrity": "sha512-9GCWmVmKUAoRfloboCd+RKm6X17xn7eGL7HnpAZUnjBXBilWCxsKnLMTC/ixSHDKS/A/057M1Tx6ZUXd89sVBw==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@react-email/body/-/body-0.2.1.tgz", + "integrity": "sha512-ljDiQiJDu/Fq//vSIIP0z5Nuvt4+DX1RqGasstChDGJB/14ogd4VdNS9aacoede/ZjGy3o3Qb+cxyS+XgM6SwQ==", "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/button": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.2.0.tgz", - "integrity": "sha512-8i+v6cMxr2emz4ihCrRiYJPp2/sdYsNNsBzXStlcA+/B9Umpm5Jj3WJKYpgTPM+aeyiqlG/MMI1AucnBm4f1oQ==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@react-email/button/-/button-0.2.1.tgz", + "integrity": "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/code-block": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.2.0.tgz", - "integrity": "sha512-eIrPW9PIFgDopQU0e/OPpwCW2QWQDtNZDSsiN4sJO8KdMnWWnXJicnRfzrit5rHwFo+Y98i+w/Y5ScnBAFr1dQ==", + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@react-email/code-block/-/code-block-0.2.1.tgz", + "integrity": "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw==", "license": "MIT", "dependencies": { "prismjs": "^1.30.0" }, "engines": { - "node": ">=22.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/code-inline": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.5.tgz", - "integrity": "sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA==", + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@react-email/code-inline/-/code-inline-0.0.6.tgz", + "integrity": "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/column": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.13.tgz", - "integrity": "sha512-Lqq17l7ShzJG/d3b1w/+lVO+gp2FM05ZUo/nW0rjxB8xBICXOVv6PqjDnn3FXKssvhO5qAV20lHM6S+spRhEwQ==", + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/column/-/column-0.0.14.tgz", + "integrity": "sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/components": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@react-email/components/-/components-1.0.2.tgz", - "integrity": "sha512-VKQR/motrySQMvy+ZUwPjdeD9iI9mCt8cfXuJAX8cK16rtzkEe12yq6/pXyW7c6qEMj7d+PNsoAcO+3AbJSfPg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@react-email/components/-/components-1.0.8.tgz", + "integrity": "sha512-zY81ED6o5MWMzBkr9uZFuT24lWarT+xIbOZxI6C9dsFmCWBczM8IE1BgOI8rhpUK4JcYVDy1uKxYAFqsx2Bc4w==", "license": "MIT", "dependencies": { - "@react-email/body": "0.2.0", - "@react-email/button": "0.2.0", - "@react-email/code-block": "0.2.0", - "@react-email/code-inline": "0.0.5", - "@react-email/column": "0.0.13", - "@react-email/container": "0.0.15", - "@react-email/font": "0.0.9", - "@react-email/head": "0.0.12", - "@react-email/heading": "0.0.15", - "@react-email/hr": "0.0.11", - "@react-email/html": "0.0.11", - "@react-email/img": "0.0.11", - "@react-email/link": "0.0.12", - "@react-email/markdown": "0.0.17", - "@react-email/preview": "0.0.13", - "@react-email/render": "2.0.0", - "@react-email/row": "0.0.12", - "@react-email/section": "0.0.16", - "@react-email/tailwind": "2.0.2", - "@react-email/text": "0.1.5" + "@react-email/body": "0.2.1", + "@react-email/button": "0.2.1", + "@react-email/code-block": "0.2.1", + "@react-email/code-inline": "0.0.6", + "@react-email/column": "0.0.14", + "@react-email/container": "0.0.16", + "@react-email/font": "0.0.10", + "@react-email/head": "0.0.13", + "@react-email/heading": "0.0.16", + "@react-email/hr": "0.0.12", + "@react-email/html": "0.0.12", + "@react-email/img": "0.0.12", + "@react-email/link": "0.0.13", + "@react-email/markdown": "0.0.18", + "@react-email/preview": "0.0.14", + "@react-email/render": "2.0.4", + "@react-email/row": "0.0.13", + "@react-email/section": "0.0.17", + "@react-email/tailwind": "2.0.5", + "@react-email/text": "0.1.6" }, "engines": { "node": ">=20.0.0" @@ -7651,136 +6475,386 @@ } }, "node_modules/@react-email/container": { - "version": "0.0.15", - "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.15.tgz", - "integrity": "sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg==", + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@react-email/container/-/container-0.0.16.tgz", + "integrity": "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/font": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.9.tgz", - "integrity": "sha512-4zjq23oT9APXkerqeslPH3OZWuh5X4crHK6nx82mVHV2SrLba8+8dPEnWbaACWTNjOCbcLIzaC9unk7Wq2MIXw==", + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@react-email/font/-/font-0.0.10.tgz", + "integrity": "sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA==", "license": "MIT", + "engines": { + "node": ">=20.0.0" + }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/head": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.12.tgz", - "integrity": "sha512-X2Ii6dDFMF+D4niNwMAHbTkeCjlYYnMsd7edXOsi0JByxt9wNyZ9EnhFiBoQdqkE+SMDcu8TlNNttMrf5sJeMA==", + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@react-email/head/-/head-0.0.13.tgz", + "integrity": "sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/heading": { - "version": "0.0.15", - "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.15.tgz", - "integrity": "sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg==", + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@react-email/heading/-/heading-0.0.16.tgz", + "integrity": "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/hr": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.11.tgz", - "integrity": "sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/hr/-/hr-0.0.12.tgz", + "integrity": "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/html": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.11.tgz", - "integrity": "sha512-qJhbOQy5VW5qzU74AimjAR9FRFQfrMa7dn4gkEXKMB/S9xZN8e1yC1uA9C15jkXI/PzmJ0muDIWmFwatm5/+VA==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/html/-/html-0.0.12.tgz", + "integrity": "sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/img": { - "version": "0.0.11", - "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.11.tgz", - "integrity": "sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ==", + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/@react-email/img/-/img-0.0.12.tgz", + "integrity": "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/link": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.12.tgz", - "integrity": "sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ==", + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@react-email/link/-/link-0.0.13.tgz", + "integrity": "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/markdown": { - "version": "0.0.17", - "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.17.tgz", - "integrity": "sha512-6op3AfsBC9BJKkhG+eoMFRFWlr0/f3FYbtQrK+VhGzJocEAY0WINIFN+W8xzXr//3IL0K/aKtnH3FtpIuescQQ==", + "version": "0.0.18", + "resolved": "https://registry.npmjs.org/@react-email/markdown/-/markdown-0.0.18.tgz", + "integrity": "sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg==", "license": "MIT", "dependencies": { "marked": "^15.0.12" }, "engines": { - "node": ">=22.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/preview": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.13.tgz", - "integrity": "sha512-F7j9FJ0JN/A4d7yr+aw28p4uX7VLWs7hTHtLo7WRyw4G+Lit6Zucq4UWKRxJC8lpsUdzVmG7aBJnKOT+urqs/w==", + "version": "0.0.14", + "resolved": "https://registry.npmjs.org/@react-email/preview/-/preview-0.0.14.tgz", + "integrity": "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, + "node_modules/@react-email/preview-server": { + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/@react-email/preview-server/-/preview-server-5.2.10.tgz", + "integrity": "sha512-cYi21KF+Z/HGXT8RpkQMNFFubBafxyoB9Hn/wrslfDNtdoews2MdsDo6XXKkZvDTRG9SxQN3HGk4v4aoQZc20g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "0.27.3", + "next": "16.1.7" + } + }, + "node_modules/@react-email/preview-server/node_modules/@next/env": { + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.7.tgz", + "integrity": "sha512-rJJbIdJB/RQr2F1nylZr/PJzamvNNhfr3brdKP6s/GW850jbtR70QlSfFselvIBbcPUOlQwBakexjFzqLzF6pg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@react-email/preview-server/node_modules/@next/swc-darwin-arm64": { + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.7.tgz", + "integrity": "sha512-b2wWIE8sABdyafc4IM8r5Y/dS6kD80JRtOGrUiKTsACFQfWWgUQ2NwoUX1yjFMXVsAwcQeNpnucF2ZrujsBBPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-email/preview-server/node_modules/@next/swc-darwin-x64": { + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.7.tgz", + "integrity": "sha512-zcnVaaZulS1WL0Ss38R5Q6D2gz7MtBu8GZLPfK+73D/hp4GFMrC2sudLky1QibfV7h6RJBJs/gOFvYP0X7UVlQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-email/preview-server/node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.7.tgz", + "integrity": "sha512-2ant89Lux/Q3VyC8vNVg7uBaFVP9SwoK2jJOOR0L8TQnX8CAYnh4uctAScy2Hwj2dgjVHqHLORQZJ2wH6VxhSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-email/preview-server/node_modules/@next/swc-linux-arm64-musl": { + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.7.tgz", + "integrity": "sha512-uufcze7LYv0FQg9GnNeZ3/whYfo+1Q3HnQpm16o6Uyi0OVzLlk2ZWoY7j07KADZFY8qwDbsmFnMQP3p3+Ftprw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-email/preview-server/node_modules/@next/swc-linux-x64-gnu": { + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.7.tgz", + "integrity": "sha512-KWVf2gxYvHtvuT+c4MBOGxuse5TD7DsMFYSxVxRBnOzok/xryNeQSjXgxSv9QpIVlaGzEn/pIuI6Koosx8CGWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-email/preview-server/node_modules/@next/swc-linux-x64-musl": { + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.7.tgz", + "integrity": "sha512-HguhaGwsGr1YAGs68uRKc4aGWxLET+NevJskOcCAwXbwj0fYX0RgZW2gsOCzr9S11CSQPIkxmoSbuVaBp4Z3dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-email/preview-server/node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.7.tgz", + "integrity": "sha512-S0n3KrDJokKTeFyM/vGGGR8+pCmXYrjNTk2ZozOL1C/JFdfUIL9O1ATaJOl5r2POe56iRChbsszrjMAdWSv7kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-email/preview-server/node_modules/@next/swc-win32-x64-msvc": { + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.7.tgz", + "integrity": "sha512-mwgtg8CNZGYm06LeEd+bNnOUfwOyNem/rOiP14Lsz+AnUY92Zq/LXwtebtUiaeVkhbroRCQ0c8GlR4UT1U+0yg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@react-email/preview-server/node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@react-email/preview-server/node_modules/next": { + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/next/-/next-16.1.7.tgz", + "integrity": "sha512-WM0L7WrSvKwoLegLYr6V+mz+RIofqQgVAfHhMp9a88ms0cFX8iX9ew+snpWlSBwpkURJOUdvCEt3uLl3NNzvWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@next/env": "16.1.7", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.1.7", + "@next/swc-darwin-x64": "16.1.7", + "@next/swc-linux-arm64-gnu": "16.1.7", + "@next/swc-linux-arm64-musl": "16.1.7", + "@next/swc-linux-x64-gnu": "16.1.7", + "@next/swc-linux-x64-musl": "16.1.7", + "@next/swc-win32-arm64-msvc": "16.1.7", + "@next/swc-win32-x64-msvc": "16.1.7", + "sharp": "^0.34.4" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/@react-email/preview-server/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/@react-email/render": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.0.tgz", - "integrity": "sha512-rdjNj6iVzv8kRKDPFas+47nnoe6B40+nwukuXwY4FCwM7XBg6tmYr+chQryCuavUj2J65MMf6fztk1bxOUiSVA==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@react-email/render/-/render-2.0.4.tgz", + "integrity": "sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g==", "license": "MIT", "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "engines": { - "node": ">=22.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", @@ -7788,33 +6862,33 @@ } }, "node_modules/@react-email/row": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.12.tgz", - "integrity": "sha512-HkCdnEjvK3o+n0y0tZKXYhIXUNPDx+2vq1dJTmqappVHXS5tXS6W5JOPZr5j+eoZ8gY3PShI2LWj5rWF7ZEtIQ==", + "version": "0.0.13", + "resolved": "https://registry.npmjs.org/@react-email/row/-/row-0.0.13.tgz", + "integrity": "sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/section": { - "version": "0.0.16", - "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.16.tgz", - "integrity": "sha512-FjqF9xQ8FoeUZYKSdt8sMIKvoT9XF8BrzhT3xiFKdEMwYNbsDflcjfErJe3jb7Wj/es/lKTbV5QR1dnLzGpL3w==", + "version": "0.0.17", + "resolved": "https://registry.npmjs.org/@react-email/section/-/section-0.0.17.tgz", + "integrity": "sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w==", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "node_modules/@react-email/tailwind": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-2.0.2.tgz", - "integrity": "sha512-ooi1H77+w+MN3a3Yps66GYTMoo9PvLtzJ1bTEI+Ta58MUUEQOcdxxXPwbnox+xj2kSwv0g/B63qquNTabKI8Bw==", + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@react-email/tailwind/-/tailwind-2.0.5.tgz", + "integrity": "sha512-7Ey+kiWliJdxPMCLYsdDts8ffp4idlP//w4Ui3q/A5kokVaLSNKG8DOg/8qAuzWmRiGwNQVOKBk7PXNlK5W+sg==", "license": "MIT", "dependencies": { "tailwindcss": "^4.1.18" @@ -7823,17 +6897,17 @@ "node": ">=20.0.0" }, "peerDependencies": { - "@react-email/body": "0.2.0", - "@react-email/button": "0.2.0", - "@react-email/code-block": "0.2.0", - "@react-email/code-inline": "0.0.5", - "@react-email/container": "0.0.15", - "@react-email/heading": "0.0.15", - "@react-email/hr": "0.0.11", - "@react-email/img": "0.0.11", - "@react-email/link": "0.0.12", - "@react-email/preview": "0.0.13", - "@react-email/text": "0.1.5", + "@react-email/body": "0.2.1", + "@react-email/button": "0.2.1", + "@react-email/code-block": "0.2.1", + "@react-email/code-inline": "0.0.6", + "@react-email/container": "0.0.16", + "@react-email/heading": "0.0.16", + "@react-email/hr": "0.0.12", + "@react-email/img": "0.0.12", + "@react-email/link": "0.0.13", + "@react-email/preview": "0.0.14", + "@react-email/text": "0.1.6", "react": "^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { @@ -7870,13 +6944,12 @@ } }, "node_modules/@react-email/text": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.5.tgz", - "integrity": "sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@react-email/text/-/text-0.1.6.tgz", + "integrity": "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw==", "license": "MIT", - "peer": true, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" @@ -7912,46 +6985,11 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@reduxjs/toolkit": { - "version": "2.11.1", - "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.1.tgz", - "integrity": "sha512-HjhlEREguAyBTGNzRlGNiDHGQ2EjLSPWwdhhpoEqHYy8hWak3Dp6/fU72OfqVsiMb8S6rbfPsWUF24fxpilrVA==", - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.0.0", - "@standard-schema/utils": "^0.3.0", - "immer": "^11.0.0", - "redux": "^5.0.1", - "redux-thunk": "^3.1.0", - "reselect": "^5.1.0" - }, - "peerDependencies": { - "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", - "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-redux": { - "optional": true - } - } - }, - "node_modules/@reduxjs/toolkit/node_modules/immer": { - "version": "11.0.1", - "resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz", - "integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, "license": "MIT" }, "node_modules/@scarf/scarf": { @@ -7981,37 +7019,37 @@ } }, "node_modules/@simplewebauthn/browser": { - "version": "13.2.2", - "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.2.2.tgz", - "integrity": "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA==", + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", + "integrity": "sha512-BE/UWv6FOToAdVk0EokzkqQQDOWtNydYlY6+OrmiZ5SCNmb41VehttboTetUM3T/fr6EAFYVXjz4My2wg230rQ==", "license": "MIT" }, "node_modules/@simplewebauthn/server": { - "version": "13.2.2", - "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.2.2.tgz", - "integrity": "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA==", + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.0.tgz", + "integrity": "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==", "license": "MIT", "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", - "@peculiar/asn1-android": "^2.3.10", - "@peculiar/asn1-ecc": "^2.3.8", - "@peculiar/asn1-rsa": "^2.3.8", - "@peculiar/asn1-schema": "^2.3.8", - "@peculiar/asn1-x509": "^2.3.8", - "@peculiar/x509": "^1.13.0" + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" }, "engines": { "node": ">=20.0.0" } }, "node_modules/@smithy/abort-controller": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.7.tgz", - "integrity": "sha512-rzMY6CaKx2qxrbYbqjXWS0plqEy7LOdKHS0bg4ixJ6aoGDPNUcLWk/FRNuCILh7GKLG9TFUXYYeQQldMBBwuyw==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.2.12.tgz", + "integrity": "sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.11.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8019,9 +7057,9 @@ } }, "node_modules/@smithy/chunked-blob-reader": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.0.tgz", - "integrity": "sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.2.2.tgz", + "integrity": "sha512-St+kVicSyayWQca+I1rGitaOEH6uKgE8IUWoYnnEX26SWdWQcL6LvMSD19Lg+vYHKdT9B2Zuu7rd3i6Wnyb/iw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -8031,12 +7069,12 @@ } }, "node_modules/@smithy/chunked-blob-reader-native": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.1.tgz", - "integrity": "sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.2.3.tgz", + "integrity": "sha512-jA5k5Udn7Y5717L86h4EIv06wIr3xn8GM1qHRi/Nf31annXcXHJjBKvgztnbn2TxH3xWrPBfgwHsOwZf0UmQWw==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-base64": "^4.3.0", + "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, "engines": { @@ -8044,16 +7082,16 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.4.5", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.5.tgz", - "integrity": "sha512-HAGoUAFYsUkoSckuKbCPayECeMim8pOu+yLy1zOxt1sifzEbrsRpYa+mKcMdiHKMeiqOibyPG0sFJnmaV/OGEg==", + "version": "4.4.11", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.11.tgz", + "integrity": "sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.7", - "@smithy/types": "^4.11.0", - "@smithy/util-config-provider": "^4.2.0", - "@smithy/util-endpoints": "^3.2.7", - "@smithy/util-middleware": "^4.2.7", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-config-provider": "^4.2.2", + "@smithy/util-endpoints": "^3.3.3", + "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" }, "engines": { @@ -8061,20 +7099,20 @@ } }, "node_modules/@smithy/core": { - "version": "3.20.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.20.0.tgz", - "integrity": "sha512-WsSHCPq/neD5G/MkK4csLI5Y5Pkd9c1NMfpYEKeghSGaD4Ja1qLIohRQf2D5c1Uy5aXp76DeKHkzWZ9KAlHroQ==", + "version": "3.23.12", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.12.tgz", + "integrity": "sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==", "license": "Apache-2.0", "dependencies": { - "@smithy/middleware-serde": "^4.2.8", - "@smithy/protocol-http": "^5.3.7", - "@smithy/types": "^4.11.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-body-length-browser": "^4.2.0", - "@smithy/util-middleware": "^4.2.7", - "@smithy/util-stream": "^4.5.8", - "@smithy/util-utf8": "^4.2.0", - "@smithy/uuid": "^1.1.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-stream": "^4.5.20", + "@smithy/util-utf8": "^4.2.2", + "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, "engines": { @@ -8082,15 +7120,15 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.7.tgz", - "integrity": "sha512-CmduWdCiILCRNbQWFR0OcZlUPVtyE49Sr8yYL0rZQ4D/wKxiNzBNS/YHemvnbkIWj623fplgkexUd/c9CAKdoA==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.12.tgz", + "integrity": "sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.7", - "@smithy/property-provider": "^4.2.7", - "@smithy/types": "^4.11.0", - "@smithy/url-parser": "^4.2.7", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", "tslib": "^2.6.2" }, "engines": { @@ -8098,14 +7136,14 @@ } }, "node_modules/@smithy/eventstream-codec": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.7.tgz", - "integrity": "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.12.tgz", + "integrity": "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/crc32": "5.2.0", - "@smithy/types": "^4.11.0", - "@smithy/util-hex-encoding": "^4.2.0", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -8113,13 +7151,13 @@ } }, "node_modules/@smithy/eventstream-serde-browser": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.7.tgz", - "integrity": "sha512-ujzPk8seYoDBmABDE5YqlhQZAXLOrtxtJLrbhHMKjBoG5b4dK4i6/mEU+6/7yXIAkqOO8sJ6YxZl+h0QQ1IJ7g==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.12.tgz", + "integrity": "sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.7", - "@smithy/types": "^4.11.0", + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8127,12 +7165,12 @@ } }, "node_modules/@smithy/eventstream-serde-config-resolver": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.7.tgz", - "integrity": "sha512-x7BtAiIPSaNaWuzm24Q/mtSkv+BrISO/fmheiJ39PKRNH3RmH2Hph/bUKSOBOBC9unqfIYDhKTHwpyZycLGPVQ==", + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.12.tgz", + "integrity": "sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.11.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8140,13 +7178,13 @@ } }, "node_modules/@smithy/eventstream-serde-node": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.7.tgz", - "integrity": "sha512-roySCtHC5+pQq5lK4be1fZ/WR6s/AxnPaLfCODIPArtN2du8s5Ot4mKVK3pPtijL/L654ws592JHJ1PbZFF6+A==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.12.tgz", + "integrity": "sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-serde-universal": "^4.2.7", - "@smithy/types": "^4.11.0", + "@smithy/eventstream-serde-universal": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8154,13 +7192,13 @@ } }, "node_modules/@smithy/eventstream-serde-universal": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.7.tgz", - "integrity": "sha512-QVD+g3+icFkThoy4r8wVFZMsIP08taHVKjE6Jpmz8h5CgX/kk6pTODq5cht0OMtcapUx+xrPzUTQdA+TmO0m1g==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.12.tgz", + "integrity": "sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/eventstream-codec": "^4.2.7", - "@smithy/types": "^4.11.0", + "@smithy/eventstream-codec": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8168,15 +7206,15 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.8", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.8.tgz", - "integrity": "sha512-h/Fi+o7mti4n8wx1SR6UHWLaakwHRx29sizvp8OOm7iqwKGFneT06GCSFhml6Bha5BT6ot5pj3CYZnCHhGC2Rg==", + "version": "5.3.15", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.15.tgz", + "integrity": "sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.7", - "@smithy/querystring-builder": "^4.2.7", - "@smithy/types": "^4.11.0", - "@smithy/util-base64": "^4.3.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, "engines": { @@ -8184,14 +7222,14 @@ } }, "node_modules/@smithy/hash-blob-browser": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.8.tgz", - "integrity": "sha512-07InZontqsM1ggTCPSRgI7d8DirqRrnpL7nIACT4PW0AWrgDiHhjGZzbAE5UtRSiU0NISGUYe7/rri9ZeWyDpw==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.2.13.tgz", + "integrity": "sha512-YrF4zWKh+ghLuquldj6e/RzE3xZYL8wIPfkt0MqCRphVICjyyjH8OwKD7LLlKpVEbk4FLizFfC1+gwK6XQdR3g==", "license": "Apache-2.0", "dependencies": { - "@smithy/chunked-blob-reader": "^5.2.0", - "@smithy/chunked-blob-reader-native": "^4.2.1", - "@smithy/types": "^4.11.0", + "@smithy/chunked-blob-reader": "^5.2.2", + "@smithy/chunked-blob-reader-native": "^4.2.3", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8199,14 +7237,14 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.7.tgz", - "integrity": "sha512-PU/JWLTBCV1c8FtB8tEFnY4eV1tSfBc7bDBADHfn1K+uRbPgSJ9jnJp0hyjiFN2PMdPzxsf1Fdu0eo9fJ760Xw==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.12.tgz", + "integrity": "sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.11.0", - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/types": "^4.13.1", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -8214,13 +7252,13 @@ } }, "node_modules/@smithy/hash-stream-node": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.7.tgz", - "integrity": "sha512-ZQVoAwNYnFMIbd4DUc517HuwNelJUY6YOzwqrbcAgCnVn+79/OK7UjwA93SPpdTOpKDVkLIzavWm/Ck7SmnDPQ==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.2.12.tgz", + "integrity": "sha512-O3YbmGExeafuM/kP7Y8r6+1y0hIh3/zn6GROx0uNlB54K9oihAL75Qtc+jFfLNliTi6pxOAYZrRKD9A7iA6UFw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.11.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -8228,12 +7266,12 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.7.tgz", - "integrity": "sha512-ncvgCr9a15nPlkhIUx3CU4d7E7WEuVJOV7fS7nnK2hLtPK9tYRBkMHQbhXU1VvvKeBm/O0x26OEoBq+ngFpOEQ==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.12.tgz", + "integrity": "sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.11.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8241,9 +7279,9 @@ } }, "node_modules/@smithy/is-array-buffer": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.0.tgz", - "integrity": "sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", + "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -8253,13 +7291,13 @@ } }, "node_modules/@smithy/md5-js": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.7.tgz", - "integrity": "sha512-Wv6JcUxtOLTnxvNjDnAiATUsk8gvA6EeS8zzHig07dotpByYsLot+m0AaQEniUBjx97AC41MQR4hW0baraD1Xw==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.2.12.tgz", + "integrity": "sha512-W/oIpHCpWU2+iAkfZYyGWE+qkpuf3vEXHLxQQDx9FPNZTTdnul0dZ2d/gUFrtQ5je1G2kp4cjG0/24YueG2LbQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.11.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/types": "^4.13.1", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -8267,13 +7305,13 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.7.tgz", - "integrity": "sha512-GszfBfCcvt7kIbJ41LuNa5f0wvQCHhnGx/aDaZJCCT05Ld6x6U2s0xsc/0mBFONBZjQJp2U/0uSJ178OXOwbhg==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.12.tgz", + "integrity": "sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.7", - "@smithy/types": "^4.11.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8281,18 +7319,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.1.tgz", - "integrity": "sha512-gpLspUAoe6f1M6H0u4cVuFzxZBrsGZmjx2O9SigurTx4PbntYa4AJ+o0G0oGm1L2oSX6oBhcGHwrfJHup2JnJg==", + "version": "4.4.26", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.26.tgz", + "integrity": "sha512-8Qfikvd2GVKSm8S6IbjfwFlRY9VlMrj0Dp4vTwAuhqbX7NhJKE5DQc2bnfJIcY0B+2YKMDBWfvexbSZeejDgeg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.20.0", - "@smithy/middleware-serde": "^4.2.8", - "@smithy/node-config-provider": "^4.3.7", - "@smithy/shared-ini-file-loader": "^4.4.2", - "@smithy/types": "^4.11.0", - "@smithy/url-parser": "^4.2.7", - "@smithy/util-middleware": "^4.2.7", + "@smithy/core": "^3.23.12", + "@smithy/middleware-serde": "^4.2.15", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", + "@smithy/url-parser": "^4.2.12", + "@smithy/util-middleware": "^4.2.12", "tslib": "^2.6.2" }, "engines": { @@ -8300,19 +7338,19 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.4.17", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.17.tgz", - "integrity": "sha512-MqbXK6Y9uq17h+4r0ogu/sBT6V/rdV+5NvYL7ZV444BKfQygYe8wAhDrVXagVebN6w2RE0Fm245l69mOsPGZzg==", + "version": "4.4.43", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.4.43.tgz", + "integrity": "sha512-ZwsifBdyuNHrFGmbc7bAfP2b54+kt9J2rhFd18ilQGAB+GDiP4SrawqyExbB7v455QVR7Psyhb2kjULvBPIhvA==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.7", - "@smithy/protocol-http": "^5.3.7", - "@smithy/service-error-classification": "^4.2.7", - "@smithy/smithy-client": "^4.10.2", - "@smithy/types": "^4.11.0", - "@smithy/util-middleware": "^4.2.7", - "@smithy/util-retry": "^4.2.7", - "@smithy/uuid": "^1.1.0", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/service-error-classification": "^4.2.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-retry": "^4.2.12", + "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, "engines": { @@ -8320,13 +7358,14 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.8.tgz", - "integrity": "sha512-8rDGYen5m5+NV9eHv9ry0sqm2gI6W7mc1VSFMtn6Igo25S507/HaOX9LTHAS2/J32VXD0xSzrY0H5FJtOMS4/w==", + "version": "4.2.15", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.15.tgz", + "integrity": "sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.7", - "@smithy/types": "^4.11.0", + "@smithy/core": "^3.23.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8334,12 +7373,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.7.tgz", - "integrity": "sha512-bsOT0rJ+HHlZd9crHoS37mt8qRRN/h9jRve1SXUhVbkRzu0QaNYZp1i1jha4n098tsvROjcwfLlfvcFuJSXEsw==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.12.tgz", + "integrity": "sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.11.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8347,14 +7386,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.7.tgz", - "integrity": "sha512-7r58wq8sdOcrwWe+klL9y3bc4GW1gnlfnFOuL7CXa7UzfhzhxKuzNdtqgzmTV+53lEp9NXh5hY/S4UgjLOzPfw==", + "version": "4.3.12", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.12.tgz", + "integrity": "sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.7", - "@smithy/shared-ini-file-loader": "^4.4.2", - "@smithy/types": "^4.11.0", + "@smithy/property-provider": "^4.2.12", + "@smithy/shared-ini-file-loader": "^4.4.7", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8362,15 +7401,15 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.4.7", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.4.7.tgz", - "integrity": "sha512-NELpdmBOO6EpZtWgQiHjoShs1kmweaiNuETUpuup+cmm/xJYjT4eUjfhrXRP4jCOaAsS3c3yPsP3B+K+/fyPCQ==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.0.tgz", + "integrity": "sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.7", - "@smithy/protocol-http": "^5.3.7", - "@smithy/querystring-builder": "^4.2.7", - "@smithy/types": "^4.11.0", + "@smithy/abort-controller": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/querystring-builder": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8378,12 +7417,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.7.tgz", - "integrity": "sha512-jmNYKe9MGGPoSl/D7JDDs1C8b3dC8f/w78LbaVfoTtWy4xAd5dfjaFG9c9PWPihY4ggMQNQSMtzU77CNgAJwmA==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.12.tgz", + "integrity": "sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.11.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8391,12 +7430,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.7.tgz", - "integrity": "sha512-1r07pb994I20dD/c2seaZhoCuNYm0rWrvBxhCQ70brNh11M5Ml2ew6qJVo0lclB3jMIXirD4s2XRXRe7QEi0xA==", + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.12.tgz", + "integrity": "sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.11.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8404,13 +7443,13 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.7.tgz", - "integrity": "sha512-eKONSywHZxK4tBxe2lXEysh8wbBdvDWiA+RIuaxZSgCMmA0zMgoDpGLJhnyj+c0leOQprVnXOmcB4m+W9Rw7sg==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.12.tgz", + "integrity": "sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.11.0", - "@smithy/util-uri-escape": "^4.2.0", + "@smithy/types": "^4.13.1", + "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -8418,12 +7457,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.7.tgz", - "integrity": "sha512-3X5ZvzUHmlSTHAXFlswrS6EGt8fMSIxX/c3Rm1Pni3+wYWB6cjGocmRIoqcQF9nU5OgGmL0u7l9m44tSUpfj9w==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.12.tgz", + "integrity": "sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.11.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8431,24 +7470,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.7.tgz", - "integrity": "sha512-YB7oCbukqEb2Dlh3340/8g8vNGbs/QsNNRms+gv3N2AtZz9/1vSBx6/6tpwQpZMEJFs7Uq8h4mmOn48ZZ72MkA==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.12.tgz", + "integrity": "sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.11.0" + "@smithy/types": "^4.13.1" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.2.tgz", - "integrity": "sha512-M7iUUff/KwfNunmrgtqBfvZSzh3bmFgv/j/t1Y1dQ+8dNo34br1cqVEqy6v0mYEgi0DkGO7Xig0AnuOaEGVlcg==", + "version": "4.4.7", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.7.tgz", + "integrity": "sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.11.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8456,18 +7495,18 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.7.tgz", - "integrity": "sha512-9oNUlqBlFZFOSdxgImA6X5GFuzE7V2H7VG/7E70cdLhidFbdtvxxt81EHgykGK5vq5D3FafH//X+Oy31j3CKOg==", + "version": "5.3.12", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.12.tgz", + "integrity": "sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.0", - "@smithy/protocol-http": "^5.3.7", - "@smithy/types": "^4.11.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-middleware": "^4.2.7", - "@smithy/util-uri-escape": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/is-array-buffer": "^4.2.2", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-middleware": "^4.2.12", + "@smithy/util-uri-escape": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -8475,17 +7514,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.10.2", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.10.2.tgz", - "integrity": "sha512-D5z79xQWpgrGpAHb054Fn2CCTQZpog7JELbVQ6XAvXs5MNKWf28U9gzSBlJkOyMl9LA1TZEjRtwvGXfP0Sl90g==", + "version": "4.12.6", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.6.tgz", + "integrity": "sha512-aib3f0jiMsJ6+cvDnXipBsGDL7ztknYSVqJs1FdN9P+u9tr/VzOR7iygSh6EUOdaBeMCMSh3N0VdyYsG4o91DQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.20.0", - "@smithy/middleware-endpoint": "^4.4.1", - "@smithy/middleware-stack": "^4.2.7", - "@smithy/protocol-http": "^5.3.7", - "@smithy/types": "^4.11.0", - "@smithy/util-stream": "^4.5.8", + "@smithy/core": "^3.23.12", + "@smithy/middleware-endpoint": "^4.4.26", + "@smithy/middleware-stack": "^4.2.12", + "@smithy/protocol-http": "^5.3.12", + "@smithy/types": "^4.13.1", + "@smithy/util-stream": "^4.5.20", "tslib": "^2.6.2" }, "engines": { @@ -8493,9 +7532,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.11.0.tgz", - "integrity": "sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -8505,13 +7544,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.7.tgz", - "integrity": "sha512-/RLtVsRV4uY3qPWhBDsjwahAtt3x2IsMGnP5W1b2VZIe+qgCqkLxI1UOHDZp1Q1QSOrdOR32MF3Ph2JfWT1VHg==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.12.tgz", + "integrity": "sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.7", - "@smithy/types": "^4.11.0", + "@smithy/querystring-parser": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8519,13 +7558,13 @@ } }, "node_modules/@smithy/util-base64": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.0.tgz", - "integrity": "sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.3.2.tgz", + "integrity": "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -8533,9 +7572,9 @@ } }, "node_modules/@smithy/util-body-length-browser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.0.tgz", - "integrity": "sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.2.2.tgz", + "integrity": "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -8545,9 +7584,9 @@ } }, "node_modules/@smithy/util-body-length-node": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.1.tgz", - "integrity": "sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.2.3.tgz", + "integrity": "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -8557,12 +7596,12 @@ } }, "node_modules/@smithy/util-buffer-from": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.0.tgz", - "integrity": "sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", + "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", "license": "Apache-2.0", "dependencies": { - "@smithy/is-array-buffer": "^4.2.0", + "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -8570,9 +7609,9 @@ } }, "node_modules/@smithy/util-config-provider": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.0.tgz", - "integrity": "sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.2.2.tgz", + "integrity": "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -8582,14 +7621,14 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.16", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.16.tgz", - "integrity": "sha512-/eiSP3mzY3TsvUOYMeL4EqUX6fgUOj2eUOU4rMMgVbq67TiRLyxT7Xsjxq0bW3OwuzK009qOwF0L2OgJqperAQ==", + "version": "4.3.42", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.42.tgz", + "integrity": "sha512-0vjwmcvkWAUtikXnWIUOyV6IFHTEeQUYh3JUZcDgcszF+hD/StAsQ3rCZNZEPHgI9kVNcbnyc8P2CBHnwgmcwg==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.7", - "@smithy/smithy-client": "^4.10.2", - "@smithy/types": "^4.11.0", + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8597,17 +7636,17 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.19", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.19.tgz", - "integrity": "sha512-3a4+4mhf6VycEJyHIQLypRbiwG6aJvbQAeRAVXydMmfweEPnLLabRbdyo/Pjw8Rew9vjsh5WCdhmDaHkQnhhhA==", + "version": "4.2.45", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.45.tgz", + "integrity": "sha512-q5dOqqfTgUcLe38TAGiFn9srToKj2YCHJ34QGOLzM+xYLLA+qRZv7N+33kl1MERVusue36ZHnlNaNEvY/PzSrw==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.5", - "@smithy/credential-provider-imds": "^4.2.7", - "@smithy/node-config-provider": "^4.3.7", - "@smithy/property-provider": "^4.2.7", - "@smithy/smithy-client": "^4.10.2", - "@smithy/types": "^4.11.0", + "@smithy/config-resolver": "^4.4.11", + "@smithy/credential-provider-imds": "^4.2.12", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/property-provider": "^4.2.12", + "@smithy/smithy-client": "^4.12.6", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8615,13 +7654,13 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.2.7.tgz", - "integrity": "sha512-s4ILhyAvVqhMDYREeTS68R43B1V5aenV5q/V1QpRQJkCXib5BPRo4s7uNdzGtIKxaPHCfU/8YkvPAEvTpxgspg==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.3.tgz", + "integrity": "sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.7", - "@smithy/types": "^4.11.0", + "@smithy/node-config-provider": "^4.3.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8629,9 +7668,9 @@ } }, "node_modules/@smithy/util-hex-encoding": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.0.tgz", - "integrity": "sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.2.2.tgz", + "integrity": "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -8641,12 +7680,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.7.tgz", - "integrity": "sha512-i1IkpbOae6NvIKsEeLLM9/2q4X+M90KV3oCFgWQI4q0Qz+yUZvsr+gZPdAEAtFhWQhAHpTsJO8DRJPuwVyln+w==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.12.tgz", + "integrity": "sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.11.0", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8654,13 +7693,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.7.tgz", - "integrity": "sha512-SvDdsQyF5CIASa4EYVT02LukPHVzAgUA4kMAuZ97QJc2BpAqZfA4PINB8/KOoCXEw9tsuv/jQjMeaHFvxdLNGg==", + "version": "4.2.12", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.2.12.tgz", + "integrity": "sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.7", - "@smithy/types": "^4.11.0", + "@smithy/service-error-classification": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8668,18 +7707,18 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.8", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.8.tgz", - "integrity": "sha512-ZnnBhTapjM0YPGUSmOs0Mcg/Gg87k503qG4zU2v/+Js2Gu+daKOJMeqcQns8ajepY8tgzzfYxl6kQyZKml6O2w==", + "version": "4.5.20", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.20.tgz", + "integrity": "sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.8", - "@smithy/node-http-handler": "^4.4.7", - "@smithy/types": "^4.11.0", - "@smithy/util-base64": "^4.3.0", - "@smithy/util-buffer-from": "^4.2.0", - "@smithy/util-hex-encoding": "^4.2.0", - "@smithy/util-utf8": "^4.2.0", + "@smithy/fetch-http-handler": "^5.3.15", + "@smithy/node-http-handler": "^4.5.0", + "@smithy/types": "^4.13.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-buffer-from": "^4.2.2", + "@smithy/util-hex-encoding": "^4.2.2", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -8687,9 +7726,9 @@ } }, "node_modules/@smithy/util-uri-escape": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.0.tgz", - "integrity": "sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.2.2.tgz", + "integrity": "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -8699,12 +7738,12 @@ } }, "node_modules/@smithy/util-utf8": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.0.tgz", - "integrity": "sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==", + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", + "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", "license": "Apache-2.0", "dependencies": { - "@smithy/util-buffer-from": "^4.2.0", + "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -8712,13 +7751,13 @@ } }, "node_modules/@smithy/util-waiter": { - "version": "4.2.7", - "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.7.tgz", - "integrity": "sha512-vHJFXi9b7kUEpHWUCY3Twl+9NPOZvQ0SAi+Ewtn48mbiJk4JY9MZmKQjGB4SCvVb9WPiSphZJYY6RIbs+grrzw==", + "version": "4.2.13", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.13.tgz", + "integrity": "sha512-2zdZ9DTHngRtcYxJK1GUDxruNr53kv5W2Lupe0LMU+Imr6ohQg8M2T14MNkj1Y0wS3FFwpgpGQyvuaMF7CiTmQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.2.7", - "@smithy/types": "^4.11.0", + "@smithy/abort-controller": "^4.2.12", + "@smithy/types": "^4.13.1", "tslib": "^2.6.2" }, "engines": { @@ -8726,9 +7765,9 @@ } }, "node_modules/@smithy/uuid": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.0.tgz", - "integrity": "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", + "integrity": "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -8760,12 +7799,6 @@ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "license": "MIT" }, - "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "license": "MIT" - }, "node_modules/@standard-schema/utils": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", @@ -8994,6 +8027,15 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@tabby_ai/hijri-converter": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", + "integrity": "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@tailwindcss/forms": { "version": "0.5.11", "resolved": "https://registry.npmjs.org/@tailwindcss/forms/-/forms-0.5.11.tgz", @@ -9007,49 +8049,49 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", - "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "enhanced-resolve": "^5.18.3", + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", - "lightningcss": "1.30.2", + "lightningcss": "1.31.1", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.18" + "tailwindcss": "4.2.1" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", - "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", "dev": true, "license": "MIT", "engines": { - "node": ">= 10" + "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-arm64": "4.1.18", - "@tailwindcss/oxide-darwin-x64": "4.1.18", - "@tailwindcss/oxide-freebsd-x64": "4.1.18", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", - "@tailwindcss/oxide-linux-x64-musl": "4.1.18", - "@tailwindcss/oxide-wasm32-wasi": "4.1.18", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", - "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", "cpu": [ "arm64" ], @@ -9060,13 +8102,13 @@ "android" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", - "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", "cpu": [ "arm64" ], @@ -9077,13 +8119,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", - "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", "cpu": [ "x64" ], @@ -9094,13 +8136,13 @@ "darwin" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", - "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", "cpu": [ "x64" ], @@ -9111,13 +8153,13 @@ "freebsd" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", - "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", "cpu": [ "arm" ], @@ -9128,13 +8170,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", - "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", "cpu": [ "arm64" ], @@ -9145,13 +8187,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", - "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", "cpu": [ "arm64" ], @@ -9162,13 +8204,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", - "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", "cpu": [ "x64" ], @@ -9179,13 +8221,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", - "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", "cpu": [ "x64" ], @@ -9196,13 +8238,13 @@ "linux" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", - "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -9218,19 +8260,19 @@ "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", - "tslib": "^2.4.0" + "tslib": "^2.8.1" }, "engines": { "node": ">=14.0.0" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.7.1", + "version": "1.8.1", "dev": true, "inBundle": true, "license": "MIT", @@ -9241,7 +8283,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.7.1", + "version": "1.8.1", "dev": true, "inBundle": true, "license": "MIT", @@ -9261,7 +8303,7 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.0", + "version": "1.1.1", "dev": true, "inBundle": true, "license": "MIT", @@ -9270,6 +8312,10 @@ "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" } }, "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { @@ -9290,9 +8336,9 @@ "optional": true }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", - "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", "cpu": [ "arm64" ], @@ -9303,13 +8349,13 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", - "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", "cpu": [ "x64" ], @@ -9320,27 +8366,27 @@ "win32" ], "engines": { - "node": ">= 10" + "node": ">= 20" } }, "node_modules/@tailwindcss/postcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.1.18.tgz", - "integrity": "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.1.tgz", + "integrity": "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.1.18", - "@tailwindcss/oxide": "4.1.18", - "postcss": "^8.4.41", - "tailwindcss": "4.1.18" + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "postcss": "^8.5.6", + "tailwindcss": "4.2.1" } }, "node_modules/@tanstack/query-core": { - "version": "5.90.12", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.12.tgz", - "integrity": "sha512-T1/8t5DhV/SisWjDnaiU2drl6ySvsHj1bHBCWNXd+/T+Hh1cf6JodyEYMd5sgwm+b/mETT4EV3H+zCVczCU5hg==", + "version": "5.90.20", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.20.tgz", + "integrity": "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==", "license": "MIT", "funding": { "type": "github", @@ -9348,9 +8394,9 @@ } }, "node_modules/@tanstack/query-devtools": { - "version": "5.91.1", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.91.1.tgz", - "integrity": "sha512-l8bxjk6BMsCaVQH6NzQEE/bEgFy1hAs5qbgXl0xhzezlaQbPk6Mgz9BqEg2vTLPOHD8N4k+w/gdgCbEzecGyNg==", + "version": "5.93.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.93.0.tgz", + "integrity": "sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==", "dev": true, "license": "MIT", "funding": { @@ -9359,13 +8405,11 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.90.12", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.12.tgz", - "integrity": "sha512-graRZspg7EoEaw0a8faiUASCyJrqjKPdqJ9EwuDRUF9mEYJ1YPczI9H+/agJ0mOJkPCJDk0lsz5QTrLZ/jQ2rg==", - "license": "MIT", - "peer": true, + "version": "5.90.21", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.21.tgz", + "integrity": "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==", "dependencies": { - "@tanstack/query-core": "5.90.12" + "@tanstack/query-core": "5.90.20" }, "funding": { "type": "github", @@ -9376,20 +8420,20 @@ } }, "node_modules/@tanstack/react-query-devtools": { - "version": "5.91.1", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.1.tgz", - "integrity": "sha512-tRnJYwEbH0kAOuToy8Ew7bJw1lX3AjkkgSlf/vzb+NpnqmHPdWM+lA2DSdGQSLi1SU0PDRrrCI1vnZnci96CsQ==", + "version": "5.91.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.3.tgz", + "integrity": "sha512-nlahjMtd/J1h7IzOOfqeyDh5LNfG0eULwlltPEonYy0QL+nqrBB+nyzJfULV+moL7sZyxc2sHdNJki+vLA9BSA==", "dev": true, "license": "MIT", "dependencies": { - "@tanstack/query-devtools": "5.91.1" + "@tanstack/query-devtools": "5.93.0" }, "funding": { "type": "github", "url": "https://github.com/sponsors/tannerlinsley" }, "peerDependencies": { - "@tanstack/react-query": "^5.90.10", + "@tanstack/react-query": "^5.90.20", "react": "^18 || ^19" } }, @@ -9463,13 +8507,22 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@types/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/better-sqlite3": { "version": "7.6.13", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.13.tgz", "integrity": "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*" } @@ -9797,10 +8850,18 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, "license": "MIT" }, "node_modules/@types/express": { @@ -9809,7 +8870,6 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -9871,12 +8931,14 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, "license": "MIT" }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, "license": "MIT" }, "node_modules/@types/jsonwebtoken": { @@ -9898,24 +8960,22 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.2.tgz", - "integrity": "sha512-WOhQTZ4G8xZ1tjJTvKOpyEVSGgOTvJAfDK3FNFgELyaTpzhdgHVHeqW8V+UJvzF5BT+/B54T/1S2K6gd9c7bbA==", + "version": "25.3.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.5.tgz", + "integrity": "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.18.0" } }, "node_modules/@types/nodemailer": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.4.tgz", - "integrity": "sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow==", + "version": "7.0.11", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz", + "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==", "dev": true, "license": "MIT", "dependencies": { - "@aws-sdk/client-sesv2": "^3.839.0", "@types/node": "*" } }, @@ -9927,12 +8987,11 @@ "license": "MIT" }, "node_modules/@types/pg": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", - "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-gT+oueVQkqnj6ajGJXblFR4iavIXWsGAFCk3dP4Kki5+a9R4NMt0JARdk6s8cUKcfUoqP5dAtDSLU8xYUTFV+Q==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@types/node": "*", "pg-protocol": "*", @@ -9954,12 +9013,10 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.7", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", - "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, - "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -9970,7 +9027,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "devOptional": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -10003,6 +9059,17 @@ "@types/node": "*" } }, + "node_modules/@types/sshpk": { + "version": "1.17.4", + "resolved": "https://registry.npmjs.org/@types/sshpk/-/sshpk-1.17.4.tgz", + "integrity": "sha512-5gI/7eJn6wmkuIuFY8JZJ1g5b30H9K5U5vKrvOuYu+hoZLb2xcVEgxhYZ2Vhbs0w/ACyzyfkJq0hQtBfSCugjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/asn1": "*", + "@types/node": "*" + } + }, "node_modules/@types/swagger-ui-express": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz", @@ -10046,13 +9113,8 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", "license": "MIT", - "optional": true - }, - "node_modules/@types/use-sync-external-store": { - "version": "0.0.6", - "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", - "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", - "license": "MIT" + "optional": true, + "peer": true }, "node_modules/@types/ws": { "version": "8.18.1", @@ -10082,19 +9144,20 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", - "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz", + "integrity": "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==", + "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/type-utils": "8.49.0", - "@typescript-eslint/utils": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "ignore": "^7.0.0", + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/type-utils": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "ignore": "^7.0.5", "natural-compare": "^1.4.0", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10104,8 +9167,8 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.49.0", - "eslint": "^8.57.0 || ^9.0.0", + "@typescript-eslint/parser": "^8.56.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -10113,23 +9176,24 @@ "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", - "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.1.tgz", + "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "debug": "^4.3.4" + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10139,19 +9203,20 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", - "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.1.tgz", + "integrity": "sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==", + "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.49.0", - "@typescript-eslint/types": "^8.49.0", - "debug": "^4.3.4" + "@typescript-eslint/tsconfig-utils": "^8.56.1", + "@typescript-eslint/types": "^8.56.1", + "debug": "^4.4.3" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10165,13 +9230,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", - "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz", + "integrity": "sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==", + "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10182,9 +9248,10 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", - "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz", + "integrity": "sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==", + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10198,16 +9265,17 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", - "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz", + "integrity": "sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==", + "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/utils": "8.49.0", - "debug": "^4.3.4", - "ts-api-utils": "^2.1.0" + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10217,14 +9285,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", - "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.1.tgz", + "integrity": "sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==", + "dev": true, "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10235,20 +9304,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", - "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz", + "integrity": "sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==", + "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.49.0", - "@typescript-eslint/tsconfig-utils": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", - "debug": "^4.3.4", - "minimatch": "^9.0.4", - "semver": "^7.6.0", + "@typescript-eslint/project-service": "8.56.1", + "@typescript-eslint/tsconfig-utils": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/visitor-keys": "8.56.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", "tinyglobby": "^0.2.15", - "ts-api-utils": "^2.1.0" + "ts-api-utils": "^2.4.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10261,40 +9331,17 @@ "typescript": ">=4.8.4 <6.0.0" } }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/@typescript-eslint/utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", - "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.1.tgz", + "integrity": "sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==", + "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0" + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.1", + "@typescript-eslint/types": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10304,18 +9351,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", - "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz", + "integrity": "sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==", + "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.49.0", - "eslint-visitor-keys": "^4.2.1" + "@typescript-eslint/types": "8.56.1", + "eslint-visitor-keys": "^5.0.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -10332,6 +9380,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10345,6 +9394,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10358,6 +9408,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10371,6 +9422,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10384,6 +9436,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10397,6 +9450,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10410,6 +9464,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10423,6 +9478,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10436,6 +9492,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10449,6 +9506,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10462,6 +9520,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10475,6 +9534,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10488,6 +9548,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10501,6 +9562,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10514,6 +9576,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10527,6 +9590,7 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -10543,6 +9607,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10556,6 +9621,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10569,6 +9635,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -10589,11 +9656,11 @@ } }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -10605,15 +9672,17 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", @@ -10680,21 +9749,6 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, - "node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -10755,6 +9809,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -10764,6 +9819,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -10780,6 +9836,7 @@ "version": "3.1.9", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -10812,6 +9869,7 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -10832,6 +9890,7 @@ "version": "1.2.6", "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -10853,6 +9912,7 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -10871,6 +9931,7 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -10889,6 +9950,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -10905,6 +9967,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.1", @@ -10922,6 +9985,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/asn1js": { "version": "3.0.6", "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.6.tgz", @@ -10936,10 +10008,20 @@ "node": ">=12.0.0" } }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, "license": "MIT" }, "node_modules/async": { @@ -10952,6 +10034,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -10978,6 +10061,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, "license": "MIT", "dependencies": { "possible-typed-array-names": "^1.0.0" @@ -10990,22 +10074,22 @@ } }, "node_modules/axe-core": { - "version": "4.11.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.0.tgz", - "integrity": "sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==", + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, "license": "MPL-2.0", "engines": { "node": ">=4" } }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", - "license": "MIT", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -11013,6 +10097,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -11024,16 +10109,18 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.26.0" } }, "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "license": "MIT" + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -11066,12 +10153,25 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.4.tgz", - "integrity": "sha512-ZCQ9GEWl73BVm8bu5Fts8nt7MHdbt5vY9bP6WGnUh+r3l8M7CgfyTlwsgCbMC66BNxPr6Xoce3j66Ms5YUQTNA==", + "version": "2.10.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", + "integrity": "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==", + "dev": true, "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "dependencies": { + "tweetnacl": "^0.14.3" } }, "node_modules/better-sqlite3": { @@ -11080,7 +10180,6 @@ "integrity": "sha512-Ba0KR+Fzxh2jDRhdg6TSH0SJGzb8C0aBY4hR8w8madIdIzzC6Y1+kx5qR6eS1Z+Gy20h6ZU28aeyg0z1VIrShQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" @@ -11160,19 +10259,21 @@ } }, "node_modules/bowser": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.13.1.tgz", - "integrity": "sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==", + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", "license": "MIT", "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" } }, "node_modules/braces": { @@ -11191,6 +10292,7 @@ "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, "funding": [ { "type": "opencollective", @@ -11206,7 +10308,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -11271,6 +10372,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, "license": "MIT", "dependencies": { "call-bind-apply-helpers": "^1.0.0", @@ -11314,15 +10416,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001759", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001759.tgz", @@ -11353,22 +10446,6 @@ "url": "https://www.paypal.me/kirilvatev" } }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -11554,24 +10631,6 @@ "node": ">=18" } }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "license": "MIT" - }, "node_modules/color-string": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.4.tgz", @@ -11640,6 +10699,7 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, "license": "MIT" }, "node_modules/conf": { @@ -11733,21 +10793,9 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, "license": "MIT" }, - "node_modules/cookie": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", - "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/cookie-parser": { "version": "1.4.7", "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", @@ -11776,23 +10824,10 @@ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", "license": "MIT" }, - "node_modules/cookies": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz", - "integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "keygrip": "~1.1.0" - }, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/cors": { - "version": "2.8.5", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", - "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -11800,6 +10835,10 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/cross-spawn": { @@ -11847,7 +10886,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, "license": "MIT" }, "node_modules/d3": { @@ -12176,7 +11214,6 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -12265,21 +11302,26 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, "license": "BSD-2-Clause" }, - "node_modules/data-uri-to-buffer": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", - "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, "engines": { - "node": ">= 12" + "node": ">=0.10" } }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -12297,6 +11339,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -12314,6 +11357,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -12429,6 +11473,7 @@ "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, "license": "MIT" }, "node_modules/deepmerge": { @@ -12444,6 +11489,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0", @@ -12461,6 +11507,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -12542,6 +11589,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" @@ -12550,6 +11598,16 @@ "node": ">=0.10.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -12592,10 +11650,14 @@ } }, "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz", + "integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==", "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, + "engines": { + "node": ">=20" + }, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -12644,505 +11706,21 @@ } }, "node_modules/drizzle-kit": { - "version": "0.31.8", - "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.8.tgz", - "integrity": "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg==", + "version": "0.31.10", + "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz", + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", "dev": true, "license": "MIT", "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", - "esbuild-register": "^3.5.0" + "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, - "node_modules/drizzle-kit/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/drizzle-kit/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, "node_modules/drizzle-orm": { "version": "0.45.1", "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.1.tgz", @@ -13282,12 +11860,15 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", @@ -13323,15 +11904,17 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.266", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.266.tgz", - "integrity": "sha512-kgWEglXvkEfMH7rxP5OSZZwnaDWT7J9EoZCujhnpLbfi0bbNtRkgdX2E3gt0Uer11c61qCYktB3hwkAS325sJg==", + "version": "1.5.321", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.321.tgz", + "integrity": "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ==", + "dev": true, "license": "ISC" }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, "license": "MIT" }, "node_modules/enabled": { @@ -13487,14 +12070,14 @@ } }, "node_modules/enhanced-resolve": { - "version": "5.18.4", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz", - "integrity": "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q==", + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.2.0" + "tapable": "^2.3.0" }, "engines": { "node": ">=10.13.0" @@ -13526,9 +12109,10 @@ } }, "node_modules/es-abstract": { - "version": "1.24.0", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", - "integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==", + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", + "dev": true, "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.2", @@ -13612,26 +12196,28 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.2.1.tgz", - "integrity": "sha512-uDn+FE1yrDzyC0pCo961B2IHbdM8y/ACZsKD4dG6WqrjV53BADjwa7D+1aom2rsNVfLyDgU/eigvlJGJ08OQ4w==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.1.tgz", + "integrity": "sha512-zWwRvqWiuBPr0muUG/78cW3aHROFCNIQ3zpmYDpwdbnt2m+xlNyRWpHBpa2lJjSBit7BQ+RXA1iwbSmu5yJ/EQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bound": "^1.0.4", "define-properties": "^1.2.1", - "es-abstract": "^1.23.6", + "es-abstract": "^1.24.1", "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.0.3", + "es-set-tostringtag": "^2.1.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.6", + "get-intrinsic": "^1.3.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "internal-slot": "^1.1.0", - "iterator.prototype": "^1.1.4", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0", "safe-array-concat": "^1.1.3" }, "engines": { @@ -13669,6 +12255,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -13681,6 +12268,7 @@ "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7", @@ -13694,30 +12282,13 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-toolkit": { - "version": "1.42.0", - "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.42.0.tgz", - "integrity": "sha512-SLHIyY7VfDJBM8clz4+T2oquwTQxEzu263AyhVK4jREOAwJ+8eebaa4wM3nlvnAqhDrMm2EsA6hWHaQsMPQ1nA==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/es6-promise": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", - "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", - "license": "MIT" - }, "node_modules/esbuild": { - "version": "0.27.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", - "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -13725,32 +12296,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.2", - "@esbuild/android-arm": "0.27.2", - "@esbuild/android-arm64": "0.27.2", - "@esbuild/android-x64": "0.27.2", - "@esbuild/darwin-arm64": "0.27.2", - "@esbuild/darwin-x64": "0.27.2", - "@esbuild/freebsd-arm64": "0.27.2", - "@esbuild/freebsd-x64": "0.27.2", - "@esbuild/linux-arm": "0.27.2", - "@esbuild/linux-arm64": "0.27.2", - "@esbuild/linux-ia32": "0.27.2", - "@esbuild/linux-loong64": "0.27.2", - "@esbuild/linux-mips64el": "0.27.2", - "@esbuild/linux-ppc64": "0.27.2", - "@esbuild/linux-riscv64": "0.27.2", - "@esbuild/linux-s390x": "0.27.2", - "@esbuild/linux-x64": "0.27.2", - "@esbuild/netbsd-arm64": "0.27.2", - "@esbuild/netbsd-x64": "0.27.2", - "@esbuild/openbsd-arm64": "0.27.2", - "@esbuild/openbsd-x64": "0.27.2", - "@esbuild/openharmony-arm64": "0.27.2", - "@esbuild/sunos-x64": "0.27.2", - "@esbuild/win32-arm64": "0.27.2", - "@esbuild/win32-ia32": "0.27.2", - "@esbuild/win32-x64": "0.27.2" + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" } }, "node_modules/esbuild-node-externals": { @@ -13769,19 +12340,6 @@ "esbuild": "0.12 - 0.27" } }, - "node_modules/esbuild-register": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz", - "integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.4" - }, - "peerDependencies": { - "esbuild": ">=0.12 <1" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -13801,6 +12359,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -13810,33 +12369,30 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz", + "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", - "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", - "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.3", + "@eslint/config-helpers": "^0.5.2", + "@eslint/core": "^1.1.1", + "@eslint/plugin-kit": "^0.6.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "ajv": "^6.12.4", - "chalk": "^4.0.0", + "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.4.0", - "eslint-visitor-keys": "^4.2.1", - "espree": "^10.4.0", - "esquery": "^1.5.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.1.1", + "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", @@ -13846,8 +12402,7 @@ "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", + "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, @@ -13855,7 +12410,7 @@ "eslint": "bin/eslint.js" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://eslint.org/donate" @@ -13870,12 +12425,13 @@ } }, "node_modules/eslint-config-next": { - "version": "16.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.0.tgz", - "integrity": "sha512-RlPb8E2uO/Ix/w3kizxz6+6ogw99WqtNzTG0ArRZ5NEkIYcsfRb8U0j7aTG7NjRvcrsak5QtUSuxGNN2UcA58g==", + "version": "16.1.7", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.1.7.tgz", + "integrity": "sha512-FTq1i/QDltzq+zf9aB/cKWAiZ77baG0V7h8dRQh3thVx7I4dwr6ZXQrWKAaTB7x5VwVXlzoUTyMLIVQPLj2gJg==", + "dev": true, "license": "MIT", "dependencies": { - "@next/eslint-plugin-next": "16.1.0", + "@next/eslint-plugin-next": "16.1.7", "eslint-import-resolver-node": "^0.3.6", "eslint-import-resolver-typescript": "^3.5.2", "eslint-plugin-import": "^2.32.0", @@ -13895,42 +12451,29 @@ } } }, - "node_modules/eslint-config-next/node_modules/globals": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", - "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "node_modules/eslint-config-next/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "node_modules/eslint-config-next/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, "license": "MIT", "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" } }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-import-resolver-typescript": { + "node_modules/eslint-config-next/node_modules/eslint-import-resolver-typescript": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, "license": "ISC", "dependencies": { "@nolyfill/is-core-module": "1.0.39", @@ -13961,38 +12504,12 @@ } } }, - "node_modules/eslint-module-utils": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", - "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", - "license": "MIT", - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { + "node_modules/eslint-config-next/node_modules/eslint-plugin-import": { "version": "2.32.0", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -14021,28 +12538,21 @@ "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" } }, - "node_modules/eslint-plugin-import/node_modules/debug": { + "node_modules/eslint-config-next/node_modules/eslint-plugin-import/node_modules/debug": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.1" } }, - "node_modules/eslint-plugin-import/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/eslint-plugin-jsx-a11y": { + "node_modules/eslint-config-next/node_modules/eslint-plugin-jsx-a11y": { "version": "6.10.2", "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, "license": "MIT", "dependencies": { "aria-query": "^5.3.2", @@ -14068,10 +12578,11 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" } }, - "node_modules/eslint-plugin-react": { + "node_modules/eslint-config-next/node_modules/eslint-plugin-react": { "version": "7.37.5", "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, "license": "MIT", "dependencies": { "array-includes": "^3.1.8", @@ -14100,10 +12611,11 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, - "node_modules/eslint-plugin-react-hooks": { + "node_modules/eslint-config-next/node_modules/eslint-plugin-react-hooks": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.24.4", @@ -14119,10 +12631,71 @@ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, - "node_modules/eslint-plugin-react-hooks/node_modules/zod-validation-error": { + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-config-next/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/eslint-config-next/node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/eslint-config-next/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/eslint-config-next/node_modules/zod-validation-error": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=18.0.0" @@ -14131,81 +12704,111 @@ "zod": "^3.25.0 || ^4.0.0" } }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", + "node_modules/eslint-import-resolver-node": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", + "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", + "dev": true, "license": "MIT", "dependencies": { + "debug": "^3.2.7", "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "resolve": "^1.22.4" } }, - "node_modules/eslint-plugin-react/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" } }, "node_modules/eslint-scope": { - "version": "8.4.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", - "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", - "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, "license": "Apache-2.0", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/espree": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", - "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.1" + "eslint-visitor-keys": "^5.0.1" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" }, "funding": { "url": "https://opencollective.com/eslint" } }, "node_modules/esquery": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", - "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" @@ -14218,6 +12821,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -14230,6 +12834,7 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -14239,6 +12844,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -14254,9 +12860,9 @@ } }, "node_modules/eventemitter3": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", - "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, "node_modules/execa": { @@ -14297,7 +12903,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -14337,12 +12942,12 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz", + "integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==", "license": "MIT", "dependencies": { - "ip-address": "10.0.1" + "ip-address": "10.1.0" }, "engines": { "node": ">= 16" @@ -14383,12 +12988,23 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, "license": "MIT" }, + "node_modules/fast-equals": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.3.tgz", + "integrity": "sha512-/boTcHZeIAQ2r/tL11voclBHDeP9WPxLt+tyAbVSyyXuUFyh0Tne7gJZTqGbxnvj79TjLdCXLOY7UIPhyG5MTw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/fast-glob": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -14405,6 +13021,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -14417,12 +13034,14 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, "license": "MIT" }, "node_modules/fast-levenshtein": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, "license": "MIT" }, "node_modules/fast-sha256": { @@ -14448,10 +13067,10 @@ ], "license": "BSD-3-Clause" }, - "node_modules/fast-xml-parser": { - "version": "5.2.5", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", - "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "node_modules/fast-xml-builder": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "funding": [ { "type": "github", @@ -14460,7 +13079,24 @@ ], "license": "MIT", "dependencies": { - "strnum": "^2.1.0" + "path-expression-matcher": "^1.1.3" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz", + "integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.1.3", + "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" @@ -14470,6 +13106,7 @@ "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -14479,6 +13116,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -14498,33 +13136,11 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, - "node_modules/fetch-blob": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", - "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "paypal", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "dependencies": { - "node-domexception": "^1.0.0", - "web-streams-polyfill": "^3.0.3" - }, - "engines": { - "node": "^12.20 || >= 14.13" - } - }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, "license": "MIT", "dependencies": { "flat-cache": "^4.0.0" @@ -14585,6 +13201,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -14601,6 +13218,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, "license": "MIT", "dependencies": { "flatted": "^3.2.9", @@ -14614,6 +13232,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, "license": "ISC" }, "node_modules/fn.name": { @@ -14646,6 +13265,7 @@ "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, "license": "MIT", "dependencies": { "is-callable": "^1.2.7" @@ -14657,36 +13277,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -14724,18 +13314,6 @@ "node": ">= 0.6" } }, - "node_modules/formdata-polyfill": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", - "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "license": "MIT", - "dependencies": { - "fetch-blob": "^3.1.2" - }, - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -14795,6 +13373,7 @@ "version": "1.1.8", "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -14815,6 +13394,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -14824,6 +13404,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -14833,6 +13414,7 @@ "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -14922,6 +13504,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -14939,6 +13522,7 @@ "version": "4.13.0", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz", "integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==", + "dev": true, "license": "MIT", "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -14947,6 +13531,15 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", @@ -14954,17 +13547,17 @@ "license": "MIT" }, "node_modules/glob": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz", - "integrity": "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA==", + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", "license": "BlueOak-1.0.0", "dependencies": { - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -14974,6 +13567,7 @@ "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.3" @@ -14982,25 +13576,11 @@ "node": ">=10.13.0" } }, - "node_modules/glob/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -15010,6 +13590,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, "license": "MIT", "dependencies": { "define-properties": "^1.2.1", @@ -15066,6 +13647,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -15074,19 +13656,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/has-property-descriptors": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, "license": "MIT", "dependencies": { "es-define-property": "^1.0.0" @@ -15099,6 +13673,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.0" @@ -15162,12 +13737,14 @@ "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, "license": "MIT" }, "node_modules/hermes-parser": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, "license": "MIT", "dependencies": { "hermes-estree": "0.25.1" @@ -15238,14 +13815,6 @@ "node": ">=10.17.0" } }, - "node_modules/i": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/i/-/i-0.3.7.tgz", - "integrity": "sha512-FYz4wlXgkQwIPqhzC5TdNMLSE5+GS1IIDJZY/1ZiEPCT2S3COUVZeT5OW4BmW4r5LHLQuOosSwsvnroG9GR59Q==", - "engines": { - "node": ">=0.4" - } - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -15258,6 +13827,21 @@ "node": ">=0.10.0" } }, + "node_modules/icu-minify": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.3.tgz", + "integrity": "sha512-65Av7FLosNk7bPbmQx5z5XG2Y3T2GFppcjiXh4z1idHeVgQxlDpAmkGoYI0eFzAvrOnjpWTL5FmPDhsdfRMPEA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], + "license": "MIT", + "dependencies": { + "@formatjs/icu-messageformat-parser": "^3.4.0" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -15282,41 +13866,17 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 4" } }, - "node_modules/immer": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", - "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/immer" - } - }, - "node_modules/import-fresh": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", - "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "license": "MIT", - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.8.19" @@ -15348,6 +13908,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -15368,24 +13929,24 @@ } }, "node_modules/intl-messageformat": { - "version": "10.7.18", - "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-10.7.18.tgz", - "integrity": "sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.1.2.tgz", + "integrity": "sha512-ucSrQmZGAxfiBHfBRXW/k7UC8MaGFlEj4Ry1tKiDcmgwQm1y3EDl40u+4VNHYomxJQMJi9NEI3riDRlth96jKg==", "license": "BSD-3-Clause", "dependencies": { - "@formatjs/ecma402-abstract": "2.3.6", - "@formatjs/fast-memoize": "2.2.7", - "@formatjs/icu-messageformat-parser": "2.11.4", - "tslib": "^2.8.0" + "@formatjs/ecma402-abstract": "3.1.1", + "@formatjs/fast-memoize": "3.1.0", + "@formatjs/icu-messageformat-parser": "3.5.1", + "tslib": "^2.8.1" } }, "node_modules/ioredis": { - "version": "5.8.2", - "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.8.2.tgz", - "integrity": "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==", + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.0.tgz", + "integrity": "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA==", "license": "MIT", "dependencies": { - "@ioredis/commands": "1.4.0", + "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", @@ -15404,9 +13965,9 @@ } }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -15425,6 +13986,7 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -15442,6 +14004,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, "license": "MIT", "dependencies": { "async-function": "^1.0.0", @@ -15461,6 +14024,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, "license": "MIT", "dependencies": { "has-bigints": "^1.0.2" @@ -15489,6 +14053,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -15505,6 +14070,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, "license": "MIT", "dependencies": { "semver": "^7.7.1" @@ -15514,6 +14080,7 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -15526,6 +14093,7 @@ "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, "license": "MIT", "dependencies": { "hasown": "^2.0.2" @@ -15541,6 +14109,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -15558,6 +14127,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -15583,6 +14153,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -15594,20 +14165,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.4", @@ -15639,6 +14201,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -15651,6 +14214,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -15672,6 +14236,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -15694,6 +14259,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -15712,6 +14278,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -15724,6 +14291,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -15751,6 +14319,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -15767,6 +14336,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -15784,6 +14354,7 @@ "version": "1.1.15", "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, "license": "MIT", "dependencies": { "which-typed-array": "^1.1.16" @@ -15799,6 +14370,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -15811,6 +14383,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3" @@ -15826,6 +14399,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -15842,6 +14416,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, "license": "MIT" }, "node_modules/isexe": { @@ -15858,6 +14433,7 @@ "version": "1.1.5", "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -15871,27 +14447,11 @@ "node": ">= 0.4" } }, - "node_modules/jackspeak": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz", - "integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", - "devOptional": true, + "dev": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -15924,10 +14484,17 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT" + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" @@ -15940,12 +14507,14 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, "license": "MIT" }, "node_modules/json-schema-typed": { @@ -15959,12 +14528,14 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, "license": "MIT", "bin": { "json5": "lib/cli.js" @@ -15999,6 +14570,7 @@ "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, "license": "MIT", "dependencies": { "array-includes": "^3.1.6", @@ -16031,23 +14603,11 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/keygrip": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", - "integrity": "sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "MIT", - "dependencies": { - "tsscmp": "1.0.6" - }, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, "license": "MIT", "dependencies": { "json-buffer": "3.0.1" @@ -16073,12 +14633,14 @@ "version": "0.3.23", "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, "license": "CC0-1.0" }, "node_modules/language-tags": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, "license": "MIT", "dependencies": { "language-subtag-registry": "^0.3.20" @@ -16100,6 +14662,7 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1", @@ -16110,9 +14673,9 @@ } }, "node_modules/lightningcss": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", - "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", "dev": true, "license": "MPL-2.0", "dependencies": { @@ -16126,23 +14689,23 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-android-arm64": "1.30.2", - "lightningcss-darwin-arm64": "1.30.2", - "lightningcss-darwin-x64": "1.30.2", - "lightningcss-freebsd-x64": "1.30.2", - "lightningcss-linux-arm-gnueabihf": "1.30.2", - "lightningcss-linux-arm64-gnu": "1.30.2", - "lightningcss-linux-arm64-musl": "1.30.2", - "lightningcss-linux-x64-gnu": "1.30.2", - "lightningcss-linux-x64-musl": "1.30.2", - "lightningcss-win32-arm64-msvc": "1.30.2", - "lightningcss-win32-x64-msvc": "1.30.2" + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" } }, "node_modules/lightningcss-android-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", - "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", "cpu": [ "arm64" ], @@ -16161,9 +14724,9 @@ } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", - "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", "cpu": [ "arm64" ], @@ -16182,9 +14745,9 @@ } }, "node_modules/lightningcss-darwin-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", - "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", "cpu": [ "x64" ], @@ -16203,9 +14766,9 @@ } }, "node_modules/lightningcss-freebsd-x64": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", - "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", "cpu": [ "x64" ], @@ -16224,9 +14787,9 @@ } }, "node_modules/lightningcss-linux-arm-gnueabihf": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", - "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", "cpu": [ "arm" ], @@ -16245,9 +14808,9 @@ } }, "node_modules/lightningcss-linux-arm64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", - "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", "cpu": [ "arm64" ], @@ -16266,9 +14829,9 @@ } }, "node_modules/lightningcss-linux-arm64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", - "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", "cpu": [ "arm64" ], @@ -16287,9 +14850,9 @@ } }, "node_modules/lightningcss-linux-x64-gnu": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", - "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", "cpu": [ "x64" ], @@ -16308,9 +14871,9 @@ } }, "node_modules/lightningcss-linux-x64-musl": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", - "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", "cpu": [ "x64" ], @@ -16329,9 +14892,9 @@ } }, "node_modules/lightningcss-win32-arm64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", - "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", "cpu": [ "arm64" ], @@ -16350,9 +14913,9 @@ } }, "node_modules/lightningcss-win32-x64-msvc": { - "version": "1.30.2", - "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", - "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", "cpu": [ "x64" ], @@ -16374,6 +14937,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -16385,6 +14949,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.defaults": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", @@ -16433,12 +15003,6 @@ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", "license": "MIT" }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "license": "MIT" - }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -16478,15 +15042,16 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "license": "ISC", "dependencies": { "yallist": "^3.0.2" } }, "node_modules/lucide-react": { - "version": "0.562.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz", - "integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==", + "version": "0.577.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz", + "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -16524,13 +15089,13 @@ } }, "node_modules/maxmind": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/maxmind/-/maxmind-5.0.1.tgz", - "integrity": "sha512-hYxQxvHkBUlyF34f7IlQOb60rytezCi2oZ8H/BtZpcoodXTlcK1eLgf7kY2TofHqBC3o+Hqtvde9kS72gFQSDw==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/maxmind/-/maxmind-5.0.5.tgz", + "integrity": "sha512-1lcH2kMjbBpCFhuHaMU32vz8CuOsKttRcWMQyXvtlklopCzN7NNHSVR/h9RYa8JPuFTGmkn2vYARm+7cIGuqDw==", "license": "MIT", "dependencies": { - "mmdb-lib": "3.0.1", - "tiny-lru": "11.4.5" + "mmdb-lib": "3.0.2", + "tiny-lru": "11.4.7" }, "engines": { "node": ">=12", @@ -16592,6 +15157,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -16692,15 +15258,18 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "license": "ISC", + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^1.1.7" + "brace-expansion": "^5.0.2" }, "engines": { - "node": "*" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/minimist": { @@ -16713,10 +15282,10 @@ } }, "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", "engines": { "node": ">=16 || 14 >=14.17" } @@ -16728,9 +15297,9 @@ "license": "MIT" }, "node_modules/mmdb-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.1.tgz", - "integrity": "sha512-dyAyMR+cRykZd1mw5altC9f4vKpCsuywPwo8l/L5fKqDay2zmqT0mF/BvUoXnQiqGn+nceO914rkPKJoyFnGxA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mmdb-lib/-/mmdb-lib-3.0.2.tgz", + "integrity": "sha512-7e87vk0DdWT647wjcfEtWeMtjm+zVGqNohN/aeIymbUfjHQ2T4Sx5kM+1irVDBSloNC3CkGKxswdMoo8yhqTDg==", "license": "MIT", "engines": { "node": ">=10", @@ -16751,6 +15320,7 @@ "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", "license": "MIT", + "peer": true, "dependencies": { "dompurify": "3.2.7", "marked": "14.0.0" @@ -16761,6 +15331,7 @@ "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -16816,6 +15387,7 @@ "version": "0.3.4", "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, "license": "MIT", "bin": { "napi-postinstall": "lib/cli.js" @@ -16831,6 +15403,7 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, "license": "MIT" }, "node_modules/negotiator": { @@ -16843,13 +15416,11 @@ } }, "node_modules/next": { - "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, + "version": "15.5.12", + "resolved": "https://registry.npmjs.org/next/-/next-15.5.12.tgz", + "integrity": "sha512-Fi/wQ4Etlrn60rz78bebG1i1SR20QxvV8tVp6iJspjLUSHcZoeUXCt+vmWoEcza85ElZzExK/jJ/F6SvtGktjA==", "dependencies": { - "@next/env": "15.5.9", + "@next/env": "15.5.12", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", @@ -16862,14 +15433,14 @@ "node": "^18.18.0 || ^19.8.0 || >= 20.0.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "15.5.7", - "@next/swc-darwin-x64": "15.5.7", - "@next/swc-linux-arm64-gnu": "15.5.7", - "@next/swc-linux-arm64-musl": "15.5.7", - "@next/swc-linux-x64-gnu": "15.5.7", - "@next/swc-linux-x64-musl": "15.5.7", - "@next/swc-win32-arm64-msvc": "15.5.7", - "@next/swc-win32-x64-msvc": "15.5.7", + "@next/swc-darwin-arm64": "15.5.12", + "@next/swc-darwin-x64": "15.5.12", + "@next/swc-linux-arm64-gnu": "15.5.12", + "@next/swc-linux-arm64-musl": "15.5.12", + "@next/swc-linux-x64-gnu": "15.5.12", + "@next/swc-linux-x64-musl": "15.5.12", + "@next/swc-win32-arm64-msvc": "15.5.12", + "@next/swc-win32-x64-msvc": "15.5.12", "sharp": "^0.34.3" }, "peerDependencies": { @@ -16896,9 +15467,9 @@ } }, "node_modules/next-intl": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.6.1.tgz", - "integrity": "sha512-KlWgWtKLBPUsTPgxqwyjws1wCMD2QKxLlVjeeGj53DC1JWfKmBShKOrhIP0NznZrRQ0GleeoDUeHSETmyyIFeA==", + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/next-intl/-/next-intl-4.8.3.tgz", + "integrity": "sha512-PvdBDWg+Leh7BR7GJUQbCDVVaBRn37GwDBWc9sv0rVQOJDQ5JU1rVzx9EEGuOGYo0DHAl70++9LQ7HxTawdL7w==", "funding": [ { "type": "individual", @@ -16907,13 +15478,14 @@ ], "license": "MIT", "dependencies": { - "@formatjs/intl-localematcher": "^0.5.4", + "@formatjs/intl-localematcher": "^0.8.1", "@parcel/watcher": "^2.4.1", "@swc/core": "^1.15.2", + "icu-minify": "^4.8.3", "negotiator": "^1.0.0", - "next-intl-swc-plugin-extractor": "^4.6.1", - "po-parser": "^2.0.0", - "use-intl": "^4.6.1" + "next-intl-swc-plugin-extractor": "^4.8.3", + "po-parser": "^2.1.1", + "use-intl": "^4.8.3" }, "peerDependencies": { "next": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0", @@ -16927,9 +15499,9 @@ } }, "node_modules/next-intl-swc-plugin-extractor": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.6.1.tgz", - "integrity": "sha512-+HHNeVERfSvuPDF7LYVn3pxst5Rf7EYdUTw7C7WIrYhcLaKiZ1b9oSRkTQddAN3mifDMCfHqO4kAQ/pcKiBl3A==", + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/next-intl-swc-plugin-extractor/-/next-intl-swc-plugin-extractor-4.8.3.tgz", + "integrity": "sha512-YcaT+R9z69XkGhpDarVFWUprrCMbxgIQYPUaXoE6LGVnLjGdo8hu3gL6bramDVjNKViYY8a/pXPy7Bna0mXORg==", "license": "MIT" }, "node_modules/next-themes": { @@ -17027,55 +15599,46 @@ "node": ">= 8.0.0" } }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, "license": "MIT", "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" }, "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + "node": ">= 0.4" }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-exports-info/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, "license": "MIT" }, "node_modules/nodemailer": { - "version": "7.0.11", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.11.tgz", - "integrity": "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==", - "license": "MIT-0", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", "engines": { "node": ">=6.0.0" } @@ -17090,162 +15653,6 @@ "node": ">=0.10.0" } }, - "node_modules/npm": { - "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", - "@npmcli/config", - "@npmcli/fs", - "@npmcli/map-workspaces", - "@npmcli/metavuln-calculator", - "@npmcli/package-json", - "@npmcli/promise-spawn", - "@npmcli/redact", - "@npmcli/run-script", - "@sigstore/tuf", - "abbrev", - "archy", - "cacache", - "chalk", - "ci-info", - "cli-columns", - "fastest-levenshtein", - "fs-minipass", - "glob", - "graceful-fs", - "hosted-git-info", - "ini", - "init-package-json", - "is-cidr", - "json-parse-even-better-errors", - "libnpmaccess", - "libnpmdiff", - "libnpmexec", - "libnpmfund", - "libnpmorg", - "libnpmpack", - "libnpmpublish", - "libnpmsearch", - "libnpmteam", - "libnpmversion", - "make-fetch-happen", - "minimatch", - "minipass", - "minipass-pipeline", - "ms", - "node-gyp", - "nopt", - "npm-audit-report", - "npm-install-checks", - "npm-package-arg", - "npm-pick-manifest", - "npm-profile", - "npm-registry-fetch", - "npm-user-validate", - "p-map", - "pacote", - "parse-conflict-json", - "proc-log", - "qrcode-terminal", - "read", - "semver", - "spdx-expression-parse", - "ssri", - "supports-color", - "tar", - "text-table", - "tiny-relative-date", - "treeverse", - "validate-npm-package-name", - "which" - ], - "license": "Artistic-2.0", - "workspaces": [ - "docs", - "smoke-tests", - "mock-globals", - "mock-registry", - "workspaces/*" - ], - "dependencies": { - "@isaacs/string-locale-compare": "^1.1.0", - "@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", - "@npmcli/package-json": "^7.0.4", - "@npmcli/promise-spawn": "^9.0.1", - "@npmcli/redact": "^4.0.0", - "@npmcli/run-script": "^10.0.3", - "@sigstore/tuf": "^4.0.0", - "abbrev": "^4.0.0", - "archy": "~1.0.0", - "cacache": "^20.0.3", - "chalk": "^5.6.2", - "ci-info": "^4.3.1", - "cli-columns": "^4.0.0", - "fastest-levenshtein": "^1.0.16", - "fs-minipass": "^3.0.3", - "glob": "^13.0.0", - "graceful-fs": "^4.2.11", - "hosted-git-info": "^9.0.2", - "ini": "^6.0.0", - "init-package-json": "^8.2.4", - "is-cidr": "^6.0.1", - "json-parse-even-better-errors": "^5.0.0", - "libnpmaccess": "^10.0.3", - "libnpmdiff": "^8.0.12", - "libnpmexec": "^10.1.11", - "libnpmfund": "^7.0.12", - "libnpmorg": "^8.0.1", - "libnpmpack": "^9.0.12", - "libnpmpublish": "^11.1.3", - "libnpmsearch": "^9.0.1", - "libnpmteam": "^8.0.2", - "libnpmversion": "^8.0.3", - "make-fetch-happen": "^15.0.3", - "minimatch": "^10.1.1", - "minipass": "^7.1.1", - "minipass-pipeline": "^1.2.4", - "ms": "^2.1.2", - "node-gyp": "^12.1.0", - "nopt": "^9.0.0", - "npm-audit-report": "^7.0.0", - "npm-install-checks": "^8.0.0", - "npm-package-arg": "^13.0.2", - "npm-pick-manifest": "^11.0.3", - "npm-profile": "^12.0.1", - "npm-registry-fetch": "^19.1.1", - "npm-user-validate": "^4.0.0", - "p-map": "^7.0.4", - "pacote": "^21.0.4", - "parse-conflict-json": "^5.0.1", - "proc-log": "^6.1.0", - "qrcode-terminal": "^0.12.0", - "read": "^5.0.1", - "semver": "^7.7.3", - "spdx-expression-parse": "^4.0.0", - "ssri": "^13.0.0", - "supports-color": "^10.2.2", - "tar": "^7.5.2", - "text-table": "~0.2.0", - "tiny-relative-date": "^2.0.2", - "treeverse": "^3.0.0", - "validate-npm-package-name": "^7.0.0", - "which": "^6.0.0" - }, - "bin": { - "npm": "bin/npm-cli.js", - "npx": "bin/npx-cli.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -17259,1823 +15666,6 @@ "node": ">=8" } }, - "node_modules/npm/node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/npm/node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/npm/node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/npm/node_modules/@isaacs/string-locale-compare": { - "version": "1.1.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/@npmcli/agent": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "agent-base": "^7.1.0", - "http-proxy-agent": "^7.0.0", - "https-proxy-agent": "^7.0.1", - "lru-cache": "^11.2.1", - "socks-proxy-agent": "^8.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/arborist": { - "version": "9.1.9", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@isaacs/string-locale-compare": "^1.1.0", - "@npmcli/fs": "^5.0.0", - "@npmcli/installed-package-contents": "^4.0.0", - "@npmcli/map-workspaces": "^5.0.0", - "@npmcli/metavuln-calculator": "^9.0.2", - "@npmcli/name-from-folder": "^4.0.0", - "@npmcli/node-gyp": "^5.0.0", - "@npmcli/package-json": "^7.0.0", - "@npmcli/query": "^5.0.0", - "@npmcli/redact": "^4.0.0", - "@npmcli/run-script": "^10.0.0", - "bin-links": "^6.0.0", - "cacache": "^20.0.1", - "common-ancestor-path": "^1.0.1", - "hosted-git-info": "^9.0.0", - "json-stringify-nice": "^1.1.4", - "lru-cache": "^11.2.1", - "minimatch": "^10.0.3", - "nopt": "^9.0.0", - "npm-install-checks": "^8.0.0", - "npm-package-arg": "^13.0.0", - "npm-pick-manifest": "^11.0.1", - "npm-registry-fetch": "^19.0.0", - "pacote": "^21.0.2", - "parse-conflict-json": "^5.0.1", - "proc-log": "^6.0.0", - "proggy": "^4.0.0", - "promise-all-reject-late": "^1.0.0", - "promise-call-limit": "^3.0.1", - "semver": "^7.3.7", - "ssri": "^13.0.0", - "treeverse": "^3.0.0", - "walk-up-path": "^4.0.0" - }, - "bin": { - "arborist": "bin/index.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/config": { - "version": "10.4.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/map-workspaces": "^5.0.0", - "@npmcli/package-json": "^7.0.0", - "ci-info": "^4.0.0", - "ini": "^6.0.0", - "nopt": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "walk-up-path": "^4.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/fs": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/git": { - "version": "7.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/promise-spawn": "^9.0.0", - "ini": "^6.0.0", - "lru-cache": "^11.2.1", - "npm-pick-manifest": "^11.0.1", - "proc-log": "^6.0.0", - "promise-retry": "^2.0.1", - "semver": "^7.3.5", - "which": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/installed-package-contents": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-bundled": "^5.0.0", - "npm-normalize-package-bin": "^5.0.0" - }, - "bin": { - "installed-package-contents": "bin/index.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/map-workspaces": { - "version": "5.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/name-from-folder": "^4.0.0", - "@npmcli/package-json": "^7.0.0", - "glob": "^13.0.0", - "minimatch": "^10.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/metavuln-calculator": { - "version": "9.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "cacache": "^20.0.0", - "json-parse-even-better-errors": "^5.0.0", - "pacote": "^21.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/name-from-folder": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/node-gyp": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/package-json": { - "version": "7.0.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^7.0.0", - "glob": "^13.0.0", - "hosted-git-info": "^9.0.0", - "json-parse-even-better-errors": "^5.0.0", - "proc-log": "^6.0.0", - "semver": "^7.5.3", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/promise-spawn": { - "version": "9.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "which": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/query": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "postcss-selector-parser": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/redact": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@npmcli/run-script": { - "version": "10.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/node-gyp": "^5.0.0", - "@npmcli/package-json": "^7.0.0", - "@npmcli/promise-spawn": "^9.0.0", - "node-gyp": "^12.1.0", - "proc-log": "^6.0.0", - "which": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@sigstore/bundle": { - "version": "4.0.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.5.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@sigstore/core": { - "version": "3.0.0", - "inBundle": true, - "license": "Apache-2.0", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@sigstore/protobuf-specs": { - "version": "0.5.0", - "inBundle": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@sigstore/sign": { - "version": "4.0.1", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.0.0", - "@sigstore/protobuf-specs": "^0.5.0", - "make-fetch-happen": "^15.0.2", - "proc-log": "^5.0.0", - "promise-retry": "^2.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@sigstore/sign/node_modules/proc-log": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^18.17.0 || >=20.5.0" - } - }, - "node_modules/npm/node_modules/@sigstore/tuf": { - "version": "4.0.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/protobuf-specs": "^0.5.0", - "tuf-js": "^4.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@sigstore/verify": { - "version": "3.0.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.0.0", - "@sigstore/protobuf-specs": "^0.5.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@tufjs/canonical-json": { - "version": "2.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/@tufjs/models": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "@tufjs/canonical-json": "2.0.0", - "minimatch": "^9.0.5" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/@tufjs/models/node_modules/minimatch": { - "version": "9.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/abbrev": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/agent-base": { - "version": "7.1.4", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/ansi-regex": { - "version": "5.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/aproba": { - "version": "2.1.0", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/archy": { - "version": "1.0.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/balanced-match": { - "version": "1.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/bin-links": { - "version": "6.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "cmd-shim": "^8.0.0", - "npm-normalize-package-bin": "^5.0.0", - "proc-log": "^6.0.0", - "read-cmd-shim": "^6.0.0", - "write-file-atomic": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/binary-extensions": { - "version": "3.1.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/brace-expansion": { - "version": "2.0.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/npm/node_modules/cacache": { - "version": "20.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/fs": "^5.0.0", - "fs-minipass": "^3.0.0", - "glob": "^13.0.0", - "lru-cache": "^11.1.0", - "minipass": "^7.0.3", - "minipass-collect": "^2.0.1", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^7.0.2", - "ssri": "^13.0.0", - "unique-filename": "^5.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/chalk": { - "version": "5.6.2", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/npm/node_modules/chownr": { - "version": "3.0.0", - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/ci-info": { - "version": "4.3.1", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/cidr-regex": { - "version": "5.0.1", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "ip-regex": "5.0.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/npm/node_modules/cli-columns": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">= 10" - } - }, - "node_modules/npm/node_modules/cmd-shim": { - "version": "8.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/common-ancestor-path": { - "version": "1.0.1", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/cssesc": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm/node_modules/debug": { - "version": "4.4.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/npm/node_modules/diff": { - "version": "8.0.2", - "inBundle": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/npm/node_modules/emoji-regex": { - "version": "8.0.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/encoding": { - "version": "0.1.13", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/npm/node_modules/env-paths": { - "version": "2.2.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/npm/node_modules/err-code": { - "version": "2.0.3", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/exponential-backoff": { - "version": "3.1.3", - "inBundle": true, - "license": "Apache-2.0" - }, - "node_modules/npm/node_modules/fastest-levenshtein": { - "version": "1.0.16", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4.9.1" - } - }, - "node_modules/npm/node_modules/fs-minipass": { - "version": "3.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/glob": { - "version": "13.0.0", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "path-scurry": "^2.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/graceful-fs": { - "version": "4.2.11", - "inBundle": true, - "license": "ISC" - }, - "node_modules/npm/node_modules/hosted-git-info": { - "version": "9.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "lru-cache": "^11.1.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/http-cache-semantics": { - "version": "4.2.0", - "inBundle": true, - "license": "BSD-2-Clause" - }, - "node_modules/npm/node_modules/http-proxy-agent": { - "version": "7.0.2", - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/https-proxy-agent": { - "version": "7.0.6", - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/iconv-lite": { - "version": "0.6.3", - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/npm/node_modules/ignore-walk": { - "version": "8.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minimatch": "^10.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/imurmurhash": { - "version": "0.1.4", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=0.8.19" - } - }, - "node_modules/npm/node_modules/ini": { - "version": "6.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/init-package-json": { - "version": "8.2.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/package-json": "^7.0.0", - "npm-package-arg": "^13.0.0", - "promzard": "^3.0.1", - "read": "^5.0.1", - "semver": "^7.7.2", - "validate-npm-package-license": "^3.0.4", - "validate-npm-package-name": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/ip-address": { - "version": "10.0.1", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, - "node_modules/npm/node_modules/ip-regex": { - "version": "5.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/is-cidr": { - "version": "6.0.1", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "cidr-regex": "5.0.1" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/npm/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/isexe": { - "version": "3.1.1", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=16" - } - }, - "node_modules/npm/node_modules/json-parse-even-better-errors": { - "version": "5.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/json-stringify-nice": { - "version": "1.1.4", - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/jsonparse": { - "version": "1.3.1", - "engines": [ - "node >= 0.2.0" - ], - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/just-diff": { - "version": "6.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/just-diff-apply": { - "version": "5.5.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/libnpmaccess": { - "version": "10.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-package-arg": "^13.0.0", - "npm-registry-fetch": "^19.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmdiff": { - "version": "8.0.12", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^9.1.9", - "@npmcli/installed-package-contents": "^4.0.0", - "binary-extensions": "^3.0.0", - "diff": "^8.0.2", - "minimatch": "^10.0.3", - "npm-package-arg": "^13.0.0", - "pacote": "^21.0.2", - "tar": "^7.5.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmexec": { - "version": "10.1.11", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^9.1.9", - "@npmcli/package-json": "^7.0.0", - "@npmcli/run-script": "^10.0.0", - "ci-info": "^4.0.0", - "npm-package-arg": "^13.0.0", - "pacote": "^21.0.2", - "proc-log": "^6.0.0", - "promise-retry": "^2.0.1", - "read": "^5.0.1", - "semver": "^7.3.7", - "signal-exit": "^4.1.0", - "walk-up-path": "^4.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmfund": { - "version": "7.0.12", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^9.1.9" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmorg": { - "version": "8.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^19.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmpack": { - "version": "9.0.12", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/arborist": "^9.1.9", - "@npmcli/run-script": "^10.0.0", - "npm-package-arg": "^13.0.0", - "pacote": "^21.0.2" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmpublish": { - "version": "11.1.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/package-json": "^7.0.0", - "ci-info": "^4.0.0", - "npm-package-arg": "^13.0.0", - "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.7", - "sigstore": "^4.0.0", - "ssri": "^13.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmsearch": { - "version": "9.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-registry-fetch": "^19.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmteam": { - "version": "8.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "aproba": "^2.0.0", - "npm-registry-fetch": "^19.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/libnpmversion": { - "version": "8.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^7.0.0", - "@npmcli/run-script": "^10.0.0", - "json-parse-even-better-errors": "^5.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.7" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/lru-cache": { - "version": "11.2.2", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/npm/node_modules/make-fetch-happen": { - "version": "15.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/agent": "^4.0.0", - "cacache": "^20.0.1", - "http-cache-semantics": "^4.1.1", - "minipass": "^7.0.2", - "minipass-fetch": "^5.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^1.0.0", - "proc-log": "^6.0.0", - "promise-retry": "^2.0.1", - "ssri": "^13.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/minimatch": { - "version": "10.1.1", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/minipass": { - "version": "7.1.2", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/npm/node_modules/minipass-collect": { - "version": "2.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/npm/node_modules/minipass-fetch": { - "version": "5.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^3.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/npm/node_modules/minipass-flush": { - "version": "1.0.5", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/npm/node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-pipeline": { - "version": "1.2.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-sized": { - "version": "1.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "inBundle": true, - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/minizlib": { - "version": "3.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/npm/node_modules/ms": { - "version": "2.1.3", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/mute-stream": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/negotiator": { - "version": "1.0.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/npm/node_modules/node-gyp": { - "version": "12.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^15.0.0", - "nopt": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "tar": "^7.5.2", - "tinyglobby": "^0.2.12", - "which": "^6.0.0" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/nopt": { - "version": "9.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "abbrev": "^4.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/npm-audit-report": { - "version": "7.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/npm-bundled": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-normalize-package-bin": "^5.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/npm-install-checks": { - "version": "8.0.0", - "inBundle": true, - "license": "BSD-2-Clause", - "dependencies": { - "semver": "^7.1.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/npm-normalize-package-bin": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/npm-package-arg": { - "version": "13.0.2", - "inBundle": true, - "license": "ISC", - "dependencies": { - "hosted-git-info": "^9.0.0", - "proc-log": "^6.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^7.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/npm-packlist": { - "version": "10.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "ignore-walk": "^8.0.0", - "proc-log": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/npm-pick-manifest": { - "version": "11.0.3", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-install-checks": "^8.0.0", - "npm-normalize-package-bin": "^5.0.0", - "npm-package-arg": "^13.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/npm-profile": { - "version": "12.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/npm-registry-fetch": { - "version": "19.1.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/redact": "^4.0.0", - "jsonparse": "^1.3.1", - "make-fetch-happen": "^15.0.0", - "minipass": "^7.0.2", - "minipass-fetch": "^5.0.0", - "minizlib": "^3.0.1", - "npm-package-arg": "^13.0.0", - "proc-log": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/npm-user-validate": { - "version": "4.0.0", - "inBundle": true, - "license": "BSD-2-Clause", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/p-map": { - "version": "7.0.4", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm/node_modules/pacote": { - "version": "21.0.4", - "inBundle": true, - "license": "ISC", - "dependencies": { - "@npmcli/git": "^7.0.0", - "@npmcli/installed-package-contents": "^4.0.0", - "@npmcli/package-json": "^7.0.0", - "@npmcli/promise-spawn": "^9.0.0", - "@npmcli/run-script": "^10.0.0", - "cacache": "^20.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^7.0.2", - "npm-package-arg": "^13.0.0", - "npm-packlist": "^10.0.1", - "npm-pick-manifest": "^11.0.1", - "npm-registry-fetch": "^19.0.0", - "proc-log": "^6.0.0", - "promise-retry": "^2.0.1", - "sigstore": "^4.0.0", - "ssri": "^13.0.0", - "tar": "^7.4.3" - }, - "bin": { - "pacote": "bin/index.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/parse-conflict-json": { - "version": "5.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "json-parse-even-better-errors": "^5.0.0", - "just-diff": "^6.0.0", - "just-diff-apply": "^5.2.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/path-scurry": { - "version": "2.0.0", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^11.0.0", - "minipass": "^7.1.2" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/postcss-selector-parser": { - "version": "7.1.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/npm/node_modules/proc-log": { - "version": "6.1.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/proggy": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/promise-all-reject-late": { - "version": "1.0.1", - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/promise-call-limit": { - "version": "3.0.2", - "inBundle": true, - "license": "ISC", - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/promise-retry": { - "version": "2.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/promzard": { - "version": "3.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "read": "^5.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/qrcode-terminal": { - "version": "0.12.0", - "inBundle": true, - "bin": { - "qrcode-terminal": "bin/qrcode-terminal.js" - } - }, - "node_modules/npm/node_modules/read": { - "version": "5.0.1", - "inBundle": true, - "license": "ISC", - "dependencies": { - "mute-stream": "^3.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/read-cmd-shim": { - "version": "6.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/retry": { - "version": "0.12.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 4" - } - }, - "node_modules/npm/node_modules/safer-buffer": { - "version": "2.1.2", - "inBundle": true, - "license": "MIT", - "optional": true - }, - "node_modules/npm/node_modules/semver": { - "version": "7.7.3", - "inBundle": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/npm/node_modules/signal-exit": { - "version": "4.1.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/npm/node_modules/sigstore": { - "version": "4.0.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "@sigstore/bundle": "^4.0.0", - "@sigstore/core": "^3.0.0", - "@sigstore/protobuf-specs": "^0.5.0", - "@sigstore/sign": "^4.0.0", - "@sigstore/tuf": "^4.0.0", - "@sigstore/verify": "^3.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/smart-buffer": { - "version": "4.2.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">= 6.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/npm/node_modules/socks": { - "version": "2.8.7", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ip-address": "^10.0.1", - "smart-buffer": "^4.2.0" - }, - "engines": { - "node": ">= 10.0.0", - "npm": ">= 3.0.0" - } - }, - "node_modules/npm/node_modules/socks-proxy-agent": { - "version": "8.0.5", - "inBundle": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "^4.3.4", - "socks": "^2.8.3" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/npm/node_modules/spdx-correct": { - "version": "3.2.0", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-expression-parse": "^3.0.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-correct/node_modules/spdx-expression-parse": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-exceptions": { - "version": "2.5.0", - "inBundle": true, - "license": "CC-BY-3.0" - }, - "node_modules/npm/node_modules/spdx-expression-parse": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/spdx-license-ids": { - "version": "3.0.22", - "inBundle": true, - "license": "CC0-1.0" - }, - "node_modules/npm/node_modules/ssri": { - "version": "13.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/string-width": { - "version": "4.2.3", - "inBundle": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/strip-ansi": { - "version": "6.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/npm/node_modules/supports-color": { - "version": "10.2.2", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, - "node_modules/npm/node_modules/tar": { - "version": "7.5.2", - "inBundle": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.1.0", - "yallist": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/tar/node_modules/yallist": { - "version": "5.0.0", - "inBundle": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/npm/node_modules/text-table": { - "version": "0.2.0", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/tiny-relative-date": { - "version": "2.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/tinyglobby": { - "version": "0.2.15", - "inBundle": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/npm/node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "inBundle": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/npm/node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "inBundle": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/npm/node_modules/treeverse": { - "version": "3.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm/node_modules/tuf-js": { - "version": "4.0.0", - "inBundle": true, - "license": "MIT", - "dependencies": { - "@tufjs/models": "4.0.0", - "debug": "^4.4.1", - "make-fetch-happen": "^15.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/unique-filename": { - "version": "5.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "unique-slug": "^6.0.0" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/unique-slug": { - "version": "6.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/util-deprecate": { - "version": "1.0.2", - "inBundle": true, - "license": "MIT" - }, - "node_modules/npm/node_modules/validate-npm-package-license": { - "version": "3.0.4", - "inBundle": true, - "license": "Apache-2.0", - "dependencies": { - "spdx-correct": "^3.0.0", - "spdx-expression-parse": "^3.0.0" - } - }, - "node_modules/npm/node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { - "version": "3.0.1", - "inBundle": true, - "license": "MIT", - "dependencies": { - "spdx-exceptions": "^2.1.0", - "spdx-license-ids": "^3.0.0" - } - }, - "node_modules/npm/node_modules/validate-npm-package-name": { - "version": "7.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/walk-up-path": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC", - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/npm/node_modules/which": { - "version": "6.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "isexe": "^3.1.1" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/write-file-atomic": { - "version": "7.0.0", - "inBundle": true, - "license": "ISC", - "dependencies": { - "imurmurhash": "^0.1.4", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": "^20.17.0 || >=22.9.0" - } - }, - "node_modules/npm/node_modules/yallist": { - "version": "4.0.0", - "inBundle": true, - "license": "ISC" - }, "node_modules/nprogress": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", @@ -19083,17 +15673,17 @@ "license": "MIT" }, "node_modules/nypm": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.0.tgz", - "integrity": "sha512-mn8wBFV9G9+UFHIrq+pZ2r2zL4aPau/by3kJb3cM7+5tQHMt6HGQB8FDIeKFYp8o0D2pnH6nVsO88N4AmUxIWg==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", "dev": true, "license": "MIT", "dependencies": { "citty": "^0.1.6", - "consola": "^3.4.0", + "consola": "^3.4.2", "pathe": "^2.0.3", - "pkg-types": "^2.0.0", - "tinyexec": "^0.3.2" + "pkg-types": "^2.3.0", + "tinyexec": "^1.0.1" }, "bin": { "nypm": "dist/cli.mjs" @@ -19136,6 +15726,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -19155,6 +15746,7 @@ "version": "4.1.7", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -19175,6 +15767,7 @@ "version": "1.1.9", "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -19190,6 +15783,7 @@ "version": "2.0.8", "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -19208,6 +15802,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -19222,6 +15817,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -19291,19 +15887,11 @@ "yaml": "^2.8.0" } }, - "node_modules/optimist": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/optimist/-/optimist-0.3.7.tgz", - "integrity": "sha512-TCx0dXQzVtSCg2OgY/bO9hjM9cV4XYx09TVK+s3+FhkjT6LovsLe+pPMzpWf+6yXK/hUizs2gUoTw3jHM0VaTQ==", - "license": "MIT/X11", - "dependencies": { - "wordwrap": "~0.0.2" - } - }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, "license": "MIT", "dependencies": { "deep-is": "^0.1.3", @@ -19614,6 +16202,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, "license": "MIT", "dependencies": { "get-intrinsic": "^1.2.6", @@ -19631,6 +16220,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -19646,6 +16236,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -19657,25 +16248,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "license": "MIT", - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/parseley": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/parseley/-/parseley-0.12.1.tgz", @@ -19702,11 +16274,27 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -19720,19 +16308,20 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, "license": "MIT" }, "node_modules/path-scurry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz", - "integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", "license": "BlueOak-1.0.0", "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" }, "engines": { - "node": "20 || >=22" + "node": "18 || 20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -19784,15 +16373,14 @@ } }, "node_modules/pg": { - "version": "8.16.3", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", - "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "license": "MIT", - "peer": true, "dependencies": { - "pg-connection-string": "^2.9.1", - "pg-pool": "^3.10.1", - "pg-protocol": "^1.10.3", + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -19800,7 +16388,7 @@ "node": ">= 16.0.0" }, "optionalDependencies": { - "pg-cloudflare": "^1.2.7" + "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" @@ -19812,16 +16400,16 @@ } }, "node_modules/pg-cloudflare": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz", - "integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", "license": "MIT", "optional": true }, "node_modules/pg-connection-string": { - "version": "2.9.1", - "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz", - "integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==", + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", "license": "MIT" }, "node_modules/pg-int8": { @@ -19834,18 +16422,18 @@ } }, "node_modules/pg-pool": { - "version": "3.10.1", - "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz", - "integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==", + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", "license": "MIT", "peerDependencies": { "pg": ">=8.0" } }, "node_modules/pg-protocol": { - "version": "1.10.3", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz", - "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", "license": "MIT" }, "node_modules/pg-types": { @@ -19883,6 +16471,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -19917,24 +16506,31 @@ } }, "node_modules/po-parser": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.0.0.tgz", - "integrity": "sha512-SZvoKi3PoI/hHa2V9je9CW7Xgxl4dvO74cvaa6tWShIHT51FkPxje6pt0gTJznJrU67ix91nDaQp2hUxkOYhKA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/po-parser/-/po-parser-2.1.1.tgz", + "integrity": "sha512-ECF4zHLbUItpUgE3OTtLKlPjeBN+fKEczj2zYjDfCGOzicNs0GK3Vg2IoAYwx7LH/XYw43fZQP6xnZ4TkNxSLQ==", "license": "MIT" }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" } }, + "node_modules/postal-mime": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.3.tgz", + "integrity": "sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==", + "license": "MIT-0" + }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", "dev": true, "funding": [ { @@ -20000,15 +16596,23 @@ } }, "node_modules/posthog-node": { - "version": "5.17.4", - "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.17.4.tgz", - "integrity": "sha512-hrd+Do/DMt40By12ESIDUfD81V9OASjq9XHjycZrGiD8cX/ZwCIVSJLUb7nQmvSCWcKII+u+nnPVuc4LjTDl9g==", + "version": "5.28.0", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.28.0.tgz", + "integrity": "sha512-EETYV0zA+7BLQmXzY+vGyDMoQK8uHf8f/1utbRjKncI41gPkw+4piGP7l4UT5Luld+4vQpJPOR1q1YrbXm7XjQ==", "license": "MIT", "dependencies": { - "@posthog/core": "1.8.1" + "@posthog/core": "1.23.2" }, "engines": { - "node": ">=20" + "node": "^20.20.0 || >=22.22.0" + }, + "peerDependencies": { + "rxjs": "^7.0.0" + }, + "peerDependenciesMeta": { + "rxjs": { + "optional": true + } } }, "node_modules/prebuild-install": { @@ -20041,15 +16645,16 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.8.0" } }, "node_modules/prettier": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", - "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" @@ -20128,6 +16733,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -20161,9 +16767,9 @@ } }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -20175,12 +16781,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/querystringify": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", - "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", - "license": "MIT" - }, "node_modules/queue-lit": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/queue-lit/-/queue-lit-1.5.2.tgz", @@ -20195,6 +16795,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, "funding": [ { "type": "github", @@ -20235,26 +16836,6 @@ "node": ">= 0.10" } }, - "node_modules/raw-body/node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/raw-body/node_modules/iconv-lite": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", @@ -20296,24 +16877,24 @@ } }, "node_modules/react": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", - "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } }, "node_modules/react-day-picker": { - "version": "9.13.0", - "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.13.0.tgz", - "integrity": "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ==", + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.14.0.tgz", + "integrity": "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==", "license": "MIT", "dependencies": { "@date-fns/tz": "^1.4.1", + "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", - "date-fns-jalali": "^4.1.0-0" + "date-fns-jalali": "4.1.0-0" }, "engines": { "node": ">=18" @@ -20327,16 +16908,15 @@ } }, "node_modules/react-dom": { - "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==", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.3" + "react": "^19.2.4" } }, "node_modules/react-easy-sort": { @@ -20356,479 +16936,37 @@ } }, "node_modules/react-email": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/react-email/-/react-email-5.0.7.tgz", - "integrity": "sha512-JsWzxl3O82Gw9HRRNJm8VjQLB8c7R5TGbP89Ffj+/Qdb2H2N4J0XRXkhqiucMvmucuqNqe9mNndZkh3jh638xA==", + "version": "5.2.10", + "resolved": "https://registry.npmjs.org/react-email/-/react-email-5.2.10.tgz", + "integrity": "sha512-Ys8yR5/a0nXf5u2GlT2UV93PJHC3ZnuMnNebEn7I5UE9XfMFPtlpgDs02mPJOJn49fhJjDTWIUlZD1vmQPDgJg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.0", - "@babel/traverse": "^7.27.0", + "@babel/parser": "7.27.0", + "@babel/traverse": "7.27.0", "chokidar": "^4.0.3", "commander": "^13.0.0", "conf": "^15.0.2", "debounce": "^2.0.0", - "esbuild": "^0.25.0", - "glob": "^11.0.0", + "esbuild": "0.27.3", + "glob": "^13.0.6", "jiti": "2.4.2", "log-symbols": "^7.0.0", "mime-types": "^3.0.0", "normalize-path": "^3.0.0", - "nypm": "0.6.0", + "nypm": "0.6.2", "ora": "^8.0.0", "prompts": "2.4.2", "socket.io": "^4.8.1", "tsconfig-paths": "4.2.0" }, "bin": { - "email": "dist/index.js" + "email": "dist/index.mjs" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/react-email/node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/react-email/node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, "node_modules/react-email/node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -20875,72 +17013,6 @@ "dev": true, "license": "MIT" }, - "node_modules/react-email/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, - "node_modules/react-email/node_modules/glob": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", - "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "foreground-child": "^3.3.1", - "jackspeak": "^4.1.1", - "minimatch": "^10.1.1", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^2.0.0" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/react-email/node_modules/is-interactive": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz", @@ -20994,22 +17066,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/react-email/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" - }, - "engines": { - "node": "20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/react-email/node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -21144,11 +17200,10 @@ } }, "node_modules/react-hook-form": { - "version": "7.68.0", - "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.68.0.tgz", - "integrity": "sha512-oNN3fjrZ/Xo40SWlHf1yCjlMK417JxoSJVUXQjGdvdRCU07NTFei1i1f8ApUAts+IVh14e4EdakeLEA+BEAs/Q==", + "version": "7.71.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.71.2.tgz", + "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -21161,9 +17216,9 @@ } }, "node_modules/react-icons": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", - "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", "license": "MIT", "peerDependencies": { "react": "*" @@ -21173,32 +17228,7 @@ "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true - }, - "node_modules/react-redux": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", - "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@types/use-sync-external-store": "^0.0.6", - "use-sync-external-store": "^1.4.0" - }, - "peerDependencies": { - "@types/react": "^18.2.25 || ^19", - "react": "^18.0 || ^19", - "redux": "^5.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "redux": { - "optional": true - } - } + "license": "MIT" }, "node_modules/react-remove-scroll": { "version": "2.7.2", @@ -21247,6 +17277,21 @@ } } }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/react-style-singleton": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", @@ -21269,6 +17314,22 @@ } } }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -21297,50 +17358,44 @@ "url": "https://paulmillr.com/funding/" } }, - "node_modules/rebuild": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/rebuild/-/rebuild-0.1.2.tgz", - "integrity": "sha512-EtDZ5IapND57htCrOOcfH7MzXCQKivzSZUIZIuc8H0xDHfmi9HDBZIyjT7Neh5GcUoxQ6hfsXluC+UrYLgGbZg==", - "dependencies": { - "optimist": "0.3.x" - }, - "bin": { - "rebuild": "cli.js" - }, - "engines": { - "node": ">=0.8.8" - } - }, "node_modules/recharts": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.5.1.tgz", - "integrity": "sha512-+v+HJojK7gnEgG6h+b2u7k8HH7FhyFUzAc4+cPrsjL4Otdgqr/ecXzAnHciqlzV1ko064eNcsdzrYOM78kankA==", + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", "license": "MIT", - "workspaces": [ - "www" - ], "dependencies": { - "@reduxjs/toolkit": "1.x.x || 2.x.x", - "clsx": "^2.1.1", - "decimal.js-light": "^2.5.1", - "es-toolkit": "^1.39.3", - "eventemitter3": "^5.0.1", - "immer": "^10.1.1", - "react-redux": "8.x.x || 9.x.x", - "reselect": "5.1.1", - "tiny-invariant": "^1.3.3", - "use-sync-external-store": "^1.2.2", - "victory-vendor": "^37.0.2" + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" }, "engines": { - "node": ">=18" + "node": ">=14" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/recharts/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, "node_modules/redis-errors": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", @@ -21362,22 +17417,6 @@ "node": ">=4" } }, - "node_modules/redux": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", - "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true - }, - "node_modules/redux-thunk": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", - "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", - "license": "MIT", - "peerDependencies": { - "redux": "^5.0.0" - } - }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", @@ -21388,6 +17427,7 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -21410,6 +17450,7 @@ "version": "1.5.4", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -21426,11 +17467,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/reo-census": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/reo-census/-/reo-census-1.2.8.tgz", + "integrity": "sha512-UMwpNwOieUTeymIITWCbo0In0FHGWZwnXIIYphpCPO/Bjl5z/385DWLnTxBcfFFCH/r/fEq/TZ0ivlPb6Smi9Q==", + "hasInstallScript": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/reodotdev": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/reodotdev/-/reodotdev-1.0.0.tgz", - "integrity": "sha512-wXe1vJucZjrhQL0SxOL9EvmJrtbMCIEGMdZX5lj/57n2T3UhBHZsAcM5TQASJ0T6ZBbrETRnMhH33bsbJeRO6Q==", - "license": "MIT" + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reodotdev/-/reodotdev-1.1.0.tgz", + "integrity": "sha512-BeIlYk59p4Gw+zPHJj249xPBQ0wHfI8NsksVFRTdPLkPXDSYyn6IBvbR0s7pELK9qk3p79UBcBWP84IsYLsvbg==", + "license": "MIT", + "dependencies": { + "reo-census": "^1.2.6" + } }, "node_modules/require-from-string": { "version": "2.0.2", @@ -21442,25 +17496,14 @@ "node": ">=0.10.0" } }, - "node_modules/requires-port": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", - "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", - "license": "MIT" - }, - "node_modules/reselect": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", - "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", - "license": "MIT" - }, "node_modules/resend": { - "version": "6.6.0", - "resolved": "https://registry.npmjs.org/resend/-/resend-6.6.0.tgz", - "integrity": "sha512-d1WoOqSxj5x76JtQMrieNAG1kZkh4NU4f+Je1yq4++JsDpLddhEwnJlNfvkCzvUuZy9ZquWmMMAm2mENd2JvRw==", + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.9.2.tgz", + "integrity": "sha512-uIM6CQ08tS+hTCRuKBFbOBvHIGaEhqZe8s4FOgqsVXSbQLAhmNWpmUhG3UAtRnmcwTWFUqnHa/+Vux8YGPyDBA==", "license": "MIT", "dependencies": { - "svix": "1.76.1" + "postal-mime": "2.7.3", + "svix": "1.84.1" }, "engines": { "node": ">=20" @@ -21478,6 +17521,7 @@ "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", + "dev": true, "license": "MIT", "dependencies": { "is-core-module": "^2.16.1", @@ -21494,19 +17538,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" @@ -21516,6 +17552,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -21548,6 +17585,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, "funding": [ { "type": "github", @@ -21577,6 +17615,7 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", "integrity": "sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -21616,6 +17655,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -21632,6 +17672,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -21679,9 +17720,9 @@ } }, "node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -21731,6 +17772,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -21748,6 +17790,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.1.4", @@ -21763,6 +17806,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, "license": "MIT", "dependencies": { "dunder-proto": "^1.0.1", @@ -22190,10 +18234,36 @@ "node": ">= 10.x" } }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, "license": "MIT" }, "node_modules/stack-trace": { @@ -22211,6 +18281,16 @@ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", "license": "MIT" }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/state-local": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", @@ -22243,6 +18323,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -22261,74 +18342,11 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string.prototype.includes": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -22343,6 +18361,7 @@ "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -22370,6 +18389,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, "license": "MIT", "dependencies": { "define-properties": "^1.1.3", @@ -22380,6 +18400,7 @@ "version": "1.2.10", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -22401,6 +18422,7 @@ "version": "1.0.9", "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -22419,6 +18441,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -22447,34 +18470,11 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -22494,6 +18494,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -22503,13 +18504,10 @@ } }, "node_modules/stripe": { - "version": "20.1.0", - "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.1.0.tgz", - "integrity": "sha512-o1VNRuMkY76ZCq92U3EH3/XHm/WHp7AerpzDs4Zyo8uE5mFL4QUcv/2SudWsSnhBSp4moO2+ZoGCZ7mT8crPmQ==", + "version": "20.4.1", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.1.tgz", + "integrity": "sha512-axCguHItc8Sxt0HC6aSkdVRPffjYPV7EQqZRb2GkIa8FzWDycE7nHJM19C6xAIynH1Qp1/BHiopSi96jGBxT0w==", "license": "MIT", - "dependencies": { - "qs": "^6.11.0" - }, "engines": { "node": ">=16" }, @@ -22523,9 +18521,9 @@ } }, "node_modules/strnum": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", - "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", + "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", "funding": [ { "type": "github", @@ -22574,22 +18572,11 @@ } } }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -22599,34 +18586,15 @@ } }, "node_modules/svix": { - "version": "1.76.1", - "resolved": "https://registry.npmjs.org/svix/-/svix-1.76.1.tgz", - "integrity": "sha512-CRuDWBTgYfDnBLRaZdKp9VuoPcNUq9An14c/k+4YJ15Qc5Grvf66vp0jvTltd4t7OIRj+8lM1DAgvSgvf7hdLw==", + "version": "1.84.1", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.84.1.tgz", + "integrity": "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==", "license": "MIT", "dependencies": { - "@stablelib/base64": "^1.0.0", - "@types/node": "^22.7.5", - "es6-promise": "^4.2.8", - "fast-sha256": "^1.3.0", - "url-parse": "^1.5.10", + "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, - "node_modules/svix/node_modules/@types/node": { - "version": "22.19.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz", - "integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/svix/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, "node_modules/svix/node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", @@ -22684,9 +18652,9 @@ } }, "node_modules/tailwind-merge": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz", - "integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz", + "integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==", "license": "MIT", "funding": { "type": "github", @@ -22694,11 +18662,10 @@ } }, "node_modules/tailwindcss": { - "version": "4.1.18", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", - "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", - "license": "MIT", - "peer": true + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "license": "MIT" }, "node_modules/tapable": { "version": "2.3.0", @@ -22755,25 +18722,29 @@ "license": "MIT" }, "node_modules/tiny-lru": { - "version": "11.4.5", - "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.5.tgz", - "integrity": "sha512-hkcz3FjNJfKXjV4mjQ1OrXSLAehg8Hw+cEZclOVT+5c/cWQWImQ9wolzTjth+dmmDe++p3bme3fTxz6Q4Etsqw==", + "version": "11.4.7", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.7.tgz", + "integrity": "sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==", "license": "BSD-3-Clause", "engines": { "node": ">=12" } }, "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -22837,9 +18808,10 @@ } }, "node_modules/ts-api-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", - "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, "license": "MIT", "engines": { "node": ">=18.12" @@ -22948,6 +18920,7 @@ "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, "license": "MIT", "dependencies": { "@types/json5": "^0.0.29", @@ -22960,6 +18933,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, "license": "MIT", "dependencies": { "minimist": "^1.2.0" @@ -22974,15 +18948,6 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, - "node_modules/tsscmp": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/tsscmp/-/tsscmp-1.0.6.tgz", - "integrity": "sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==", - "license": "MIT", - "engines": { - "node": ">=0.6.x" - } - }, "node_modules/tsx": { "version": "4.21.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", @@ -23042,10 +19007,17 @@ "url": "https://github.com/sponsors/Wombosvideo" } }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, "license": "MIT", "dependencies": { "prelude-ls": "^1.2.1" @@ -23088,6 +19060,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -23102,6 +19075,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.8", @@ -23121,6 +19095,7 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -23142,6 +19117,7 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.7", @@ -23162,8 +19138,8 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -23173,15 +19149,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.49.0.tgz", - "integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==", + "version": "8.56.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.1.tgz", + "integrity": "sha512-U4lM6pjmBX7J5wk4szltF7I1cGBHXZopnAXCMXb3+fZ3B/0Z3hq3wS/CCUB2NZBNAExK92mCU2tEohWuwVMsDQ==", + "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/utils": "8.49.0" + "@typescript-eslint/eslint-plugin": "8.56.1", + "@typescript-eslint/parser": "8.56.1", + "@typescript-eslint/typescript-estree": "8.56.1", + "@typescript-eslint/utils": "8.56.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -23191,7 +19168,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, @@ -23212,6 +19189,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.3", @@ -23227,9 +19205,9 @@ } }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", "devOptional": true, "license": "MIT" }, @@ -23246,6 +19224,7 @@ "version": "1.11.1", "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -23277,9 +19256,10 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", - "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, "funding": [ { "type": "opencollective", @@ -23310,21 +19290,12 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } }, - "node_modules/url-parse": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", - "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", - "license": "MIT", - "dependencies": { - "querystringify": "^2.1.1", - "requires-port": "^1.0.0" - } - }, "node_modules/use-callback-ref": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", @@ -23346,15 +19317,34 @@ } } }, + "node_modules/use-debounce": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/use-debounce/-/use-debounce-10.1.0.tgz", + "integrity": "sha512-lu87Za35V3n/MyMoEpD5zJv0k7hCn0p+V/fK2kWD+3k2u3kOCwO593UArbczg1fhfs2rqPEnHpULJ3KmGdDzvg==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/use-intl": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.6.1.tgz", - "integrity": "sha512-mUIj6QvJZ7Rk33mLDxRziz1YiBBAnIji8YW4TXXMdYHtaPEbVucrXD3iKQGAqJhbVn0VnjrEtIKYO1B18mfSJw==", + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/use-intl/-/use-intl-4.8.3.tgz", + "integrity": "sha512-nLxlC/RH+le6g3amA508Itnn/00mE+J22ui21QhOWo5V9hCEC43+WtnRAITbJW0ztVZphev5X9gvOf2/Dk9PLA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/amannn" + } + ], "license": "MIT", "dependencies": { - "@formatjs/fast-memoize": "^2.2.0", + "@formatjs/fast-memoize": "^3.1.0", "@schummar/icu-type-parser": "1.21.5", - "intl-messageformat": "^10.5.14" + "icu-minify": "^4.8.3", + "intl-messageformat": "^11.1.0" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0" @@ -23433,9 +19423,9 @@ } }, "node_modules/victory-vendor": { - "version": "37.3.6", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", - "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", "license": "MIT AND ISC", "dependencies": { "@types/d3-array": "^3.0.3", @@ -23460,15 +19450,6 @@ "integrity": "sha512-jHl/NQgASfw5ZML3cnbjdfr/gXK5zO8a2xKSoCVe+5+EsIaO9tMTh7SsnfhESnCpZ+Xb6XBeU91wiuyERUPshQ==", "license": "BSD-3-Clause" }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, "node_modules/when-exit": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/when-exit/-/when-exit-2.1.5.tgz", @@ -23496,6 +19477,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, "license": "MIT", "dependencies": { "is-bigint": "^1.1.0", @@ -23515,6 +19497,7 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -23542,6 +19525,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, "license": "MIT", "dependencies": { "is-map": "^2.0.3", @@ -23560,6 +19544,7 @@ "version": "1.1.19", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", + "dev": true, "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", @@ -23582,7 +19567,6 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.8", @@ -23636,115 +19620,12 @@ "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/wordwrap": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.3.tgz", - "integrity": "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -23752,9 +19633,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -23794,6 +19675,7 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, "license": "ISC" }, "node_modules/yaml": { @@ -23864,6 +19746,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -23886,11 +19769,10 @@ } }, "node_modules/zod": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", - "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index c0df54d5c..05ae3b49f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "type": "module", - "description": "Tunneled Reverse Proxy Management Server with Identity and Access Control and Dashboard UI", + "description": "Identity-aware VPN and proxy for remote access to anything, anywhere and Dashboard UI", "homepage": "https://github.com/fosrl/pangolin", "repository": { "type": "git", @@ -12,30 +12,29 @@ "license": "SEE LICENSE IN LICENSE AND README.md", "scripts": { "dev": "NODE_ENV=development ENVIRONMENT=dev tsx watch server/index.ts", - "db:pg:generate": "drizzle-kit generate --config=./drizzle.pg.config.ts", - "db:sqlite:generate": "drizzle-kit generate --config=./drizzle.sqlite.config.ts", - "db:pg:push": "npx tsx server/db/pg/migrate.ts", - "db:sqlite:push": "npx tsx server/db/sqlite/migrate.ts", - "db:sqlite:studio": "drizzle-kit studio --config=./drizzle.sqlite.config.ts", - "db:pg:studio": "drizzle-kit studio --config=./drizzle.pg.config.ts", + "dev:check": "npx tsc --noEmit && npm run format:check", + "dev:setup": "cp config/config.example.yml config/config.yml && npm run set:oss && npm run set:sqlite && npm run db:sqlite:generate && npm run db:sqlite:push", + "db:generate": "drizzle-kit generate --config=./drizzle.config.ts", + "db:push": "npx tsx server/db/migrate.ts", + "db:studio": "drizzle-kit studio --config=./drizzle.config.ts", "db:clear-migrations": "rm -rf server/migrations", - "set:oss": "echo 'export const build = \"oss\" as any;' > server/build.ts && cp tsconfig.oss.json tsconfig.json", - "set:saas": "echo 'export const build = \"saas\" as any;' > server/build.ts && cp tsconfig.saas.json tsconfig.json", - "set:enterprise": "echo 'export const build = \"enterprise\" as any;' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json", - "set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts", - "set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts", - "next:build": "next build", - "build:sqlite": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsSqlite.ts -o dist/migrations.mjs", - "build:pg": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrationsPg.ts -o dist/migrations.mjs", + "set:oss": "echo 'export const build = \"oss\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.oss.json tsconfig.json", + "set:saas": "echo 'export const build = \"saas\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.saas.json tsconfig.json", + "set:enterprise": "echo 'export const build = \"enterprise\" as \"saas\" | \"enterprise\" | \"oss\";' > server/build.ts && cp tsconfig.enterprise.json tsconfig.json", + "set:sqlite": "echo 'export * from \"./sqlite\";\nexport const driver: \"pg\" | \"sqlite\" = \"sqlite\";' > server/db/index.ts && cp drizzle.sqlite.config.ts drizzle.config.ts && cp server/setup/migrationsSqlite.ts server/setup/migrations.ts", + "set:pg": "echo 'export * from \"./pg\";\nexport const driver: \"pg\" | \"sqlite\" = \"pg\";' > server/db/index.ts && cp drizzle.pg.config.ts drizzle.config.ts && cp server/setup/migrationsPg.ts server/setup/migrations.ts", + "build:next": "next build", + "build": "mkdir -p dist && next build && node esbuild.mjs -e server/index.ts -o dist/server.mjs && node esbuild.mjs -e server/setup/migrations.ts -o dist/migrations.mjs", "start": "ENVIRONMENT=prod node dist/migrations.mjs && ENVIRONMENT=prod NODE_ENV=development node --enable-source-maps dist/server.mjs", "email": "email dev --dir server/emails/templates --port 3005", "build:cli": "node esbuild.mjs -e cli/index.ts -o dist/cli.mjs", + "format:check": "prettier --check .", "format": "prettier --write ." }, "dependencies": { - "@asteasolutions/zod-to-openapi": "8.2.0", - "@aws-sdk/client-s3": "3.955.0", - "@faker-js/faker": "10.1.0", + "@asteasolutions/zod-to-openapi": "8.4.1", + "@aws-sdk/client-s3": "3.1011.0", + "@faker-js/faker": "10.3.0", "@headlessui/react": "2.2.9", "@hookform/resolvers": "5.2.2", "@monaco-editor/react": "4.7.0", @@ -60,90 +59,83 @@ "@radix-ui/react-tabs": "1.1.13", "@radix-ui/react-toast": "1.2.15", "@radix-ui/react-tooltip": "1.2.8", - "@react-email/components": "1.0.2", - "@react-email/render": "2.0.0", - "@react-email/tailwind": "2.0.2", - "@simplewebauthn/browser": "13.2.2", - "@simplewebauthn/server": "13.2.2", + "@react-email/components": "1.0.8", + "@react-email/render": "2.0.4", + "@react-email/tailwind": "2.0.5", + "@simplewebauthn/browser": "13.3.0", + "@simplewebauthn/server": "13.3.0", "@tailwindcss/forms": "0.5.11", - "@tanstack/react-query": "5.90.12", + "@tanstack/react-query": "5.90.21", "@tanstack/react-table": "8.21.3", "arctic": "3.7.0", - "axios": "1.13.2", + "axios": "1.13.5", "better-sqlite3": "11.9.1", "canvas-confetti": "1.9.4", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "cmdk": "1.1.1", - "cookie": "1.1.1", "cookie-parser": "1.4.7", - "cookies": "0.9.1", - "cors": "2.8.5", + "cors": "2.8.6", "crypto-js": "4.2.0", "d3": "7.9.0", - "date-fns": "4.1.0", "drizzle-orm": "0.45.1", - "eslint": "9.39.2", - "eslint-config-next": "16.1.0", "express": "5.2.1", - "express-rate-limit": "8.2.1", - "glob": "13.0.0", + "express-rate-limit": "8.3.0", + "glob": "13.0.6", "helmet": "8.1.0", "http-errors": "2.0.1", - "i": "0.3.7", "input-otp": "1.4.2", - "ioredis": "5.8.2", + "ioredis": "5.10.0", "jmespath": "0.16.0", "js-yaml": "4.1.1", "jsonwebtoken": "9.0.3", - "lucide-react": "0.562.0", - "maxmind": "5.0.1", + "lucide-react": "0.577.0", + "maxmind": "5.0.5", "moment": "2.30.1", - "next": "15.5.9", - "next-intl": "4.6.1", + "next": "15.5.12", + "next-intl": "4.8.3", "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.7.0", - "nprogress": "0.2.0", + "nodemailer": "8.0.1", "oslo": "1.2.1", - "pg": "8.16.3", - "posthog-node": "5.17.4", + "pg": "8.20.0", + "posthog-node": "5.28.0", "qrcode.react": "4.2.0", - "react": "19.2.3", - "react-day-picker": "9.13.0", - "react-dom": "19.2.3", + "react": "19.2.4", + "react-day-picker": "9.14.0", + "react-dom": "19.2.4", "react-easy-sort": "1.8.0", - "react-hook-form": "7.68.0", - "react-icons": "5.5.0", - "rebuild": "0.1.2", - "recharts": "3.5.1", - "reodotdev": "1.0.0", - "resend": "6.6.0", - "semver": "7.7.3", - "stripe": "20.1.0", + "react-hook-form": "7.71.2", + "react-icons": "5.6.0", + "recharts": "2.15.4", + "reodotdev": "1.1.0", + "resend": "6.9.2", + "semver": "7.7.4", + "sshpk": "^1.18.0", + "stripe": "20.4.1", "swagger-ui-express": "5.0.1", - "tailwind-merge": "3.4.0", + "tailwind-merge": "3.5.0", "topojson-client": "3.1.0", "tw-animate-css": "1.4.0", + "use-debounce": "^10.1.0", "uuid": "13.0.0", "vaul": "1.1.2", "visionscarto-world-atlas": "1.0.0", "winston": "3.19.0", "winston-daily-rotate-file": "5.0.0", - "ws": "8.18.3", + "ws": "8.19.0", "yaml": "2.8.2", "yargs": "18.0.0", - "zod": "4.2.1", + "zod": "4.3.6", "zod-validation-error": "5.0.0" }, "devDependencies": { - "@dotenvx/dotenvx": "1.51.2", + "@dotenvx/dotenvx": "1.54.1", "@esbuild-plugins/tsconfig-paths": "0.1.2", - "@tailwindcss/postcss": "4.1.18", - "@tanstack/react-query-devtools": "5.91.1", + "@react-email/preview-server": "5.2.10", + "@tailwindcss/postcss": "4.2.1", + "@tanstack/react-query-devtools": "5.91.3", "@types/better-sqlite3": "7.6.13", "@types/cookie-parser": "1.4.10", "@types/cors": "2.8.19", @@ -152,30 +144,37 @@ "@types/express": "5.0.6", "@types/express-session": "1.18.2", "@types/jmespath": "0.15.2", + "@types/js-yaml": "4.0.9", "@types/jsonwebtoken": "9.0.10", - "@types/node": "24.10.2", - "@types/nodemailer": "7.0.4", + "@types/node": "25.3.5", + "@types/nodemailer": "7.0.11", "@types/nprogress": "0.2.3", - "@types/pg": "8.16.0", - "@types/react": "19.2.7", + "@types/pg": "8.18.0", + "@types/react": "19.2.14", "@types/react-dom": "19.2.3", "@types/semver": "7.7.1", + "@types/sshpk": "^1.17.4", "@types/swagger-ui-express": "4.1.8", "@types/topojson-client": "3.1.5", "@types/ws": "8.18.1", "@types/yargs": "17.0.35", - "@types/js-yaml": "4.0.9", "babel-plugin-react-compiler": "1.0.0", - "drizzle-kit": "0.31.8", - "esbuild": "0.27.2", + "drizzle-kit": "0.31.10", + "esbuild": "0.27.3", "esbuild-node-externals": "1.20.1", - "postcss": "8.5.6", - "prettier": "3.7.4", - "react-email": "5.0.7", - "tailwindcss": "4.1.18", + "eslint": "10.0.3", + "eslint-config-next": "16.1.7", + "postcss": "8.5.8", + "prettier": "3.8.1", + "react-email": "5.2.10", + "tailwindcss": "4.2.1", "tsc-alias": "1.8.16", "tsx": "4.21.0", "typescript": "5.9.3", - "typescript-eslint": "8.49.0" + "typescript-eslint": "8.56.1" + }, + "overrides": { + "esbuild": "0.27.3", + "dompurify": "3.3.2" } } diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 71017f8d9..fc5daa4f8 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -1,9 +1,10 @@ import { Request } from "express"; import { db } from "@server/db"; -import { userActions, roleActions, userOrgs } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { userActions, roleActions } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export enum ActionsEnum { createOrgUser = "createOrgUser", @@ -19,6 +20,7 @@ export enum ActionsEnum { getSite = "getSite", listSites = "listSites", updateSite = "updateSite", + resetSiteBandwidth = "resetSiteBandwidth", reGenerateSecret = "reGenerateSecret", createResource = "createResource", deleteResource = "deleteResource", @@ -52,6 +54,8 @@ export enum ActionsEnum { listRoleResources = "listRoleResources", // listRoleActions = "listRoleActions", addUserRole = "addUserRole", + removeUserRole = "removeUserRole", + setUserOrgRoles = "setUserOrgRoles", // addUserSite = "addUserSite", // addUserAction = "addUserAction", // removeUserAction = "removeUserAction", @@ -78,6 +82,10 @@ export enum ActionsEnum { updateSiteResource = "updateSiteResource", createClient = "createClient", deleteClient = "deleteClient", + archiveClient = "archiveClient", + unarchiveClient = "unarchiveClient", + blockClient = "blockClient", + unblockClient = "unblockClient", updateClient = "updateClient", listClients = "listClients", getClient = "getClient", @@ -104,6 +112,10 @@ export enum ActionsEnum { listApiKeyActions = "listApiKeyActions", listApiKeys = "listApiKeys", getApiKey = "getApiKey", + createSiteProvisioningKey = "createSiteProvisioningKey", + listSiteProvisioningKeys = "listSiteProvisioningKeys", + updateSiteProvisioningKey = "updateSiteProvisioningKey", + deleteSiteProvisioningKey = "deleteSiteProvisioningKey", getCertificate = "getCertificate", restartCertificate = "restartCertificate", billing = "billing", @@ -125,7 +137,10 @@ export enum ActionsEnum { getBlueprint = "getBlueprint", applyBlueprint = "applyBlueprint", viewLogs = "viewLogs", - exportLogs = "exportLogs" + exportLogs = "exportLogs", + listApprovals = "listApprovals", + updateApprovals = "updateApprovals", + signSshKey = "signSshKey" } export async function checkUserActionPermission( @@ -146,29 +161,16 @@ export async function checkUserActionPermission( } try { - let userOrgRoleId = req.userOrgRoleId; + let userOrgRoleIds = req.userOrgRoleIds; - // If userOrgRoleId is not available on the request, fetch it - if (userOrgRoleId === undefined) { - const userOrgRole = await db - .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.userId, userId), - eq(userOrgs.orgId, req.userOrgId!) - ) - ) - .limit(1); - - if (userOrgRole.length === 0) { + if (userOrgRoleIds === undefined) { + userOrgRoleIds = await getUserOrgRoleIds(userId, req.userOrgId!); + if (userOrgRoleIds.length === 0) { throw createHttpError( HttpCode.FORBIDDEN, "User does not have access to this organization" ); } - - userOrgRoleId = userOrgRole[0].roleId; } // Check if the user has direct permission for the action in the current org @@ -179,7 +181,7 @@ export async function checkUserActionPermission( and( eq(userActions.userId, userId), eq(userActions.actionId, actionId), - eq(userActions.orgId, req.userOrgId!) // TODO: we cant pass the org id if we are not checking the org + eq(userActions.orgId, req.userOrgId!) ) ) .limit(1); @@ -188,14 +190,14 @@ export async function checkUserActionPermission( return true; } - // If no direct permission, check role-based permission + // If no direct permission, check role-based permission (any of user's roles) const roleActionPermission = await db .select() .from(roleActions) .where( and( eq(roleActions.actionId, actionId), - eq(roleActions.roleId, userOrgRoleId!), + inArray(roleActions.roleId, userOrgRoleIds), eq(roleActions.orgId, req.userOrgId!) ) ) diff --git a/server/auth/canUserAccessResource.ts b/server/auth/canUserAccessResource.ts index 161a0bee9..2c8911490 100644 --- a/server/auth/canUserAccessResource.ts +++ b/server/auth/canUserAccessResource.ts @@ -1,26 +1,29 @@ import { db } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { roleResources, userResources } from "@server/db"; export async function canUserAccessResource({ userId, resourceId, - roleId + roleIds }: { userId: string; resourceId: number; - roleId: number; + roleIds: number[]; }): Promise { - const roleResourceAccess = await db - .select() - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resourceId), - eq(roleResources.roleId, roleId) - ) - ) - .limit(1); + const roleResourceAccess = + roleIds.length > 0 + ? await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + inArray(roleResources.roleId, roleIds) + ) + ) + .limit(1) + : []; if (roleResourceAccess.length > 0) { return true; diff --git a/server/auth/canUserAccessSiteResource.ts b/server/auth/canUserAccessSiteResource.ts new file mode 100644 index 000000000..7e6ec9bb8 --- /dev/null +++ b/server/auth/canUserAccessSiteResource.ts @@ -0,0 +1,48 @@ +import { db } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; +import { roleSiteResources, userSiteResources } from "@server/db"; + +export async function canUserAccessSiteResource({ + userId, + resourceId, + roleIds +}: { + userId: string; + resourceId: number; + roleIds: number[]; +}): Promise { + const roleResourceAccess = + roleIds.length > 0 + ? await db + .select() + .from(roleSiteResources) + .where( + and( + eq(roleSiteResources.siteResourceId, resourceId), + inArray(roleSiteResources.roleId, roleIds) + ) + ) + .limit(1) + : []; + + if (roleResourceAccess.length > 0) { + return true; + } + + const userResourceAccess = await db + .select() + .from(userSiteResources) + .where( + and( + eq(userSiteResources.userId, userId), + eq(userSiteResources.siteResourceId, resourceId) + ) + ) + .limit(1); + + if (userResourceAccess.length > 0) { + return true; + } + + return false; +} diff --git a/server/auth/sessions/app.ts b/server/auth/sessions/app.ts index 73b220fa6..f6cae441b 100644 --- a/server/auth/sessions/app.ts +++ b/server/auth/sessions/app.ts @@ -3,7 +3,14 @@ import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; -import { resourceSessions, Session, sessions, User, users } from "@server/db"; +import { + resourceSessions, + safeRead, + Session, + sessions, + User, + users +} from "@server/db"; import { db } from "@server/db"; import { eq, inArray } from "drizzle-orm"; import config from "@server/lib/config"; @@ -54,11 +61,15 @@ export async function validateSessionToken( const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(token)) ); - const result = await db - .select({ user: users, session: sessions }) - .from(sessions) - .innerJoin(users, eq(sessions.userId, users.userId)) - .where(eq(sessions.sessionId, sessionId)); + + const result = await safeRead((db) => + db + .select({ user: users, session: sessions }) + .from(sessions) + .innerJoin(users, eq(sessions.userId, users.userId)) + .where(eq(sessions.sessionId, sessionId)) + ); + if (result.length < 1) { return { session: null, user: null }; } diff --git a/server/auth/sessions/resource.ts b/server/auth/sessions/resource.ts index 9a5b2b5ff..a1ae13373 100644 --- a/server/auth/sessions/resource.ts +++ b/server/auth/sessions/resource.ts @@ -1,7 +1,7 @@ import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { resourceSessions, ResourceSession } from "@server/db"; -import { db } from "@server/db"; +import { db, safeRead } from "@server/db"; import { eq, and } from "drizzle-orm"; import config from "@server/lib/config"; @@ -66,15 +66,17 @@ export async function validateResourceSessionToken( const sessionId = encodeHexLowerCase( sha256(new TextEncoder().encode(token)) ); - const result = await db - .select() - .from(resourceSessions) - .where( - and( - eq(resourceSessions.sessionId, sessionId), - eq(resourceSessions.resourceId, resourceId) + const result = await safeRead((db) => + db + .select() + .from(resourceSessions) + .where( + and( + eq(resourceSessions.sessionId, sessionId), + eq(resourceSessions.resourceId, resourceId) + ) ) - ); + ); if (result.length < 1) { return { resourceSession: null }; @@ -85,7 +87,7 @@ export async function validateResourceSessionToken( if (Date.now() >= resourceSession.expiresAt) { await db .delete(resourceSessions) - .where(eq(resourceSessions.sessionId, resourceSessions.sessionId)); + .where(eq(resourceSessions.sessionId, sessionId)); return { resourceSession: null }; } else if ( Date.now() >= @@ -179,7 +181,7 @@ export function serializeResourceSessionCookie( return `${cookieName}_s.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Secure; Domain=${domain}`; } else { if (expiresAt === undefined) { - return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=$domain}`; + return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Path=/; Domain=${domain}`; } return `${cookieName}.${now}=${token}; HttpOnly; SameSite=Lax; Expires=${expiresAt.toUTCString()}; Path=/; Domain=${domain}`; } diff --git a/server/cleanup.ts b/server/cleanup.ts index e494fcdc9..10e9f4cc3 100644 --- a/server/cleanup.ts +++ b/server/cleanup.ts @@ -1,6 +1,14 @@ +import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage"; +import { flushConnectionLogToDb } from "#dynamic/routers/newt"; +import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth"; +import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator"; import { cleanup as wsCleanup } from "#dynamic/routers/ws"; async function cleanup() { + await stopPingAccumulator(); + await flushBandwidthToDb(); + await flushConnectionLogToDb(); + await flushSiteBandwidthToDb(); await wsCleanup(); process.exit(0); diff --git a/server/db/README.md b/server/db/README.md index 36c3730b7..1f7d57d1b 100644 --- a/server/db/README.md +++ b/server/db/README.md @@ -56,15 +56,15 @@ Ensure drizzle-kit is installed. You must have a connection string in your config file, as shown above. ```bash -npm run db:pg:generate -npm run db:pg:push +npm run db:generate +npm run db:push ``` ### SQLite ```bash -npm run db:sqlite:generate -npm run db:sqlite:push +npm run db:generate +npm run db:push ``` ## Build Time diff --git a/server/db/asns.ts b/server/db/asns.ts index f78577f5f..baeb7488f 100644 --- a/server/db/asns.ts +++ b/server/db/asns.ts @@ -68,7 +68,7 @@ export const MAJOR_ASNS = [ code: "AS36351", asn: 36351 }, - + // CDNs { name: "Cloudflare", @@ -90,7 +90,7 @@ export const MAJOR_ASNS = [ code: "AS16625", asn: 16625 }, - + // Mobile Carriers - US { name: "T-Mobile USA", @@ -117,7 +117,7 @@ export const MAJOR_ASNS = [ code: "AS6430", asn: 6430 }, - + // Mobile Carriers - Europe { name: "Vodafone UK", @@ -144,7 +144,7 @@ export const MAJOR_ASNS = [ code: "AS12430", asn: 12430 }, - + // Mobile Carriers - Asia { name: "NTT DoCoMo (Japan)", @@ -176,7 +176,7 @@ export const MAJOR_ASNS = [ code: "AS9808", asn: 9808 }, - + // Major US ISPs { name: "AT&T Services", @@ -208,7 +208,7 @@ export const MAJOR_ASNS = [ code: "AS209", asn: 209 }, - + // Major European ISPs { name: "Deutsche Telekom", @@ -235,7 +235,7 @@ export const MAJOR_ASNS = [ code: "AS12956", asn: 12956 }, - + // Major Asian ISPs { name: "China Telecom", @@ -262,7 +262,7 @@ export const MAJOR_ASNS = [ code: "AS55836", asn: 55836 }, - + // VPN/Proxy Providers { name: "Private Internet Access", @@ -279,7 +279,7 @@ export const MAJOR_ASNS = [ code: "AS213281", asn: 213281 }, - + // Social Media / Major Tech { name: "Facebook/Meta", @@ -301,7 +301,7 @@ export const MAJOR_ASNS = [ code: "AS2906", asn: 2906 }, - + // Academic/Research { name: "MIT", diff --git a/server/db/ios_models.json b/server/db/ios_models.json new file mode 100644 index 000000000..99fcbea17 --- /dev/null +++ b/server/db/ios_models.json @@ -0,0 +1,150 @@ +{ + "iPad1,1": "iPad", + "iPad2,1": "iPad 2", + "iPad2,2": "iPad 2", + "iPad2,3": "iPad 2", + "iPad2,4": "iPad 2", + "iPad3,1": "iPad 3rd Gen", + "iPad3,3": "iPad 3rd Gen", + "iPad3,2": "iPad 3rd Gen", + "iPad3,4": "iPad 4th Gen", + "iPad3,5": "iPad 4th Gen", + "iPad3,6": "iPad 4th Gen", + "iPad6,11": "iPad 9.7 5th Gen", + "iPad6,12": "iPad 9.7 5th Gen", + "iPad7,5": "iPad 9.7 6th Gen", + "iPad7,6": "iPad 9.7 6th Gen", + "iPad7,11": "iPad 10.2 7th Gen", + "iPad7,12": "iPad 10.2 7th Gen", + "iPad11,6": "iPad 10.2 8th Gen", + "iPad11,7": "iPad 10.2 8th Gen", + "iPad12,1": "iPad 10.2 9th Gen", + "iPad12,2": "iPad 10.2 9th Gen", + "iPad13,18": "iPad 10.9 10th Gen", + "iPad13,19": "iPad 10.9 10th Gen", + "iPad4,1": "iPad Air", + "iPad4,2": "iPad Air", + "iPad4,3": "iPad Air", + "iPad5,3": "iPad Air 2", + "iPad5,4": "iPad Air 2", + "iPad11,3": "iPad Air 3rd Gen", + "iPad11,4": "iPad Air 3rd Gen", + "iPad13,1": "iPad Air 4th Gen", + "iPad13,2": "iPad Air 4th Gen", + "iPad13,16": "iPad Air 5th Gen", + "iPad13,17": "iPad Air 5th Gen", + "iPad14,8": "iPad Air M2 11", + "iPad14,9": "iPad Air M2 11", + "iPad14,10": "iPad Air M2 13", + "iPad14,11": "iPad Air M2 13", + "iPad2,5": "iPad mini", + "iPad2,6": "iPad mini", + "iPad2,7": "iPad mini", + "iPad4,4": "iPad mini 2", + "iPad4,5": "iPad mini 2", + "iPad4,6": "iPad mini 2", + "iPad4,7": "iPad mini 3", + "iPad4,8": "iPad mini 3", + "iPad4,9": "iPad mini 3", + "iPad5,1": "iPad mini 4", + "iPad5,2": "iPad mini 4", + "iPad11,1": "iPad mini 5th Gen", + "iPad11,2": "iPad mini 5th Gen", + "iPad14,1": "iPad mini 6th Gen", + "iPad14,2": "iPad mini 6th Gen", + "iPad6,7": "iPad Pro 12.9", + "iPad6,8": "iPad Pro 12.9", + "iPad6,3": "iPad Pro 9.7", + "iPad6,4": "iPad Pro 9.7", + "iPad7,3": "iPad Pro 10.5", + "iPad7,4": "iPad Pro 10.5", + "iPad7,1": "iPad Pro 12.9", + "iPad7,2": "iPad Pro 12.9", + "iPad8,1": "iPad Pro 11", + "iPad8,2": "iPad Pro 11", + "iPad8,3": "iPad Pro 11", + "iPad8,4": "iPad Pro 11", + "iPad8,5": "iPad Pro 12.9", + "iPad8,6": "iPad Pro 12.9", + "iPad8,7": "iPad Pro 12.9", + "iPad8,8": "iPad Pro 12.9", + "iPad8,9": "iPad Pro 11", + "iPad8,10": "iPad Pro 11", + "iPad8,11": "iPad Pro 12.9", + "iPad8,12": "iPad Pro 12.9", + "iPad13,4": "iPad Pro 11", + "iPad13,5": "iPad Pro 11", + "iPad13,6": "iPad Pro 11", + "iPad13,7": "iPad Pro 11", + "iPad13,8": "iPad Pro 12.9", + "iPad13,9": "iPad Pro 12.9", + "iPad13,10": "iPad Pro 12.9", + "iPad13,11": "iPad Pro 12.9", + "iPad14,3": "iPad Pro 11", + "iPad14,4": "iPad Pro 11", + "iPad14,5": "iPad Pro 12.9", + "iPad14,6": "iPad Pro 12.9", + "iPad16,3": "iPad Pro M4 11", + "iPad16,4": "iPad Pro M4 11", + "iPad16,5": "iPad Pro M4 13", + "iPad16,6": "iPad Pro M4 13", + "iPhone1,1": "iPhone", + "iPhone1,2": "iPhone 3G", + "iPhone2,1": "iPhone 3GS", + "iPhone3,1": "iPhone 4", + "iPhone3,2": "iPhone 4", + "iPhone3,3": "iPhone 4", + "iPhone4,1": "iPhone 4S", + "iPhone5,1": "iPhone 5", + "iPhone5,2": "iPhone 5", + "iPhone5,3": "iPhone 5c", + "iPhone5,4": "iPhone 5c", + "iPhone6,1": "iPhone 5s", + "iPhone6,2": "iPhone 5s", + "iPhone7,2": "iPhone 6", + "iPhone7,1": "iPhone 6 Plus", + "iPhone8,1": "iPhone 6s", + "iPhone8,2": "iPhone 6s Plus", + "iPhone8,4": "iPhone SE", + "iPhone9,1": "iPhone 7", + "iPhone9,3": "iPhone 7", + "iPhone9,2": "iPhone 7 Plus", + "iPhone9,4": "iPhone 7 Plus", + "iPhone10,1": "iPhone 8", + "iPhone10,4": "iPhone 8", + "iPhone10,2": "iPhone 8 Plus", + "iPhone10,5": "iPhone 8 Plus", + "iPhone10,3": "iPhone X", + "iPhone10,6": "iPhone X", + "iPhone11,2": "iPhone Xs", + "iPhone11,6": "iPhone Xs Max", + "iPhone11,8": "iPhone XR", + "iPhone12,1": "iPhone 11", + "iPhone12,3": "iPhone 11 Pro", + "iPhone12,5": "iPhone 11 Pro Max", + "iPhone12,8": "iPhone SE", + "iPhone13,1": "iPhone 12 mini", + "iPhone13,2": "iPhone 12", + "iPhone13,3": "iPhone 12 Pro", + "iPhone13,4": "iPhone 12 Pro Max", + "iPhone14,4": "iPhone 13 mini", + "iPhone14,5": "iPhone 13", + "iPhone14,2": "iPhone 13 Pro", + "iPhone14,3": "iPhone 13 Pro Max", + "iPhone14,6": "iPhone SE", + "iPhone14,7": "iPhone 14", + "iPhone14,8": "iPhone 14 Plus", + "iPhone15,2": "iPhone 14 Pro", + "iPhone15,3": "iPhone 14 Pro Max", + "iPhone15,4": "iPhone 15", + "iPhone15,5": "iPhone 15 Plus", + "iPhone16,1": "iPhone 15 Pro", + "iPhone16,2": "iPhone 15 Pro Max", + "iPod1,1": "iPod touch Original", + "iPod2,1": "iPod touch 2nd", + "iPod3,1": "iPod touch 3rd Gen", + "iPod4,1": "iPod touch 4th", + "iPod5,1": "iPod touch 5th", + "iPod7,1": "iPod touch 6th Gen", + "iPod9,1": "iPod touch 7th Gen" +} \ No newline at end of file diff --git a/server/db/mac_models.json b/server/db/mac_models.json new file mode 100644 index 000000000..db473f3ae --- /dev/null +++ b/server/db/mac_models.json @@ -0,0 +1,201 @@ +{ + "PowerMac4,4": "eMac", + "PowerMac6,4": "eMac", + "PowerBook2,1": "iBook", + "PowerBook2,2": "iBook", + "PowerBook4,1": "iBook", + "PowerBook4,2": "iBook", + "PowerBook4,3": "iBook", + "PowerBook6,3": "iBook", + "PowerBook6,5": "iBook", + "PowerBook6,7": "iBook", + "iMac,1": "iMac", + "PowerMac2,1": "iMac", + "PowerMac2,2": "iMac", + "PowerMac4,1": "iMac", + "PowerMac4,2": "iMac", + "PowerMac4,5": "iMac", + "PowerMac6,1": "iMac", + "PowerMac6,3*": "iMac", + "PowerMac6,3": "iMac", + "PowerMac8,1": "iMac", + "PowerMac8,2": "iMac", + "PowerMac12,1": "iMac", + "iMac4,1": "iMac", + "iMac4,2": "iMac", + "iMac5,2": "iMac", + "iMac5,1": "iMac", + "iMac6,1": "iMac", + "iMac7,1": "iMac", + "iMac8,1": "iMac", + "iMac9,1": "iMac", + "iMac10,1": "iMac", + "iMac11,1": "iMac", + "iMac11,2": "iMac", + "iMac11,3": "iMac", + "iMac12,1": "iMac", + "iMac12,2": "iMac", + "iMac13,1": "iMac", + "iMac13,2": "iMac", + "iMac14,1": "iMac", + "iMac14,3": "iMac", + "iMac14,2": "iMac", + "iMac14,4": "iMac", + "iMac15,1": "iMac", + "iMac16,1": "iMac", + "iMac16,2": "iMac", + "iMac17,1": "iMac", + "iMac18,1": "iMac", + "iMac18,2": "iMac", + "iMac18,3": "iMac", + "iMac19,2": "iMac", + "iMac19,1": "iMac", + "iMac20,1": "iMac", + "iMac20,2": "iMac", + "iMac21,2": "iMac", + "iMac21,1": "iMac", + "iMacPro1,1": "iMac Pro", + "PowerMac10,1": "Mac mini", + "PowerMac10,2": "Mac mini", + "Macmini1,1": "Mac mini", + "Macmini2,1": "Mac mini", + "Macmini3,1": "Mac mini", + "Macmini4,1": "Mac mini", + "Macmini5,1": "Mac mini", + "Macmini5,2": "Mac mini", + "Macmini5,3": "Mac mini", + "Macmini6,1": "Mac mini", + "Macmini6,2": "Mac mini", + "Macmini7,1": "Mac mini", + "Macmini8,1": "Mac mini", + "ADP3,2": "Mac mini", + "Macmini9,1": "Mac mini", + "Mac14,3": "Mac mini", + "Mac14,12": "Mac mini", + "MacPro1,1*": "Mac Pro", + "MacPro2,1": "Mac Pro", + "MacPro3,1": "Mac Pro", + "MacPro4,1": "Mac Pro", + "MacPro5,1": "Mac Pro", + "MacPro6,1": "Mac Pro", + "MacPro7,1": "Mac Pro", + "N/A*": "Power Macintosh", + "PowerMac1,1": "Power Macintosh", + "PowerMac3,1": "Power Macintosh", + "PowerMac3,3": "Power Macintosh", + "PowerMac3,4": "Power Macintosh", + "PowerMac3,5": "Power Macintosh", + "PowerMac3,6": "Power Macintosh", + "Mac13,1": "Mac Studio", + "Mac13,2": "Mac Studio", + "MacBook1,1": "MacBook", + "MacBook2,1": "MacBook", + "MacBook3,1": "MacBook", + "MacBook4,1": "MacBook", + "MacBook5,1": "MacBook", + "MacBook5,2": "MacBook", + "MacBook6,1": "MacBook", + "MacBook7,1": "MacBook", + "MacBook8,1": "MacBook", + "MacBook9,1": "MacBook", + "MacBook10,1": "MacBook", + "MacBookAir1,1": "MacBook Air", + "MacBookAir2,1": "MacBook Air", + "MacBookAir3,1": "MacBook Air", + "MacBookAir3,2": "MacBook Air", + "MacBookAir4,1": "MacBook Air", + "MacBookAir4,2": "MacBook Air", + "MacBookAir5,1": "MacBook Air", + "MacBookAir5,2": "MacBook Air", + "MacBookAir6,1": "MacBook Air", + "MacBookAir6,2": "MacBook Air", + "MacBookAir7,1": "MacBook Air", + "MacBookAir7,2": "MacBook Air", + "MacBookAir8,1": "MacBook Air", + "MacBookAir8,2": "MacBook Air", + "MacBookAir9,1": "MacBook Air", + "MacBookAir10,1": "MacBook Air", + "Mac14,2": "MacBook Air", + "MacBookPro1,1": "MacBook Pro", + "MacBookPro1,2": "MacBook Pro", + "MacBookPro2,2": "MacBook Pro", + "MacBookPro2,1": "MacBook Pro", + "MacBookPro3,1": "MacBook Pro", + "MacBookPro4,1": "MacBook Pro", + "MacBookPro5,1": "MacBook Pro", + "MacBookPro5,2": "MacBook Pro", + "MacBookPro5,5": "MacBook Pro", + "MacBookPro5,4": "MacBook Pro", + "MacBookPro5,3": "MacBook Pro", + "MacBookPro7,1": "MacBook Pro", + "MacBookPro6,2": "MacBook Pro", + "MacBookPro6,1": "MacBook Pro", + "MacBookPro8,1": "MacBook Pro", + "MacBookPro8,2": "MacBook Pro", + "MacBookPro8,3": "MacBook Pro", + "MacBookPro9,2": "MacBook Pro", + "MacBookPro9,1": "MacBook Pro", + "MacBookPro10,1": "MacBook Pro", + "MacBookPro10,2": "MacBook Pro", + "MacBookPro11,1": "MacBook Pro", + "MacBookPro11,2": "MacBook Pro", + "MacBookPro11,3": "MacBook Pro", + "MacBookPro12,1": "MacBook Pro", + "MacBookPro11,4": "MacBook Pro", + "MacBookPro11,5": "MacBook Pro", + "MacBookPro13,1": "MacBook Pro", + "MacBookPro13,2": "MacBook Pro", + "MacBookPro13,3": "MacBook Pro", + "MacBookPro14,1": "MacBook Pro", + "MacBookPro14,2": "MacBook Pro", + "MacBookPro14,3": "MacBook Pro", + "MacBookPro15,2": "MacBook Pro", + "MacBookPro15,1": "MacBook Pro", + "MacBookPro15,3": "MacBook Pro", + "MacBookPro15,4": "MacBook Pro", + "MacBookPro16,1": "MacBook Pro", + "MacBookPro16,3": "MacBook Pro", + "MacBookPro16,2": "MacBook Pro", + "MacBookPro16,4": "MacBook Pro", + "MacBookPro17,1": "MacBook Pro", + "MacBookPro18,3": "MacBook Pro", + "MacBookPro18,4": "MacBook Pro", + "MacBookPro18,1": "MacBook Pro", + "MacBookPro18,2": "MacBook Pro", + "Mac14,7": "MacBook Pro", + "Mac14,9": "MacBook Pro", + "Mac14,5": "MacBook Pro", + "Mac14,10": "MacBook Pro", + "Mac14,6": "MacBook Pro", + "PowerMac1,2": "Power Macintosh", + "PowerMac5,1": "Power Macintosh", + "PowerMac7,2": "Power Macintosh", + "PowerMac7,3": "Power Macintosh", + "PowerMac9,1": "Power Macintosh", + "PowerMac11,2": "Power Macintosh", + "PowerBook1,1": "PowerBook", + "PowerBook3,1": "PowerBook", + "PowerBook3,2": "PowerBook", + "PowerBook3,3": "PowerBook", + "PowerBook3,4": "PowerBook", + "PowerBook3,5": "PowerBook", + "PowerBook6,1": "PowerBook", + "PowerBook5,1": "PowerBook", + "PowerBook6,2": "PowerBook", + "PowerBook5,2": "PowerBook", + "PowerBook5,3": "PowerBook", + "PowerBook6,4": "PowerBook", + "PowerBook5,4": "PowerBook", + "PowerBook5,5": "PowerBook", + "PowerBook6,8": "PowerBook", + "PowerBook5,6": "PowerBook", + "PowerBook5,7": "PowerBook", + "PowerBook5,8": "PowerBook", + "PowerBook5,9": "PowerBook", + "RackMac1,1": "Xserve", + "RackMac1,2": "Xserve", + "RackMac3,1": "Xserve", + "Xserve1,1": "Xserve", + "Xserve2,1": "Xserve", + "Xserve3,1": "Xserve" +} \ No newline at end of file diff --git a/server/db/migrate.ts b/server/db/migrate.ts new file mode 100644 index 000000000..67ff15ec9 --- /dev/null +++ b/server/db/migrate.ts @@ -0,0 +1,3 @@ +import { runMigrations } from "./"; + +await runMigrations(); diff --git a/server/db/names.ts b/server/db/names.ts index 32b0a3939..6f9e12305 100644 --- a/server/db/names.ts +++ b/server/db/names.ts @@ -16,6 +16,24 @@ if (!dev) { } export const names = JSON.parse(readFileSync(file, "utf-8")); +// Load iOS and Mac model mappings +let iosModelsFile: string; +let macModelsFile: string; +if (!dev) { + iosModelsFile = join(__DIRNAME, "ios_models.json"); + macModelsFile = join(__DIRNAME, "mac_models.json"); +} else { + iosModelsFile = join("server/db/ios_models.json"); + macModelsFile = join("server/db/mac_models.json"); +} + +const iosModels: Record = JSON.parse( + readFileSync(iosModelsFile, "utf-8") +); +const macModels: Record = JSON.parse( + readFileSync(macModelsFile, "utf-8") +); + export async function getUniqueClientName(orgId: string): Promise { let loops = 0; while (true) { @@ -159,3 +177,29 @@ export function generateName(): string { // clean out any non-alphanumeric characters except for dashes return name.replace(/[^a-z0-9-]/g, ""); } + +export function getMacDeviceName(macIdentifier?: string | null): string | null { + if (macIdentifier && macModels[macIdentifier]) { + return macModels[macIdentifier]; + } + return null; +} + +export function getIosDeviceName(iosIdentifier?: string | null): string | null { + if (iosIdentifier && iosModels[iosIdentifier]) { + return iosModels[iosIdentifier]; + } + return null; +} + +export function getUserDeviceName( + model: string | null, + fallBack: string | null +): string { + return ( + getMacDeviceName(model) || + getIosDeviceName(model) || + fallBack || + "Unknown Device" + ); +} diff --git a/server/db/pg/driver.ts b/server/db/pg/driver.ts index 9456effbd..9366e32e1 100644 --- a/server/db/pg/driver.ts +++ b/server/db/pg/driver.ts @@ -1,33 +1,33 @@ import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres"; -import { Pool } from "pg"; import { readConfigFile } from "@server/lib/readConfigFile"; import { withReplicas } from "drizzle-orm/pg-core"; +import { createPool } from "./poolConfig"; 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 || []; @@ -39,12 +39,17 @@ function createDb() { // Create connection pools instead of individual connections const poolConfig = config.postgres.pool; - const primaryPool = new Pool({ + const maxConnections = poolConfig?.max_connections || 20; + const idleTimeoutMs = poolConfig?.idle_timeout_ms || 30000; + const connectionTimeoutMs = poolConfig?.connection_timeout_ms || 5000; + + const primaryPool = createPool( connectionString, - max: poolConfig?.max_connections || 20, - idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000, - connectionTimeoutMillis: poolConfig?.connection_timeout_ms || 5000 - }); + maxConnections, + idleTimeoutMs, + connectionTimeoutMs, + "primary" + ); const replicas = []; @@ -55,14 +60,16 @@ function createDb() { }) ); } else { + const maxReplicaConnections = + poolConfig?.max_replica_connections || 20; for (const conn of replicaConnections) { - const replicaPool = new Pool({ - connectionString: conn.connection_string, - max: poolConfig?.max_replica_connections || 20, - idleTimeoutMillis: poolConfig?.idle_timeout_ms || 30000, - connectionTimeoutMillis: - poolConfig?.connection_timeout_ms || 5000 - }); + const replicaPool = createPool( + conn.connection_string, + maxReplicaConnections, + idleTimeoutMs, + connectionTimeoutMs, + "replica" + ); replicas.push( DrizzlePostgres(replicaPool, { logger: process.env.QUERY_LOGGING == "true" @@ -81,6 +88,7 @@ function createDb() { export const db = createDb(); export default db; +export const primaryDb = db.$primary; export type Transaction = Parameters< Parameters<(typeof db)["transaction"]>[0] ->[0]; +>[0]; \ No newline at end of file diff --git a/server/db/pg/index.ts b/server/db/pg/index.ts index 6e2c79f50..f8c04ac9e 100644 --- a/server/db/pg/index.ts +++ b/server/db/pg/index.ts @@ -1,3 +1,6 @@ export * from "./driver"; +export * from "./logsDriver"; +export * from "./safeRead"; export * from "./schema/schema"; export * from "./schema/privateSchema"; +export * from "./migrate"; diff --git a/server/db/pg/logsDriver.ts b/server/db/pg/logsDriver.ts new file mode 100644 index 000000000..146b8fb2f --- /dev/null +++ b/server/db/pg/logsDriver.ts @@ -0,0 +1,94 @@ +import { drizzle as DrizzlePostgres } from "drizzle-orm/node-postgres"; +import { readConfigFile } from "@server/lib/readConfigFile"; +import { withReplicas } from "drizzle-orm/pg-core"; +import { build } from "@server/build"; +import { db as mainDb, primaryDb as mainPrimaryDb } from "./driver"; +import { createPool } from "./poolConfig"; + +function createLogsDb() { + // Only use separate logs database in SaaS builds + if (build !== "saas") { + return mainDb; + } + + const config = readConfigFile(); + + // Merge configs, prioritizing private config + const logsConfig = config.postgres_logs; + + // Check environment variable first + let connectionString = process.env.POSTGRES_LOGS_CONNECTION_STRING; + let replicaConnections: Array<{ connection_string: string }> = []; + + if (!connectionString && logsConfig) { + connectionString = logsConfig.connection_string; + replicaConnections = logsConfig.replicas || []; + } + + // If POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS is set, use it + if (process.env.POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS) { + replicaConnections = + process.env.POSTGRES_LOGS_REPLICA_CONNECTION_STRINGS.split(",").map( + (conn) => ({ + connection_string: conn.trim() + }) + ); + } + + // If no logs database is configured, fall back to main database + if (!connectionString) { + return mainDb; + } + + // Create separate connection pool for logs database + const poolConfig = logsConfig?.pool || config.postgres?.pool; + const maxConnections = poolConfig?.max_connections || 20; + const idleTimeoutMs = poolConfig?.idle_timeout_ms || 30000; + const connectionTimeoutMs = poolConfig?.connection_timeout_ms || 5000; + + const primaryPool = createPool( + connectionString, + maxConnections, + idleTimeoutMs, + connectionTimeoutMs, + "logs-primary" + ); + + const replicas = []; + + if (!replicaConnections.length) { + replicas.push( + DrizzlePostgres(primaryPool, { + logger: process.env.QUERY_LOGGING == "true" + }) + ); + } else { + const maxReplicaConnections = + poolConfig?.max_replica_connections || 20; + for (const conn of replicaConnections) { + const replicaPool = createPool( + conn.connection_string, + maxReplicaConnections, + idleTimeoutMs, + connectionTimeoutMs, + "logs-replica" + ); + replicas.push( + DrizzlePostgres(replicaPool, { + logger: process.env.QUERY_LOGGING == "true" + }) + ); + } + } + + return withReplicas( + DrizzlePostgres(primaryPool, { + logger: process.env.QUERY_LOGGING == "true" + }), + replicas as any + ); +} + +export const logsDb = createLogsDb(); +export default logsDb; +export const primaryLogsDb = logsDb.$primary; \ No newline at end of file diff --git a/server/db/pg/migrate.ts b/server/db/pg/migrate.ts index 8bbcceb73..d84cc010f 100644 --- a/server/db/pg/migrate.ts +++ b/server/db/pg/migrate.ts @@ -4,18 +4,16 @@ import path from "path"; const migrationsFolder = path.join("server/migrations"); -const runMigrations = async () => { +export const runMigrations = async () => { console.log("Running migrations..."); try { await migrate(db as any, { migrationsFolder: migrationsFolder }); - console.log("Migrations completed successfully."); + console.log("Migrations completed successfully. ✅"); process.exit(0); } catch (error) { console.error("Error running migrations:", error); process.exit(1); } }; - -runMigrations(); diff --git a/server/db/pg/poolConfig.ts b/server/db/pg/poolConfig.ts new file mode 100644 index 000000000..f753121c1 --- /dev/null +++ b/server/db/pg/poolConfig.ts @@ -0,0 +1,63 @@ +import { Pool, PoolConfig } from "pg"; +import logger from "@server/logger"; + +export function createPoolConfig( + connectionString: string, + maxConnections: number, + idleTimeoutMs: number, + connectionTimeoutMs: number +): PoolConfig { + return { + connectionString, + max: maxConnections, + idleTimeoutMillis: idleTimeoutMs, + connectionTimeoutMillis: connectionTimeoutMs, + // TCP keepalive to prevent silent connection drops by NAT gateways, + // load balancers, and other intermediate network devices (e.g. AWS + // NAT Gateway drops idle TCP connections after ~350s) + keepAlive: true, + keepAliveInitialDelayMillis: 10000, // send first keepalive after 10s of idle + // Allow connections to be released and recreated more aggressively + // to avoid stale connections building up + allowExitOnIdle: false + }; +} + +export function attachPoolErrorHandlers(pool: Pool, label: string): void { + pool.on("error", (err) => { + // This catches errors on idle clients in the pool. Without this + // handler an unexpected disconnect would crash the process. + logger.error( + `Unexpected error on idle ${label} database client: ${err.message}` + ); + }); + + pool.on("connect", (client) => { + // Set a statement timeout on every new connection so a single slow + // query can't block the pool forever + client.query("SET statement_timeout = '30s'").catch((err: Error) => { + logger.warn( + `Failed to set statement_timeout on ${label} client: ${err.message}` + ); + }); + }); +} + +export function createPool( + connectionString: string, + maxConnections: number, + idleTimeoutMs: number, + connectionTimeoutMs: number, + label: string +): Pool { + const pool = new Pool( + createPoolConfig( + connectionString, + maxConnections, + idleTimeoutMs, + connectionTimeoutMs + ) + ); + attachPoolErrorHandlers(pool, label); + return pool; +} \ No newline at end of file diff --git a/server/db/pg/safeRead.ts b/server/db/pg/safeRead.ts new file mode 100644 index 000000000..eac9ac31d --- /dev/null +++ b/server/db/pg/safeRead.ts @@ -0,0 +1,24 @@ +import { db, primaryDb } from "./driver"; + +/** + * Runs a read query with replica fallback for Postgres. + * Executes the query against the replica first (when replicas exist). + * If the query throws or returns no data (null, undefined, or empty array), + * runs the same query against the primary. + */ +export async function safeRead( + query: (d: typeof db | typeof primaryDb) => Promise +): Promise { + try { + const result = await query(db); + if (result === undefined || result === null) { + return query(primaryDb); + } + if (Array.isArray(result) && result.length === 0) { + return query(primaryDb); + } + return result; + } catch { + return query(primaryDb); + } +} diff --git a/server/db/pg/schema/privateSchema.ts b/server/db/pg/schema/privateSchema.ts index cb809b710..1f0de4e7d 100644 --- a/server/db/pg/schema/privateSchema.ts +++ b/server/db/pg/schema/privateSchema.ts @@ -7,10 +7,21 @@ import { bigint, real, text, - index + index, + primaryKey } from "drizzle-orm/pg-core"; import { InferSelectModel } from "drizzle-orm"; -import { domains, orgs, targets, users, exitNodes, sessions } from "./schema"; +import { + domains, + orgs, + targets, + users, + exitNodes, + sessions, + clients, + siteResources, + sites +} from "./schema"; export const certificates = pgTable("certificates", { certId: serial("certId").primaryKey(), @@ -74,11 +85,16 @@ export const subscriptions = pgTable("subscriptions", { canceledAt: bigint("canceledAt", { mode: "number" }), createdAt: bigint("createdAt", { mode: "number" }).notNull(), updatedAt: bigint("updatedAt", { mode: "number" }), - billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }) + version: integer("version"), + billingCycleAnchor: bigint("billingCycleAnchor", { mode: "number" }), + type: varchar("type", { length: 50 }) // tier1, tier2, tier3, or license }); export const subscriptionItems = pgTable("subscriptionItems", { subscriptionItemId: serial("subscriptionItemId").primaryKey(), + stripeSubscriptionItemId: varchar("stripeSubscriptionItemId", { + length: 255 + }), subscriptionId: varchar("subscriptionId", { length: 255 }) .notNull() .references(() => subscriptions.subscriptionId, { @@ -86,6 +102,7 @@ export const subscriptionItems = pgTable("subscriptionItems", { }), planId: varchar("planId", { length: 255 }).notNull(), priceId: varchar("priceId", { length: 255 }), + featureId: varchar("featureId", { length: 255 }), meterId: varchar("meterId", { length: 255 }), unitAmount: real("unitAmount"), tiers: text("tiers"), @@ -128,6 +145,7 @@ export const limits = pgTable("limits", { }) .notNull(), value: real("value"), + override: boolean("override").default(false), description: text("description") }); @@ -204,6 +222,29 @@ export const loginPageOrg = pgTable("loginPageOrg", { .references(() => orgs.orgId, { onDelete: "cascade" }) }); +export const loginPageBranding = pgTable("loginPageBranding", { + loginPageBrandingId: serial("loginPageBrandingId").primaryKey(), + logoUrl: text("logoUrl"), + logoWidth: integer("logoWidth").notNull(), + logoHeight: integer("logoHeight").notNull(), + primaryColor: text("primaryColor"), + resourceTitle: text("resourceTitle").notNull(), + resourceSubtitle: text("resourceSubtitle"), + orgTitle: text("orgTitle"), + orgSubtitle: text("orgSubtitle") +}); + +export const loginPageBrandingOrg = pgTable("loginPageBrandingOrg", { + loginPageBrandingId: integer("loginPageBrandingId") + .notNull() + .references(() => loginPageBranding.loginPageBrandingId, { + onDelete: "cascade" + }), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) +}); + export const sessionTransferToken = pgTable("sessionTransferToken", { token: varchar("token").primaryKey(), sessionId: varchar("sessionId") @@ -250,6 +291,7 @@ export const accessAuditLog = pgTable( actor: varchar("actor", { length: 255 }), actorId: varchar("actorId", { length: 255 }), resourceId: integer("resourceId"), + siteResourceId: integer("siteResourceId"), ip: varchar("ip", { length: 45 }), type: varchar("type", { length: 100 }).notNull(), action: boolean("action").notNull(), @@ -266,6 +308,115 @@ export const accessAuditLog = pgTable( ] ); +export const connectionAuditLog = pgTable( + "connectionAuditLog", + { + id: serial("id").primaryKey(), + sessionId: text("sessionId").notNull(), + siteResourceId: integer("siteResourceId").references( + () => siteResources.siteResourceId, + { onDelete: "cascade" } + ), + orgId: text("orgId").references(() => orgs.orgId, { + onDelete: "cascade" + }), + siteId: integer("siteId").references(() => sites.siteId, { + onDelete: "cascade" + }), + clientId: integer("clientId").references(() => clients.clientId, { + onDelete: "cascade" + }), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), + sourceAddr: text("sourceAddr").notNull(), + destAddr: text("destAddr").notNull(), + protocol: text("protocol").notNull(), + startedAt: integer("startedAt").notNull(), + endedAt: integer("endedAt"), + bytesTx: integer("bytesTx"), + bytesRx: integer("bytesRx") + }, + (table) => [ + index("idx_accessAuditLog_startedAt").on(table.startedAt), + index("idx_accessAuditLog_org_startedAt").on( + table.orgId, + table.startedAt + ), + index("idx_accessAuditLog_siteResourceId").on(table.siteResourceId) + ] +); + +export const approvals = pgTable("approvals", { + approvalId: serial("approvalId").primaryKey(), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: varchar("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + clientId: integer("clientId").references(() => clients.clientId, { + onDelete: "cascade" + }), // clients reference user devices (in this case) + userId: varchar("userId") + .references(() => users.userId, { + // optionally tied to a user and in this case delete when the user deletes + onDelete: "cascade" + }) + .notNull(), + decision: varchar("decision") + .$type<"approved" | "denied" | "pending">() + .default("pending") + .notNull(), + type: varchar("type") + .$type<"user_device" /*| 'proxy' // for later */>() + .notNull() +}); + +export const bannedEmails = pgTable("bannedEmails", { + email: varchar("email", { length: 255 }).primaryKey() +}); + +export const bannedIps = pgTable("bannedIps", { + ip: varchar("ip", { length: 255 }).primaryKey() +}); + +export const siteProvisioningKeys = pgTable("siteProvisioningKeys", { + siteProvisioningKeyId: varchar("siteProvisioningKeyId", { + length: 255 + }).primaryKey(), + name: varchar("name", { length: 255 }).notNull(), + siteProvisioningKeyHash: text("siteProvisioningKeyHash").notNull(), + lastChars: varchar("lastChars", { length: 4 }).notNull(), + createdAt: varchar("dateCreated", { length: 255 }).notNull(), + lastUsed: varchar("lastUsed", { length: 255 }), + maxBatchSize: integer("maxBatchSize"), // null = no limit + numUsed: integer("numUsed").notNull().default(0), + validUntil: varchar("validUntil", { length: 255 }) +}); + +export const siteProvisioningKeyOrg = pgTable( + "siteProvisioningKeyOrg", + { + siteProvisioningKeyId: varchar("siteProvisioningKeyId", { + length: 255 + }) + .notNull() + .references(() => siteProvisioningKeys.siteProvisioningKeyId, { + onDelete: "cascade" + }), + orgId: varchar("orgId", { length: 255 }) + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) + }, + (table) => [ + primaryKey({ + columns: [table.siteProvisioningKeyId, table.orgId] + }) + ] +); + +export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; @@ -283,5 +434,7 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; +export type LoginPageBranding = InferSelectModel; export type ActionAuditLog = InferSelectModel; export type AccessAuditLog = InferSelectModel; +export type ConnectionAuditLog = InferSelectModel; diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index 71877f2f1..bb05ca358 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -1,17 +1,18 @@ -import { - pgTable, - serial, - varchar, - boolean, - integer, - bigint, - real, - text, - index -} from "drizzle-orm/pg-core"; -import { InferSelectModel } from "drizzle-orm"; import { randomUUID } from "crypto"; -import { alias } from "yargs"; +import { InferSelectModel } from "drizzle-orm"; +import { + bigint, + boolean, + index, + integer, + pgTable, + primaryKey, + real, + serial, + text, + unique, + varchar +} from "drizzle-orm/pg-core"; export const domains = pgTable("domains", { domainId: varchar("domainId").primaryKey(), @@ -23,7 +24,8 @@ export const domains = pgTable("domains", { tries: integer("tries").notNull().default(0), certResolver: varchar("certResolver"), customCertResolver: varchar("customCertResolver"), - preferWildcardCert: boolean("preferWildcardCert") + preferWildcardCert: boolean("preferWildcardCert"), + errorMessage: text("errorMessage") }); export const dnsRecords = pgTable("dnsRecords", { @@ -54,7 +56,14 @@ export const orgs = pgTable("orgs", { .default(0), settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() - .default(0) + .default(0), + settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year + .notNull() + .default(0), + sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) + sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format) + isBillingOrg: boolean("isBillingOrg"), + billingOrgId: varchar("billingOrgId") }); export const orgDomains = pgTable("orgDomains", { @@ -85,6 +94,7 @@ export const sites = pgTable("sites", { lastBandwidthUpdate: varchar("lastBandwidthUpdate"), type: varchar("type").notNull(), // "newt" or "wireguard" online: boolean("online").notNull().default(false), + lastPing: integer("lastPing"), address: varchar("address"), endpoint: varchar("endpoint"), publicKey: varchar("publicKey"), @@ -131,7 +141,18 @@ export const resources = pgTable("resources", { }), headers: text("headers"), // comma-separated list of headers to add to the request proxyProtocol: boolean("proxyProtocol").notNull().default(false), - proxyProtocolVersion: integer("proxyProtocolVersion").default(1) + proxyProtocolVersion: integer("proxyProtocolVersion").default(1), + + maintenanceModeEnabled: boolean("maintenanceModeEnabled") + .notNull() + .default(false), + maintenanceModeType: text("maintenanceModeType", { + enum: ["forced", "automatic"] + }).default("forced"), // "forced" = always show, "automatic" = only when down + maintenanceTitle: text("maintenanceTitle"), + maintenanceMessage: text("maintenanceMessage"), + maintenanceEstimatedTime: text("maintenanceEstimatedTime"), + postAuthPath: text("postAuthPath") }); export const targets = pgTable("targets", { @@ -176,7 +197,9 @@ export const targetHealthCheck = pgTable("targetHealthCheck", { hcFollowRedirects: boolean("hcFollowRedirects").default(true), hcMethod: varchar("hcMethod").default("GET"), hcStatus: integer("hcStatus"), // http code - hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy" + hcHealth: text("hcHealth") + .$type<"unknown" | "healthy" | "unhealthy">() + .default("unknown"), // "unknown", "healthy", "unhealthy" hcTlsServerName: text("hcTlsServerName") }); @@ -206,14 +229,21 @@ export const siteResources = pgTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: varchar("niceId").notNull(), name: varchar("name").notNull(), - mode: varchar("mode").notNull(), // "host" | "cidr" | "port" + mode: varchar("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" protocol: varchar("protocol"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode 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").notNull().default("*"), + udpPortRangeString: varchar("udpPortRangeString").notNull().default("*"), + disableIcmp: boolean("disableIcmp").notNull().default(false), + authDaemonPort: integer("authDaemonPort").default(22123), + authDaemonMode: varchar("authDaemonMode", { length: 32 }) + .$type<"site" | "remote">() + .default("site") }); export const clientSiteResources = pgTable("clientSiteResources", { @@ -260,6 +290,7 @@ export const users = pgTable("user", { dateCreated: varchar("dateCreated").notNull(), termsAcceptedTimestamp: varchar("termsAcceptedTimestamp"), termsVersion: varchar("termsVersion"), + marketingEmailConsent: boolean("marketingEmailConsent").default(false), serverAdmin: boolean("serverAdmin").notNull().default(false), lastPasswordChange: bigint("lastPasswordChange", { mode: "number" }) }); @@ -309,11 +340,9 @@ export const userOrgs = pgTable("userOrgs", { onDelete: "cascade" }) .notNull(), - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId), isOwner: boolean("isOwner").notNull().default(false), - autoProvisioned: boolean("autoProvisioned").default(false) + autoProvisioned: boolean("autoProvisioned").default(false), + pamUsername: varchar("pamUsername") // cleaned username for ssh and such }); export const emailVerificationCodes = pgTable("emailVerificationCodes", { @@ -351,9 +380,30 @@ export const roles = pgTable("roles", { .notNull(), isAdmin: boolean("isAdmin"), name: varchar("name").notNull(), - description: varchar("description") + description: varchar("description"), + requireDeviceApproval: boolean("requireDeviceApproval").default(false), + sshSudoMode: varchar("sshSudoMode", { length: 32 }).default("none"), // "none" | "full" | "commands" + sshSudoCommands: text("sshSudoCommands").default("[]"), + sshCreateHomeDir: boolean("sshCreateHomeDir").default(true), + sshUnixGroups: text("sshUnixGroups").default("[]") }); +export const userOrgRoles = pgTable( + "userOrgRoles", + { + userId: varchar("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + orgId: varchar("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) + }, + (t) => [unique().on(t.userId, t.orgId, t.roleId)] +); + export const roleActions = pgTable("roleActions", { roleId: integer("roleId") .notNull() @@ -421,12 +471,22 @@ export const userInvites = pgTable("userInvites", { .references(() => orgs.orgId, { onDelete: "cascade" }), email: varchar("email").notNull(), expiresAt: bigint("expiresAt", { mode: "number" }).notNull(), - tokenHash: varchar("token").notNull(), - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }) + tokenHash: varchar("token").notNull() }); +export const userInviteRoles = pgTable( + "userInviteRoles", + { + inviteId: varchar("inviteId") + .notNull() + .references(() => userInvites.inviteId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) + }, + (t) => [primaryKey({ columns: [t.inviteId, t.roleId] })] +); + export const resourcePincode = pgTable("resourcePincode", { pincodeId: serial("pincodeId").primaryKey(), resourceId: integer("resourceId") @@ -452,6 +512,23 @@ export const resourceHeaderAuth = pgTable("resourceHeaderAuth", { headerAuthHash: varchar("headerAuthHash").notNull() }); +export const resourceHeaderAuthExtendedCompatibility = pgTable( + "resourceHeaderAuthExtendedCompatibility", + { + headerAuthExtendedCompatibilityId: serial( + "headerAuthExtendedCompatibilityId" + ).primaryKey(), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + extendedCompatibilityIsActivated: boolean( + "extendedCompatibilityIsActivated" + ) + .notNull() + .default(true) + } +); + export const resourceAccessToken = pgTable("resourceAccessToken", { accessTokenId: varchar("accessTokenId").primaryKey(), orgId: varchar("orgId") @@ -560,7 +637,8 @@ export const idp = pgTable("idp", { type: varchar("type").notNull(), defaultRoleMapping: varchar("defaultRoleMapping"), defaultOrgMapping: varchar("defaultOrgMapping"), - autoProvision: boolean("autoProvision").notNull().default(false) + autoProvision: boolean("autoProvision").notNull().default(false), + tags: text("tags") }); export const idpOidcConfig = pgTable("idpOidcConfig", { @@ -657,7 +735,12 @@ export const clients = pgTable("clients", { online: boolean("online").notNull().default(false), // endpoint: varchar("endpoint"), lastHolePunch: integer("lastHolePunch"), - maxConnections: integer("maxConnections") + maxConnections: integer("maxConnections"), + archived: boolean("archived").notNull().default(false), + blocked: boolean("blocked").notNull().default(false), + approvalState: varchar("approvalState").$type< + "pending" | "approved" | "denied" + >() }); export const clientSitesAssociationsCache = pgTable( @@ -667,6 +750,7 @@ export const clientSitesAssociationsCache = pgTable( .notNull(), siteId: integer("siteId").notNull(), isRelayed: boolean("isRelayed").notNull().default(false), + isJitMode: boolean("isJitMode").notNull().default(false), endpoint: varchar("endpoint"), publicKey: varchar("publicKey") // this will act as the session's public key for hole punching so we can track when it changes } @@ -681,6 +765,16 @@ export const clientSiteResourcesAssociationsCache = pgTable( } ); +export const clientPostureSnapshots = pgTable("clientPostureSnapshots", { + snapshotId: serial("snapshotId").primaryKey(), + + clientId: integer("clientId").references(() => clients.clientId, { + onDelete: "cascade" + }), + + collectedAt: integer("collectedAt").notNull() +}); + export const olms = pgTable("olms", { olmId: varchar("id").primaryKey(), secretHash: varchar("secretHash").notNull(), @@ -695,7 +789,118 @@ export const olms = pgTable("olms", { userId: text("userId").references(() => users.userId, { // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" - }) + }), + archived: boolean("archived").notNull().default(false) +}); + +export const currentFingerprint = pgTable("currentFingerprint", { + fingerprintId: serial("id").primaryKey(), + + olmId: text("olmId") + .references(() => olms.olmId, { onDelete: "cascade" }) + .notNull(), + + firstSeen: integer("firstSeen").notNull(), + lastSeen: integer("lastSeen").notNull(), + lastCollectedAt: integer("lastCollectedAt").notNull(), + + username: text("username"), + hostname: text("hostname"), + platform: text("platform"), + osVersion: text("osVersion"), + kernelVersion: text("kernelVersion"), + arch: text("arch"), + deviceModel: text("deviceModel"), + serialNumber: text("serialNumber"), + platformFingerprint: varchar("platformFingerprint"), + + // Platform-agnostic checks + + biometricsEnabled: boolean("biometricsEnabled").notNull().default(false), + diskEncrypted: boolean("diskEncrypted").notNull().default(false), + firewallEnabled: boolean("firewallEnabled").notNull().default(false), + autoUpdatesEnabled: boolean("autoUpdatesEnabled").notNull().default(false), + tpmAvailable: boolean("tpmAvailable").notNull().default(false), + + // Windows-specific posture check information + + windowsAntivirusEnabled: boolean("windowsAntivirusEnabled") + .notNull() + .default(false), + + // macOS-specific posture check information + + macosSipEnabled: boolean("macosSipEnabled").notNull().default(false), + macosGatekeeperEnabled: boolean("macosGatekeeperEnabled") + .notNull() + .default(false), + macosFirewallStealthMode: boolean("macosFirewallStealthMode") + .notNull() + .default(false), + + // Linux-specific posture check information + + linuxAppArmorEnabled: boolean("linuxAppArmorEnabled") + .notNull() + .default(false), + linuxSELinuxEnabled: boolean("linuxSELinuxEnabled").notNull().default(false) +}); + +export const fingerprintSnapshots = pgTable("fingerprintSnapshots", { + snapshotId: serial("id").primaryKey(), + + fingerprintId: integer("fingerprintId").references( + () => currentFingerprint.fingerprintId, + { + onDelete: "set null" + } + ), + + username: text("username"), + hostname: text("hostname"), + platform: text("platform"), + osVersion: text("osVersion"), + kernelVersion: text("kernelVersion"), + arch: text("arch"), + deviceModel: text("deviceModel"), + serialNumber: text("serialNumber"), + platformFingerprint: varchar("platformFingerprint"), + + // Platform-agnostic checks + + biometricsEnabled: boolean("biometricsEnabled").notNull().default(false), + diskEncrypted: boolean("diskEncrypted").notNull().default(false), + firewallEnabled: boolean("firewallEnabled").notNull().default(false), + autoUpdatesEnabled: boolean("autoUpdatesEnabled").notNull().default(false), + tpmAvailable: boolean("tpmAvailable").notNull().default(false), + + // Windows-specific posture check information + + windowsAntivirusEnabled: boolean("windowsAntivirusEnabled") + .notNull() + .default(false), + + // macOS-specific posture check information + + macosSipEnabled: boolean("macosSipEnabled").notNull().default(false), + macosGatekeeperEnabled: boolean("macosGatekeeperEnabled") + .notNull() + .default(false), + macosFirewallStealthMode: boolean("macosFirewallStealthMode") + .notNull() + .default(false), + + // Linux-specific posture check information + + linuxAppArmorEnabled: boolean("linuxAppArmorEnabled") + .notNull() + .default(false), + linuxSELinuxEnabled: boolean("linuxSELinuxEnabled") + .notNull() + .default(false), + + hash: text("hash").notNull(), + collectedAt: integer("collectedAt").notNull() }); export const olmSessions = pgTable("clientSession", { @@ -824,6 +1029,16 @@ export const deviceWebAuthCodes = pgTable("deviceWebAuthCodes", { }) }); +export const roundTripMessageTracker = pgTable("roundTripMessageTracker", { + messageId: serial("messageId").primaryKey(), + wsClientId: varchar("clientId"), + messageType: varchar("messageType"), + sentAt: bigint("sentAt", { mode: "number" }).notNull(), + receivedAt: bigint("receivedAt", { mode: "number" }), + error: text("error"), + complete: boolean("complete").notNull().default(false) +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -847,11 +1062,16 @@ export type UserSite = InferSelectModel; export type RoleResource = InferSelectModel; export type UserResource = InferSelectModel; export type UserInvite = InferSelectModel; +export type UserInviteRole = InferSelectModel; export type UserOrg = InferSelectModel; +export type UserOrgRole = InferSelectModel; export type ResourceSession = InferSelectModel; export type ResourcePincode = InferSelectModel; export type ResourcePassword = InferSelectModel; export type ResourceHeaderAuth = InferSelectModel; +export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel< + typeof resourceHeaderAuthExtendedCompatibility +>; export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; @@ -881,3 +1101,6 @@ export type SecurityKey = InferSelectModel; export type WebauthnChallenge = InferSelectModel; export type DeviceWebAuthCode = InferSelectModel; export type RequestAuditLog = InferSelectModel; +export type RoundTripMessageTracker = InferSelectModel< + typeof roundTripMessageTracker +>; diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 774c4e53d..989e111a7 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -1,4 +1,12 @@ -import { db, loginPage, LoginPage, loginPageOrg, Org, orgs } from "@server/db"; +import { + db, + loginPage, + LoginPage, + loginPageOrg, + Org, + orgs, + roles +} from "@server/db"; import { Resource, ResourcePassword, @@ -12,17 +20,19 @@ import { resources, roleResources, sessions, - userOrgs, userResources, - users + users, + ResourceHeaderAuthExtendedCompatibility, + resourceHeaderAuthExtendedCompatibility } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; export type ResourceWithAuth = { resource: Resource | null; pincode: ResourcePincode | null; password: ResourcePassword | null; headerAuth: ResourceHeaderAuth | null; + headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; org: Org; }; @@ -52,6 +62,13 @@ export async function getResourceByDomain( resourceHeaderAuth, eq(resourceHeaderAuth.resourceId, resources.resourceId) ) + .leftJoin( + resourceHeaderAuthExtendedCompatibility, + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + resources.resourceId + ) + ) .innerJoin(orgs, eq(orgs.orgId, resources.orgId)) .where(eq(resources.fullDomain, domain)) .limit(1); @@ -65,6 +82,8 @@ export async function getResourceByDomain( pincode: result.resourcePincode, password: result.resourcePassword, headerAuth: result.resourceHeaderAuth, + headerAuthExtendedCompatibility: + result.resourceHeaderAuthExtendedCompatibility, org: result.orgs }; } @@ -92,16 +111,15 @@ export async function getUserSessionWithUser( } /** - * Get user organization role + * Get role name by role ID (for display). */ -export async function getUserOrgRole(userId: string, orgId: string) { - const userOrgRole = await db - .select() - .from(userOrgs) - .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) +export async function getRoleName(roleId: number): Promise { + const [row] = await db + .select({ name: roles.name }) + .from(roles) + .where(eq(roles.roleId, roleId)) .limit(1); - - return userOrgRole.length > 0 ? userOrgRole[0] : null; + return row?.name ?? null; } /** @@ -109,7 +127,7 @@ export async function getUserOrgRole(userId: string, orgId: string) { */ export async function getRoleResourceAccess( resourceId: number, - roleId: number + roleIds: number[] ) { const roleResourceAccess = await db .select() @@ -117,12 +135,11 @@ export async function getRoleResourceAccess( .where( and( eq(roleResources.resourceId, resourceId), - eq(roleResources.roleId, roleId) + inArray(roleResources.roleId, roleIds) ) - ) - .limit(1); + ); - return roleResourceAccess.length > 0 ? roleResourceAccess[0] : null; + return roleResourceAccess.length > 0 ? roleResourceAccess : null; } /** diff --git a/server/db/sqlite/driver.ts b/server/db/sqlite/driver.ts index 0f696df65..9cbc8d7be 100644 --- a/server/db/sqlite/driver.ts +++ b/server/db/sqlite/driver.ts @@ -20,6 +20,7 @@ function createDb() { export const db = createDb(); export default db; +export const primaryDb = db; export type Transaction = Parameters< Parameters<(typeof db)["transaction"]>[0] >[0]; diff --git a/server/db/sqlite/index.ts b/server/db/sqlite/index.ts index 6e2c79f50..f8c04ac9e 100644 --- a/server/db/sqlite/index.ts +++ b/server/db/sqlite/index.ts @@ -1,3 +1,6 @@ export * from "./driver"; +export * from "./logsDriver"; +export * from "./safeRead"; export * from "./schema/schema"; export * from "./schema/privateSchema"; +export * from "./migrate"; diff --git a/server/db/sqlite/logsDriver.ts b/server/db/sqlite/logsDriver.ts new file mode 100644 index 000000000..f70c79fc5 --- /dev/null +++ b/server/db/sqlite/logsDriver.ts @@ -0,0 +1,7 @@ +import { db as mainDb } from "./driver"; + +// SQLite doesn't support separate databases for logs in the same way as Postgres +// Always use the main database connection for SQLite +export const logsDb = mainDb; +export default logsDb; +export const primaryLogsDb = logsDb; \ No newline at end of file diff --git a/server/db/sqlite/migrate.ts b/server/db/sqlite/migrate.ts index 7c337ae2d..79a3d8c73 100644 --- a/server/db/sqlite/migrate.ts +++ b/server/db/sqlite/migrate.ts @@ -4,7 +4,7 @@ import path from "path"; const migrationsFolder = path.join("server/migrations"); -const runMigrations = async () => { +export const runMigrations = async () => { console.log("Running migrations..."); try { migrate(db as any, { @@ -16,5 +16,3 @@ const runMigrations = async () => { process.exit(1); } }; - -runMigrations(); diff --git a/server/db/sqlite/safeRead.ts b/server/db/sqlite/safeRead.ts new file mode 100644 index 000000000..6d3e90686 --- /dev/null +++ b/server/db/sqlite/safeRead.ts @@ -0,0 +1,11 @@ +import { db } from "./driver"; + +/** + * Runs a read query. For SQLite there is no replica/primary distinction, + * so the query is executed once against the database. + */ +export async function safeRead( + query: (d: typeof db) => Promise +): Promise { + return query(db); +} diff --git a/server/db/sqlite/schema/privateSchema.ts b/server/db/sqlite/schema/privateSchema.ts index 975a949b0..d651c1a38 100644 --- a/server/db/sqlite/schema/privateSchema.ts +++ b/server/db/sqlite/schema/privateSchema.ts @@ -1,13 +1,13 @@ -import { - sqliteTable, - integer, - text, - real, - index -} from "drizzle-orm/sqlite-core"; import { InferSelectModel } from "drizzle-orm"; -import { domains, orgs, targets, users, exitNodes, sessions } from "./schema"; -import { metadata } from "@app/app/[orgId]/settings/layout"; +import { + index, + integer, + primaryKey, + real, + sqliteTable, + text +} from "drizzle-orm/sqlite-core"; +import { clients, domains, exitNodes, orgs, sessions, siteResources, sites, users } from "./schema"; export const certificates = sqliteTable("certificates", { certId: integer("certId").primaryKey({ autoIncrement: true }), @@ -71,13 +71,16 @@ export const subscriptions = sqliteTable("subscriptions", { canceledAt: integer("canceledAt"), createdAt: integer("createdAt").notNull(), updatedAt: integer("updatedAt"), - billingCycleAnchor: integer("billingCycleAnchor") + version: integer("version"), + billingCycleAnchor: integer("billingCycleAnchor"), + type: text("type") // tier1, tier2, tier3, or license }); export const subscriptionItems = sqliteTable("subscriptionItems", { subscriptionItemId: integer("subscriptionItemId").primaryKey({ autoIncrement: true }), + stripeSubscriptionItemId: text("stripeSubscriptionItemId"), subscriptionId: text("subscriptionId") .notNull() .references(() => subscriptions.subscriptionId, { @@ -85,6 +88,7 @@ export const subscriptionItems = sqliteTable("subscriptionItems", { }), planId: text("planId").notNull(), priceId: text("priceId"), + featureId: text("featureId"), meterId: text("meterId"), unitAmount: real("unitAmount"), tiers: text("tiers"), @@ -127,6 +131,7 @@ export const limits = sqliteTable("limits", { }) .notNull(), value: real("value"), + override: integer("override", { mode: "boolean" }).default(false), description: text("description") }); @@ -203,6 +208,31 @@ export const loginPageOrg = sqliteTable("loginPageOrg", { .references(() => orgs.orgId, { onDelete: "cascade" }) }); +export const loginPageBranding = sqliteTable("loginPageBranding", { + loginPageBrandingId: integer("loginPageBrandingId").primaryKey({ + autoIncrement: true + }), + logoUrl: text("logoUrl"), + logoWidth: integer("logoWidth").notNull(), + logoHeight: integer("logoHeight").notNull(), + primaryColor: text("primaryColor"), + resourceTitle: text("resourceTitle").notNull(), + resourceSubtitle: text("resourceSubtitle"), + orgTitle: text("orgTitle"), + orgSubtitle: text("orgSubtitle") +}); + +export const loginPageBrandingOrg = sqliteTable("loginPageBrandingOrg", { + loginPageBrandingId: integer("loginPageBrandingId") + .notNull() + .references(() => loginPageBranding.loginPageBrandingId, { + onDelete: "cascade" + }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) +}); + export const sessionTransferToken = sqliteTable("sessionTransferToken", { token: text("token").primaryKey(), sessionId: text("sessionId") @@ -249,6 +279,7 @@ export const accessAuditLog = sqliteTable( actor: text("actor"), actorId: text("actorId"), resourceId: integer("resourceId"), + siteResourceId: integer("siteResourceId"), ip: text("ip"), location: text("location"), type: text("type").notNull(), @@ -265,6 +296,109 @@ export const accessAuditLog = sqliteTable( ] ); +export const connectionAuditLog = sqliteTable( + "connectionAuditLog", + { + id: integer("id").primaryKey({ autoIncrement: true }), + sessionId: text("sessionId").notNull(), + siteResourceId: integer("siteResourceId").references( + () => siteResources.siteResourceId, + { onDelete: "cascade" } + ), + orgId: text("orgId").references(() => orgs.orgId, { + onDelete: "cascade" + }), + siteId: integer("siteId").references(() => sites.siteId, { + onDelete: "cascade" + }), + clientId: integer("clientId").references(() => clients.clientId, { + onDelete: "cascade" + }), + userId: text("userId").references(() => users.userId, { + onDelete: "cascade" + }), + sourceAddr: text("sourceAddr").notNull(), + destAddr: text("destAddr").notNull(), + protocol: text("protocol").notNull(), + startedAt: integer("startedAt").notNull(), + endedAt: integer("endedAt"), + bytesTx: integer("bytesTx"), + bytesRx: integer("bytesRx") + }, + (table) => [ + index("idx_accessAuditLog_startedAt").on(table.startedAt), + index("idx_accessAuditLog_org_startedAt").on( + table.orgId, + table.startedAt + ), + index("idx_accessAuditLog_siteResourceId").on(table.siteResourceId) + ] +); + +export const approvals = sqliteTable("approvals", { + approvalId: integer("approvalId").primaryKey({ autoIncrement: true }), + timestamp: integer("timestamp").notNull(), // this is EPOCH time in seconds + orgId: text("orgId") + .references(() => orgs.orgId, { + onDelete: "cascade" + }) + .notNull(), + clientId: integer("clientId").references(() => clients.clientId, { + onDelete: "cascade" + }), // olms reference user devices clients + userId: text("userId").references(() => users.userId, { + // optionally tied to a user and in this case delete when the user deletes + onDelete: "cascade" + }), + decision: text("decision") + .$type<"approved" | "denied" | "pending">() + .default("pending") + .notNull(), + type: text("type") + .$type<"user_device" /*| 'proxy' // for later */>() + .notNull() +}); + +export const bannedEmails = sqliteTable("bannedEmails", { + email: text("email").primaryKey() +}); + +export const bannedIps = sqliteTable("bannedIps", { + ip: text("ip").primaryKey() +}); + +export const siteProvisioningKeys = sqliteTable("siteProvisioningKeys", { + siteProvisioningKeyId: text("siteProvisioningKeyId").primaryKey(), + name: text("name").notNull(), + siteProvisioningKeyHash: text("siteProvisioningKeyHash").notNull(), + lastChars: text("lastChars").notNull(), + createdAt: text("dateCreated").notNull(), + lastUsed: text("lastUsed"), + maxBatchSize: integer("maxBatchSize"), // null = no limit + numUsed: integer("numUsed").notNull().default(0), + validUntil: text("validUntil") +}); + +export const siteProvisioningKeyOrg = sqliteTable( + "siteProvisioningKeyOrg", + { + siteProvisioningKeyId: text("siteProvisioningKeyId") + .notNull() + .references(() => siteProvisioningKeys.siteProvisioningKeyId, { + onDelete: "cascade" + }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }) + }, + (table) => [ + primaryKey({ + columns: [table.siteProvisioningKeyId, table.orgId] + }) + ] +); + +export type Approval = InferSelectModel; export type Limit = InferSelectModel; export type Account = InferSelectModel; export type Certificate = InferSelectModel; @@ -282,5 +416,7 @@ export type RemoteExitNodeSession = InferSelectModel< >; export type ExitNodeOrg = InferSelectModel; export type LoginPage = InferSelectModel; +export type LoginPageBranding = InferSelectModel; export type ActionAuditLog = InferSelectModel; export type AccessAuditLog = InferSelectModel; +export type ConnectionAuditLog = InferSelectModel; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index 6e17cac49..5d7c01377 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -1,7 +1,13 @@ import { randomUUID } from "crypto"; import { InferSelectModel } from "drizzle-orm"; -import { sqliteTable, text, integer, index } from "drizzle-orm/sqlite-core"; -import { no } from "zod/v4/locales"; +import { + index, + integer, + primaryKey, + sqliteTable, + text, + unique +} from "drizzle-orm/sqlite-core"; export const domains = sqliteTable("domains", { domainId: text("domainId").primaryKey(), @@ -14,7 +20,8 @@ export const domains = sqliteTable("domains", { failed: integer("failed", { mode: "boolean" }).notNull().default(false), tries: integer("tries").notNull().default(0), certResolver: text("certResolver"), - preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }) + preferWildcardCert: integer("preferWildcardCert", { mode: "boolean" }), + errorMessage: text("errorMessage") }); export const dnsRecords = sqliteTable("dnsRecords", { @@ -46,7 +53,14 @@ export const orgs = sqliteTable("orgs", { .default(0), settingsLogRetentionDaysAction: integer("settingsLogRetentionDaysAction") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year .notNull() - .default(0) + .default(0), + settingsLogRetentionDaysConnection: integer("settingsLogRetentionDaysConnection") // where 0 = dont keep logs and -1 = keep forever and 9001 = end of the following year + .notNull() + .default(0), + sshCaPrivateKey: text("sshCaPrivateKey"), // Encrypted SSH CA private key (PEM format) + sshCaPublicKey: text("sshCaPublicKey"), // SSH CA public key (OpenSSH format) + isBillingOrg: integer("isBillingOrg", { mode: "boolean" }), + billingOrgId: text("billingOrgId") }); export const userDomains = sqliteTable("userDomains", { @@ -86,6 +100,7 @@ export const sites = sqliteTable("sites", { lastBandwidthUpdate: text("lastBandwidthUpdate"), type: text("type").notNull(), // "newt" or "wireguard" online: integer("online", { mode: "boolean" }).notNull().default(false), + lastPing: integer("lastPing"), // exit node stuff that is how to connect to the site when it has a wg server address: text("address"), // this is the address of the wireguard interface in newt @@ -144,7 +159,20 @@ export const resources = sqliteTable("resources", { proxyProtocol: integer("proxyProtocol", { mode: "boolean" }) .notNull() .default(false), - proxyProtocolVersion: integer("proxyProtocolVersion").default(1) + proxyProtocolVersion: integer("proxyProtocolVersion").default(1), + + maintenanceModeEnabled: integer("maintenanceModeEnabled", { + mode: "boolean" + }) + .notNull() + .default(false), + maintenanceModeType: text("maintenanceModeType", { + enum: ["forced", "automatic"] + }).default("forced"), // "forced" = always show, "automatic" = only when down + maintenanceTitle: text("maintenanceTitle"), + maintenanceMessage: text("maintenanceMessage"), + maintenanceEstimatedTime: text("maintenanceEstimatedTime"), + postAuthPath: text("postAuthPath") }); export const targets = sqliteTable("targets", { @@ -195,7 +223,9 @@ export const targetHealthCheck = sqliteTable("targetHealthCheck", { }).default(true), hcMethod: text("hcMethod").default("GET"), hcStatus: integer("hcStatus"), // http code - hcHealth: text("hcHealth").default("unknown"), // "unknown", "healthy", "unhealthy" + hcHealth: text("hcHealth") + .$type<"unknown" | "healthy" | "unhealthy">() + .default("unknown"), // "unknown", "healthy", "unhealthy" hcTlsServerName: text("hcTlsServerName") }); @@ -227,14 +257,23 @@ export const siteResources = sqliteTable("siteResources", { .references(() => orgs.orgId, { onDelete: "cascade" }), niceId: text("niceId").notNull(), name: text("name").notNull(), - mode: text("mode").notNull(), // "host" | "cidr" | "port" + mode: text("mode").$type<"host" | "cidr">().notNull(), // "host" | "cidr" | "port" protocol: text("protocol"), // only for port mode proxyPort: integer("proxyPort"), // only for port mode destinationPort: integer("destinationPort"), // only for port mode 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").notNull().default("*"), + udpPortRangeString: text("udpPortRangeString").notNull().default("*"), + disableIcmp: integer("disableIcmp", { mode: "boolean" }) + .notNull() + .default(false), + authDaemonPort: integer("authDaemonPort").default(22123), + authDaemonMode: text("authDaemonMode") + .$type<"site" | "remote">() + .default("site") }); export const clientSiteResources = sqliteTable("clientSiteResources", { @@ -287,6 +326,9 @@ export const users = sqliteTable("user", { dateCreated: text("dateCreated").notNull(), termsAcceptedTimestamp: text("termsAcceptedTimestamp"), termsVersion: text("termsVersion"), + marketingEmailConsent: integer("marketingEmailConsent", { + mode: "boolean" + }).default(false), serverAdmin: integer("serverAdmin", { mode: "boolean" }) .notNull() .default(false), @@ -362,7 +404,12 @@ export const clients = sqliteTable("clients", { type: text("type").notNull(), // "olm" online: integer("online", { mode: "boolean" }).notNull().default(false), // endpoint: text("endpoint"), - lastHolePunch: integer("lastHolePunch") + lastHolePunch: integer("lastHolePunch"), + archived: integer("archived", { mode: "boolean" }).notNull().default(false), + blocked: integer("blocked", { mode: "boolean" }).notNull().default(false), + approvalState: text("approvalState").$type< + "pending" | "approved" | "denied" + >() }); export const clientSitesAssociationsCache = sqliteTable( @@ -374,6 +421,9 @@ export const clientSitesAssociationsCache = sqliteTable( isRelayed: integer("isRelayed", { mode: "boolean" }) .notNull() .default(false), + isJitMode: integer("isJitMode", { mode: "boolean" }) + .notNull() + .default(false), endpoint: text("endpoint"), publicKey: text("publicKey") // this will act as the session's public key for hole punching so we can track when it changes } @@ -402,7 +452,160 @@ export const olms = sqliteTable("olms", { userId: text("userId").references(() => users.userId, { // optionally tied to a user and in this case delete when the user deletes onDelete: "cascade" + }), + archived: integer("archived", { mode: "boolean" }).notNull().default(false) +}); + +export const currentFingerprint = sqliteTable("currentFingerprint", { + fingerprintId: integer("id").primaryKey({ autoIncrement: true }), + + olmId: text("olmId") + .references(() => olms.olmId, { onDelete: "cascade" }) + .notNull(), + + firstSeen: integer("firstSeen").notNull(), + lastSeen: integer("lastSeen").notNull(), + lastCollectedAt: integer("lastCollectedAt").notNull(), + + username: text("username"), + hostname: text("hostname"), + platform: text("platform"), + osVersion: text("osVersion"), + kernelVersion: text("kernelVersion"), + arch: text("arch"), + deviceModel: text("deviceModel"), + serialNumber: text("serialNumber"), + platformFingerprint: text("platformFingerprint"), + + // Platform-agnostic checks + + biometricsEnabled: integer("biometricsEnabled", { mode: "boolean" }) + .notNull() + .default(false), + diskEncrypted: integer("diskEncrypted", { mode: "boolean" }) + .notNull() + .default(false), + firewallEnabled: integer("firewallEnabled", { mode: "boolean" }) + .notNull() + .default(false), + autoUpdatesEnabled: integer("autoUpdatesEnabled", { mode: "boolean" }) + .notNull() + .default(false), + tpmAvailable: integer("tpmAvailable", { mode: "boolean" }) + .notNull() + .default(false), + + // Windows-specific posture check information + + windowsAntivirusEnabled: integer("windowsAntivirusEnabled", { + mode: "boolean" }) + .notNull() + .default(false), + + // macOS-specific posture check information + + macosSipEnabled: integer("macosSipEnabled", { mode: "boolean" }) + .notNull() + .default(false), + macosGatekeeperEnabled: integer("macosGatekeeperEnabled", { + mode: "boolean" + }) + .notNull() + .default(false), + macosFirewallStealthMode: integer("macosFirewallStealthMode", { + mode: "boolean" + }) + .notNull() + .default(false), + + // Linux-specific posture check information + + linuxAppArmorEnabled: integer("linuxAppArmorEnabled", { mode: "boolean" }) + .notNull() + .default(false), + linuxSELinuxEnabled: integer("linuxSELinuxEnabled", { + mode: "boolean" + }) + .notNull() + .default(false) +}); + +export const fingerprintSnapshots = sqliteTable("fingerprintSnapshots", { + snapshotId: integer("id").primaryKey({ autoIncrement: true }), + + fingerprintId: integer("fingerprintId").references( + () => currentFingerprint.fingerprintId, + { + onDelete: "set null" + } + ), + + username: text("username"), + hostname: text("hostname"), + platform: text("platform"), + osVersion: text("osVersion"), + kernelVersion: text("kernelVersion"), + arch: text("arch"), + deviceModel: text("deviceModel"), + serialNumber: text("serialNumber"), + platformFingerprint: text("platformFingerprint"), + + // Platform-agnostic checks + + biometricsEnabled: integer("biometricsEnabled", { mode: "boolean" }) + .notNull() + .default(false), + diskEncrypted: integer("diskEncrypted", { mode: "boolean" }) + .notNull() + .default(false), + firewallEnabled: integer("firewallEnabled", { mode: "boolean" }) + .notNull() + .default(false), + autoUpdatesEnabled: integer("autoUpdatesEnabled", { mode: "boolean" }) + .notNull() + .default(false), + tpmAvailable: integer("tpmAvailable", { mode: "boolean" }) + .notNull() + .default(false), + + // Windows-specific posture check information + + windowsAntivirusEnabled: integer("windowsAntivirusEnabled", { + mode: "boolean" + }) + .notNull() + .default(false), + + // macOS-specific posture check information + + macosSipEnabled: integer("macosSipEnabled", { mode: "boolean" }) + .notNull() + .default(false), + macosGatekeeperEnabled: integer("macosGatekeeperEnabled", { + mode: "boolean" + }) + .notNull() + .default(false), + macosFirewallStealthMode: integer("macosFirewallStealthMode", { + mode: "boolean" + }) + .notNull() + .default(false), + + // Linux-specific posture check information + + linuxAppArmorEnabled: integer("linuxAppArmorEnabled", { mode: "boolean" }) + .notNull() + .default(false), + linuxSELinuxEnabled: integer("linuxSELinuxEnabled", { + mode: "boolean" + }) + .notNull() + .default(false), + + hash: text("hash").notNull(), + collectedAt: integer("collectedAt").notNull() }); export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", { @@ -450,13 +653,11 @@ export const userOrgs = sqliteTable("userOrgs", { onDelete: "cascade" }) .notNull(), - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId), isOwner: integer("isOwner", { mode: "boolean" }).notNull().default(false), autoProvisioned: integer("autoProvisioned", { mode: "boolean" - }).default(false) + }).default(false), + pamUsername: text("pamUsername") // cleaned username for ssh and such }); export const emailVerificationCodes = sqliteTable("emailVerificationCodes", { @@ -494,9 +695,34 @@ export const roles = sqliteTable("roles", { .notNull(), isAdmin: integer("isAdmin", { mode: "boolean" }), name: text("name").notNull(), - description: text("description") + description: text("description"), + requireDeviceApproval: integer("requireDeviceApproval", { + mode: "boolean" + }).default(false), + sshSudoMode: text("sshSudoMode").default("none"), // "none" | "full" | "commands" + sshSudoCommands: text("sshSudoCommands").default("[]"), + sshCreateHomeDir: integer("sshCreateHomeDir", { mode: "boolean" }).default( + true + ), + sshUnixGroups: text("sshUnixGroups").default("[]") }); +export const userOrgRoles = sqliteTable( + "userOrgRoles", + { + userId: text("userId") + .notNull() + .references(() => users.userId, { onDelete: "cascade" }), + orgId: text("orgId") + .notNull() + .references(() => orgs.orgId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) + }, + (t) => [unique().on(t.userId, t.orgId, t.roleId)] +); + export const roleActions = sqliteTable("roleActions", { roleId: integer("roleId") .notNull() @@ -582,12 +808,22 @@ export const userInvites = sqliteTable("userInvites", { .references(() => orgs.orgId, { onDelete: "cascade" }), email: text("email").notNull(), expiresAt: integer("expiresAt").notNull(), - tokenHash: text("token").notNull(), - roleId: integer("roleId") - .notNull() - .references(() => roles.roleId, { onDelete: "cascade" }) + tokenHash: text("token").notNull() }); +export const userInviteRoles = sqliteTable( + "userInviteRoles", + { + inviteId: text("inviteId") + .notNull() + .references(() => userInvites.inviteId, { onDelete: "cascade" }), + roleId: integer("roleId") + .notNull() + .references(() => roles.roleId, { onDelete: "cascade" }) + }, + (t) => [primaryKey({ columns: [t.inviteId, t.roleId] })] +); + export const resourcePincode = sqliteTable("resourcePincode", { pincodeId: integer("pincodeId").primaryKey({ autoIncrement: true @@ -619,6 +855,26 @@ export const resourceHeaderAuth = sqliteTable("resourceHeaderAuth", { headerAuthHash: text("headerAuthHash").notNull() }); +export const resourceHeaderAuthExtendedCompatibility = sqliteTable( + "resourceHeaderAuthExtendedCompatibility", + { + headerAuthExtendedCompatibilityId: integer( + "headerAuthExtendedCompatibilityId" + ).primaryKey({ + autoIncrement: true + }), + resourceId: integer("resourceId") + .notNull() + .references(() => resources.resourceId, { onDelete: "cascade" }), + extendedCompatibilityIsActivated: integer( + "extendedCompatibilityIsActivated", + { mode: "boolean" } + ) + .notNull() + .default(true) + } +); + export const resourceAccessToken = sqliteTable("resourceAccessToken", { accessTokenId: text("accessTokenId").primaryKey(), orgId: text("orgId") @@ -733,7 +989,8 @@ export const idp = sqliteTable("idp", { mode: "boolean" }) .notNull() - .default(false) + .default(false), + tags: text("tags") }); // Identity Provider OAuth Configuration @@ -874,6 +1131,16 @@ export const deviceWebAuthCodes = sqliteTable("deviceWebAuthCodes", { }) }); +export const roundTripMessageTracker = sqliteTable("roundTripMessageTracker", { + messageId: integer("messageId").primaryKey({ autoIncrement: true }), + wsClientId: text("clientId"), + messageType: text("messageType"), + sentAt: integer("sentAt").notNull(), + receivedAt: integer("receivedAt"), + error: text("error"), + complete: integer("complete", { mode: "boolean" }).notNull().default(false) +}); + export type Org = InferSelectModel; export type User = InferSelectModel; export type Site = InferSelectModel; @@ -899,11 +1166,16 @@ export type UserSite = InferSelectModel; export type RoleResource = InferSelectModel; export type UserResource = InferSelectModel; export type UserInvite = InferSelectModel; +export type UserInviteRole = InferSelectModel; export type UserOrg = InferSelectModel; +export type UserOrgRole = InferSelectModel; export type ResourceSession = InferSelectModel; export type ResourcePincode = InferSelectModel; export type ResourcePassword = InferSelectModel; export type ResourceHeaderAuth = InferSelectModel; +export type ResourceHeaderAuthExtendedCompatibility = InferSelectModel< + typeof resourceHeaderAuthExtendedCompatibility +>; export type ResourceOtp = InferSelectModel; export type ResourceAccessToken = InferSelectModel; export type ResourceWhitelist = InferSelectModel; @@ -932,3 +1204,6 @@ export type SecurityKey = InferSelectModel; export type WebauthnChallenge = InferSelectModel; export type RequestAuditLog = InferSelectModel; export type DeviceWebAuthCode = InferSelectModel; +export type RoundTripMessageTracker = InferSelectModel< + typeof roundTripMessageTracker +>; diff --git a/server/emails/sendEmail.ts b/server/emails/sendEmail.ts index c8a0b0771..32a5fb472 100644 --- a/server/emails/sendEmail.ts +++ b/server/emails/sendEmail.ts @@ -10,6 +10,7 @@ export async function sendEmail( from: string | undefined; to: string | undefined; subject: string; + replyTo?: string; } ) { if (!emailClient) { @@ -32,6 +33,7 @@ export async function sendEmail( address: opts.from }, to: opts.to, + replyTo: opts.replyTo, subject: opts.subject, html: emailHtml }); diff --git a/server/emails/templates/EnterpriseEditionKeyGenerated.tsx b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx new file mode 100644 index 000000000..44472c8a6 --- /dev/null +++ b/server/emails/templates/EnterpriseEditionKeyGenerated.tsx @@ -0,0 +1,118 @@ +import React from "react"; +import { Body, Head, Html, Preview, Tailwind } from "@react-email/components"; +import { themeColors } from "./lib/theme"; +import { + EmailContainer, + EmailFooter, + EmailGreeting, + EmailHeading, + EmailInfoSection, + EmailLetterHead, + EmailSection, + EmailSignature, + EmailText +} from "./components/Email"; +import CopyCodeBox from "./components/CopyCodeBox"; +import ButtonLink from "./components/ButtonLink"; + +type EnterpriseEditionKeyGeneratedProps = { + keyValue: string; + personalUseOnly: boolean; + users: number; + sites: number; + modifySubscriptionLink?: string; +}; + +export const EnterpriseEditionKeyGenerated = ({ + keyValue, + personalUseOnly, + users, + sites, + modifySubscriptionLink +}: EnterpriseEditionKeyGeneratedProps) => { + const previewText = personalUseOnly + ? "Your Enterprise Edition key for personal use is ready" + : "Thank you for your purchase — your Enterprise Edition key is ready"; + + return ( + + + {previewText} + + + + + + Hi there, + + {personalUseOnly ? ( + + Your Enterprise Edition license key has been + generated. Qualifying users can use the + Enterprise Edition for free for{" "} + personal use only. + + ) : ( + <> + + Thank you for your purchase. Your Enterprise + Edition license key is ready. Below are the + terms of your license. + + + {modifySubscriptionLink && ( + + + Modify subscription + + + )} + + )} + + + Your license key: + + + + + If you need to purchase additional license keys or + modify your existing license, please reach out to + our support team at{" "} + + support@pangolin.net + + . + + + + + + + + + + ); +}; + +export default EnterpriseEditionKeyGenerated; diff --git a/server/emails/templates/components/CopyCodeBox.tsx b/server/emails/templates/components/CopyCodeBox.tsx index 3e4d1d08e..497fe7a93 100644 --- a/server/emails/templates/components/CopyCodeBox.tsx +++ b/server/emails/templates/components/CopyCodeBox.tsx @@ -1,6 +1,14 @@ import React from "react"; -export default function CopyCodeBox({ text }: { text: string }) { +const DEFAULT_HINT = "Copy and paste this code when prompted"; + +export default function CopyCodeBox({ + text, + hint +}: { + text: string; + hint?: string; +}) { return (
@@ -8,9 +16,7 @@ export default function CopyCodeBox({ text }: { text: string }) { {text}
-

- Copy and paste this code when prompted -

+

{hint ?? DEFAULT_HINT}

); } diff --git a/server/index.ts b/server/index.ts index a61daca7f..0fc44c279 100644 --- a/server/index.ts +++ b/server/index.ts @@ -74,7 +74,7 @@ declare global { session: Session; userOrg?: UserOrg; apiKeyOrg?: ApiKeyOrg; - userOrgRoleId?: number; + userOrgRoleIds?: number[]; userOrgId?: string; userOrgIds?: string[]; remoteExitNode?: RemoteExitNode; diff --git a/server/integrationApiServer.ts b/server/integrationApiServer.ts index 0ef0c0afc..ce029d9b5 100644 --- a/server/integrationApiServer.ts +++ b/server/integrationApiServer.ts @@ -17,6 +17,7 @@ import fs from "fs"; import path from "path"; import { APP_PATH } from "./lib/consts"; import yaml from "js-yaml"; +import { z } from "zod"; const dev = process.env.ENVIRONMENT !== "prod"; const externalPort = config.getRawConfig().server.integration_port; @@ -38,12 +39,24 @@ export function createIntegrationApiServer() { apiServer.use(cookieParser()); apiServer.use(express.json()); + const openApiDocumentation = getOpenApiDocumentation(); + apiServer.use( "/v1/docs", swaggerUi.serve, - swaggerUi.setup(getOpenApiDocumentation()) + swaggerUi.setup(openApiDocumentation) ); + // Unauthenticated OpenAPI spec endpoints + apiServer.get("/v1/openapi.json", (_req, res) => { + res.json(openApiDocumentation); + }); + + apiServer.get("/v1/openapi.yaml", (_req, res) => { + const yamlOutput = yaml.dump(openApiDocumentation); + res.type("application/yaml").send(yamlOutput); + }); + // API routes const prefix = `/v1`; apiServer.use(logIncomingMiddleware); @@ -75,16 +88,6 @@ function getOpenApiDocumentation() { } ); - for (const def of registry.definitions) { - if (def.type === "route") { - def.route.security = [ - { - [bearerAuth.name]: [] - } - ]; - } - } - registry.registerPath({ method: "get", path: "/", @@ -94,6 +97,74 @@ function getOpenApiDocumentation() { responses: {} }); + registry.registerPath({ + method: "get", + path: "/openapi.json", + description: "Get OpenAPI specification as JSON", + tags: [], + request: {}, + responses: { + "200": { + description: "OpenAPI specification as JSON", + content: { + "application/json": { + schema: { + type: "object" + } + } + } + } + } + }); + + registry.registerPath({ + method: "get", + path: "/openapi.yaml", + description: "Get OpenAPI specification as YAML", + tags: [], + request: {}, + responses: { + "200": { + description: "OpenAPI specification as YAML", + content: { + "application/yaml": { + schema: { + type: "string" + } + } + } + } + } + }); + + for (const def of registry.definitions) { + if (def.type === "route") { + def.route.security = [ + { + [bearerAuth.name]: [] + } + ]; + + // Ensure every route has a generic JSON response schema so Swagger UI can render responses + const existingResponses = def.route.responses; + const hasExistingResponses = + existingResponses && Object.keys(existingResponses).length > 0; + + if (!hasExistingResponses) { + def.route.responses = { + "*": { + description: "", + content: { + "application/json": { + schema: z.object({}) + } + } + } + }; + } + } + } + const generator = new OpenApiGeneratorV3(registry.definitions); const generated = generator.generateDocument({ @@ -105,11 +176,13 @@ function getOpenApiDocumentation() { servers: [{ url: "/v1" }] }); - // convert to yaml and save to file - const outputPath = path.join(APP_PATH, "openapi.yaml"); - const yamlOutput = yaml.dump(generated); - fs.writeFileSync(outputPath, yamlOutput, "utf8"); - logger.info(`OpenAPI documentation saved to ${outputPath}`); + if (!process.env.DISABLE_GEN_OPENAPI) { + // convert to yaml and save to file + const outputPath = path.join(APP_PATH, "openapi.yaml"); + const yamlOutput = yaml.dump(generated); + fs.writeFileSync(outputPath, yamlOutput, "utf8"); + logger.info(`OpenAPI documentation saved to ${outputPath}`); + } return generated; } diff --git a/server/internalServer.ts b/server/internalServer.ts index d15e3c45d..7ba046e4b 100644 --- a/server/internalServer.ts +++ b/server/internalServer.ts @@ -16,6 +16,11 @@ const internalPort = config.getRawConfig().server.internal_port; export function createInternalServer() { const internalServer = express(); + const trustProxy = config.getRawConfig().server.trust_proxy; + if (trustProxy) { + internalServer.set("trust proxy", trustProxy); + } + internalServer.use(helmet()); internalServer.use(cors()); internalServer.use(stripDuplicateSesions); diff --git a/server/lib/billing/features.ts b/server/lib/billing/features.ts index d074894ab..6063b470f 100644 --- a/server/lib/billing/features.ts +++ b/server/lib/billing/features.ts @@ -1,30 +1,44 @@ -import Stripe from "stripe"; - export enum FeatureId { - SITE_UPTIME = "siteUptime", USERS = "users", + SITES = "sites", EGRESS_DATA_MB = "egressDataMb", DOMAINS = "domains", - REMOTE_EXIT_NODES = "remoteExitNodes" + REMOTE_EXIT_NODES = "remoteExitNodes", + ORGINIZATIONS = "organizations", + TIER1 = "tier1" } -export const FeatureMeterIds: Record = { - [FeatureId.SITE_UPTIME]: "mtr_61Srrej5wUJuiTWgo41D3Ee2Ir7WmDLU", - [FeatureId.USERS]: "mtr_61SrreISyIWpwUNGR41D3Ee2Ir7WmQro", - [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW", - [FeatureId.DOMAINS]: "mtr_61Ss9nIKDNMw0LDRU41D3Ee2Ir7WmRPU", - [FeatureId.REMOTE_EXIT_NODES]: "mtr_61T86UXnfxTVXy9sD41D3Ee2Ir7WmFTE" +export async function getFeatureDisplayName(featureId: FeatureId): Promise { + switch (featureId) { + case FeatureId.USERS: + return "Users"; + case FeatureId.SITES: + return "Sites"; + case FeatureId.EGRESS_DATA_MB: + return "Egress Data (MB)"; + case FeatureId.DOMAINS: + return "Domains"; + case FeatureId.REMOTE_EXIT_NODES: + return "Remote Exit Nodes"; + case FeatureId.ORGINIZATIONS: + return "Organizations"; + case FeatureId.TIER1: + return "Home Lab"; + default: + return featureId; + } +} + +// this is from the old system +export const FeatureMeterIds: Partial> = { // right now we are not charging for any data + // [FeatureId.EGRESS_DATA_MB]: "mtr_61Srreh9eWrExDSCe41D3Ee2Ir7Wm5YW" }; -export const FeatureMeterIdsSandbox: Record = { - [FeatureId.SITE_UPTIME]: "mtr_test_61Snh3cees4w60gv841DCpkOb237BDEu", - [FeatureId.USERS]: "mtr_test_61Sn5fLtq1gSfRkyA41DCpkOb237B6au", - [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ", - [FeatureId.DOMAINS]: "mtr_test_61SsA8qrdAlgPpFRQ41DCpkOb237BGts", - [FeatureId.REMOTE_EXIT_NODES]: "mtr_test_61T86Vqmwa3D9ra3341DCpkOb237B94K" +export const FeatureMeterIdsSandbox: Partial> = { + // [FeatureId.EGRESS_DATA_MB]: "mtr_test_61Snh2a2m6qome5Kv41DCpkOb237B3dQ" }; -export function getFeatureMeterId(featureId: FeatureId): string { +export function getFeatureMeterId(featureId: FeatureId): string | undefined { if ( process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true" @@ -43,45 +57,81 @@ export function getFeatureIdByMetricId( )?.[0]; } -export type FeaturePriceSet = { - [key in Exclude]: string; -} & { - [FeatureId.DOMAINS]?: string; // Optional since domains are not billed +export type FeaturePriceSet = Partial>; + +export const tier1FeaturePriceSet: FeaturePriceSet = { + [FeatureId.TIER1]: "price_1SzVE3D3Ee2Ir7Wm6wT5Dl3G" }; -export const standardFeaturePriceSet: FeaturePriceSet = { - // Free tier matches the freeLimitSet - [FeatureId.SITE_UPTIME]: "price_1RrQc4D3Ee2Ir7WmaJGZ3MtF", - [FeatureId.USERS]: "price_1RrQeJD3Ee2Ir7WmgveP3xea", - [FeatureId.EGRESS_DATA_MB]: "price_1RrQXFD3Ee2Ir7WmvGDlgxQk", - // [FeatureId.DOMAINS]: "price_1Rz3tMD3Ee2Ir7Wm5qLeASzC", - [FeatureId.REMOTE_EXIT_NODES]: "price_1S46weD3Ee2Ir7Wm94KEHI4h" +export const tier1FeaturePriceSetSandbox: FeaturePriceSet = { + [FeatureId.TIER1]: "price_1SxgpPDCpkOb237Bfo4rIsoT" }; -export const standardFeaturePriceSetSandbox: FeaturePriceSet = { - // Free tier matches the freeLimitSet - [FeatureId.SITE_UPTIME]: "price_1RefFBDCpkOb237BPrKZ8IEU", - [FeatureId.USERS]: "price_1ReNa4DCpkOb237Bc67G5muF", - [FeatureId.EGRESS_DATA_MB]: "price_1Rfp9LDCpkOb237BwuN5Oiu0", - // [FeatureId.DOMAINS]: "price_1Ryi88DCpkOb237B2D6DM80b", - [FeatureId.REMOTE_EXIT_NODES]: "price_1RyiZvDCpkOb237BXpmoIYJL" -}; - -export function getStandardFeaturePriceSet(): FeaturePriceSet { +export function getTier1FeaturePriceSet(): FeaturePriceSet { if ( process.env.ENVIRONMENT == "prod" && process.env.SANDBOX_MODE !== "true" ) { - return standardFeaturePriceSet; + return tier1FeaturePriceSet; } else { - return standardFeaturePriceSetSandbox; + return tier1FeaturePriceSetSandbox; } } -export function getLineItems( - featurePriceSet: FeaturePriceSet -): Stripe.Checkout.SessionCreateParams.LineItem[] { - return Object.entries(featurePriceSet).map(([featureId, priceId]) => ({ - price: priceId - })); +export const tier2FeaturePriceSet: FeaturePriceSet = { + [FeatureId.USERS]: "price_1SzVCcD3Ee2Ir7Wmn6U3KvPN" +}; + +export const tier2FeaturePriceSetSandbox: FeaturePriceSet = { + [FeatureId.USERS]: "price_1SxaEHDCpkOb237BD9lBkPiR" +}; + +export function getTier2FeaturePriceSet(): FeaturePriceSet { + if ( + process.env.ENVIRONMENT == "prod" && + process.env.SANDBOX_MODE !== "true" + ) { + return tier2FeaturePriceSet; + } else { + return tier2FeaturePriceSetSandbox; + } +} + +export const tier3FeaturePriceSet: FeaturePriceSet = { + [FeatureId.USERS]: "price_1SzVDKD3Ee2Ir7WmPtOKNusv" +}; + +export const tier3FeaturePriceSetSandbox: FeaturePriceSet = { + [FeatureId.USERS]: "price_1SxaEODCpkOb237BiXdCBSfs" +}; + +export function getTier3FeaturePriceSet(): FeaturePriceSet { + if ( + process.env.ENVIRONMENT == "prod" && + process.env.SANDBOX_MODE !== "true" + ) { + return tier3FeaturePriceSet; + } else { + return tier3FeaturePriceSetSandbox; + } +} + +export function getFeatureIdByPriceId(priceId: string): FeatureId | undefined { + // Check all feature price sets + const allPriceSets = [ + getTier1FeaturePriceSet(), + getTier2FeaturePriceSet(), + getTier3FeaturePriceSet() + ]; + + for (const priceSet of allPriceSets) { + const entry = (Object.entries(priceSet) as [FeatureId, string][]).find( + ([_, price]) => price === priceId + ); + if (entry) { + return entry[0]; + } + } + + return undefined; } diff --git a/server/lib/billing/getLineItems.ts b/server/lib/billing/getLineItems.ts new file mode 100644 index 000000000..d386e5e96 --- /dev/null +++ b/server/lib/billing/getLineItems.ts @@ -0,0 +1,25 @@ +import Stripe from "stripe"; +import { FeatureId, FeaturePriceSet } from "./features"; +import { usageService } from "./usageService"; + +export async function getLineItems( + featurePriceSet: FeaturePriceSet, + orgId: string, +): Promise { + const users = await usageService.getUsage(orgId, FeatureId.USERS); + + return Object.entries(featurePriceSet).map(([featureId, priceId]) => { + let quantity: number | undefined; + + if (featureId === FeatureId.USERS) { + quantity = users?.instantaneousValue || 1; + } else if (featureId === FeatureId.TIER1) { + quantity = 1; + } + + return { + price: priceId, + quantity: quantity + }; + }); +} diff --git a/server/lib/billing/licenses.ts b/server/lib/billing/licenses.ts new file mode 100644 index 000000000..3fecb32b5 --- /dev/null +++ b/server/lib/billing/licenses.ts @@ -0,0 +1,37 @@ +export enum LicenseId { + SMALL_LICENSE = "small_license", + BIG_LICENSE = "big_license" +} + +export type LicensePriceSet = { + [key in LicenseId]: string; +}; + +export const licensePriceSet: LicensePriceSet = { + // Free license matches the freeLimitSet + [LicenseId.SMALL_LICENSE]: "price_1SxKHiD3Ee2Ir7WmvtEh17A8", + [LicenseId.BIG_LICENSE]: "price_1SxKHiD3Ee2Ir7WmMUiP0H6Y" +}; + +export const licensePriceSetSandbox: LicensePriceSet = { + // Free license matches the freeLimitSet + // when matching license the keys closer to 0 index are matched first so list the licenses in descending order of value + [LicenseId.SMALL_LICENSE]: "price_1SxDwuDCpkOb237Bz0yTiOgN", + [LicenseId.BIG_LICENSE]: "price_1SxDy0DCpkOb237BWJxrxYkl" +}; + +export function getLicensePriceSet( + environment?: string, + sandbox_mode?: boolean +): LicensePriceSet { + if ( + (process.env.ENVIRONMENT == "prod" && + process.env.SANDBOX_MODE !== "true") || + (environment === "prod" && sandbox_mode !== true) + ) { + // THIS GETS LOADED CLIENT SIDE AND SERVER SIDE + return licensePriceSet; + } else { + return licensePriceSetSandbox; + } +} diff --git a/server/lib/billing/limitSet.ts b/server/lib/billing/limitSet.ts index 820b121ac..ae9a18ffe 100644 --- a/server/lib/billing/limitSet.ts +++ b/server/lib/billing/limitSet.ts @@ -1,50 +1,70 @@ import { FeatureId } from "./features"; -export type LimitSet = { +export type LimitSet = Partial<{ [key in FeatureId]: { value: number | null; // null indicates no limit description?: string; }; -}; - -export const sandboxLimitSet: LimitSet = { - [FeatureId.SITE_UPTIME]: { value: 2880, description: "Sandbox limit" }, // 1 site up for 2 days - [FeatureId.USERS]: { value: 1, description: "Sandbox limit" }, - [FeatureId.EGRESS_DATA_MB]: { value: 1000, description: "Sandbox limit" }, // 1 GB - [FeatureId.DOMAINS]: { value: 0, description: "Sandbox limit" }, - [FeatureId.REMOTE_EXIT_NODES]: { value: 0, description: "Sandbox limit" } -}; +}>; export const freeLimitSet: LimitSet = { - [FeatureId.SITE_UPTIME]: { value: 46080, description: "Free tier limit" }, // 1 site up for 32 days - [FeatureId.USERS]: { value: 3, description: "Free tier limit" }, - [FeatureId.EGRESS_DATA_MB]: { - value: 25000, - description: "Free tier limit" - }, // 25 GB - [FeatureId.DOMAINS]: { value: 3, description: "Free tier limit" }, - [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Free tier limit" } + [FeatureId.SITES]: { value: 5, description: "Basic limit" }, + [FeatureId.USERS]: { value: 5, description: "Basic limit" }, + [FeatureId.DOMAINS]: { value: 5, description: "Basic limit" }, + [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Basic limit" }, + [FeatureId.ORGINIZATIONS]: { value: 1, description: "Basic limit" }, }; -export const subscribedLimitSet: LimitSet = { - [FeatureId.SITE_UPTIME]: { - value: 2232000, - description: "Contact us to increase soft limit." - }, // 50 sites up for 31 days +export const tier1LimitSet: LimitSet = { + [FeatureId.USERS]: { value: 7, description: "Home limit" }, + [FeatureId.SITES]: { value: 10, description: "Home limit" }, + [FeatureId.DOMAINS]: { value: 10, description: "Home limit" }, + [FeatureId.REMOTE_EXIT_NODES]: { value: 1, description: "Home limit" }, + [FeatureId.ORGINIZATIONS]: { value: 1, description: "Home limit" }, +}; + +export const tier2LimitSet: LimitSet = { [FeatureId.USERS]: { - value: 150, - description: "Contact us to increase soft limit." + value: 100, + description: "Team limit" + }, + [FeatureId.SITES]: { + value: 50, + description: "Team limit" }, - [FeatureId.EGRESS_DATA_MB]: { - value: 12000000, - description: "Contact us to increase soft limit." - }, // 12000 GB [FeatureId.DOMAINS]: { - value: 25, - description: "Contact us to increase soft limit." + value: 50, + description: "Team limit" }, [FeatureId.REMOTE_EXIT_NODES]: { - value: 5, - description: "Contact us to increase soft limit." + value: 3, + description: "Team limit" + }, + [FeatureId.ORGINIZATIONS]: { + value: 1, + description: "Team limit" } }; + +export const tier3LimitSet: LimitSet = { + [FeatureId.USERS]: { + value: 500, + description: "Business limit" + }, + [FeatureId.SITES]: { + value: 250, + description: "Business limit" + }, + [FeatureId.DOMAINS]: { + value: 100, + description: "Business limit" + }, + [FeatureId.REMOTE_EXIT_NODES]: { + value: 20, + description: "Business limit" + }, + [FeatureId.ORGINIZATIONS]: { + value: 5, + description: "Business limit" + }, +}; diff --git a/server/lib/billing/limitsService.ts b/server/lib/billing/limitsService.ts index a07f70b32..f364d6e00 100644 --- a/server/lib/billing/limitsService.ts +++ b/server/lib/billing/limitsService.ts @@ -2,6 +2,7 @@ import { db, limits } from "@server/db"; import { and, eq } from "drizzle-orm"; import { LimitSet } from "./limitSet"; import { FeatureId } from "./features"; +import logger from "@server/logger"; class LimitService { async applyLimitSetToOrg(orgId: string, limitSet: LimitSet): Promise { @@ -13,6 +14,21 @@ class LimitService { for (const [featureId, entry] of limitEntries) { const limitId = `${orgId}-${featureId}`; const { value, description } = entry; + // get the limit first + const [limit] = await trx + .select() + .from(limits) + .where(eq(limits.limitId, limitId)) + .limit(1); + + // check if its overriden + if (limit && limit.override) { + logger.debug( + `Skipping limit ${limitId} for org ${orgId} since it is overridden...` + ); + continue; + } + await trx .insert(limits) .values({ limitId, orgId, featureId, value, description }); diff --git a/server/lib/billing/tierMatrix.ts b/server/lib/billing/tierMatrix.ts new file mode 100644 index 000000000..2aa38e1ef --- /dev/null +++ b/server/lib/billing/tierMatrix.ts @@ -0,0 +1,58 @@ +import { Tier } from "@server/types/Tiers"; + +export enum TierFeature { + OrgOidc = "orgOidc", + LoginPageDomain = "loginPageDomain", // handle downgrade by removing custom domain + DeviceApprovals = "deviceApprovals", // handle downgrade by disabling device approvals + LoginPageBranding = "loginPageBranding", // handle downgrade by setting to default branding + LogExport = "logExport", + AccessLogs = "accessLogs", // set the retention period to none on downgrade + ActionLogs = "actionLogs", // set the retention period to none on downgrade + ConnectionLogs = "connectionLogs", + RotateCredentials = "rotateCredentials", + MaintencePage = "maintencePage", // handle downgrade + DevicePosture = "devicePosture", + TwoFactorEnforcement = "twoFactorEnforcement", // handle downgrade by setting to optional + SessionDurationPolicies = "sessionDurationPolicies", // handle downgrade by setting to default duration + PasswordExpirationPolicies = "passwordExpirationPolicies", // handle downgrade by setting to default duration + AutoProvisioning = "autoProvisioning", // handle downgrade by disabling auto provisioning + SshPam = "sshPam", + FullRbac = "fullRbac", + SiteProvisioningKeys = "siteProvisioningKeys" // handle downgrade by revoking keys if needed +} + +export const tierMatrix: Record = { + [TierFeature.OrgOidc]: ["tier1", "tier2", "tier3", "enterprise"], + [TierFeature.LoginPageDomain]: ["tier1", "tier2", "tier3", "enterprise"], + [TierFeature.DeviceApprovals]: ["tier1", "tier3", "enterprise"], + [TierFeature.LoginPageBranding]: ["tier1", "tier3", "enterprise"], + [TierFeature.LogExport]: ["tier3", "enterprise"], + [TierFeature.AccessLogs]: ["tier2", "tier3", "enterprise"], + [TierFeature.ActionLogs]: ["tier2", "tier3", "enterprise"], + [TierFeature.ConnectionLogs]: ["tier2", "tier3", "enterprise"], + [TierFeature.RotateCredentials]: ["tier1", "tier2", "tier3", "enterprise"], + [TierFeature.MaintencePage]: ["tier1", "tier2", "tier3", "enterprise"], + [TierFeature.DevicePosture]: ["tier2", "tier3", "enterprise"], + [TierFeature.TwoFactorEnforcement]: [ + "tier1", + "tier2", + "tier3", + "enterprise" + ], + [TierFeature.SessionDurationPolicies]: [ + "tier1", + "tier2", + "tier3", + "enterprise" + ], + [TierFeature.PasswordExpirationPolicies]: [ + "tier1", + "tier2", + "tier3", + "enterprise" + ], + [TierFeature.AutoProvisioning]: ["tier1", "tier3", "enterprise"], + [TierFeature.SshPam]: ["tier1", "tier3", "enterprise"], + [TierFeature.FullRbac]: ["tier1", "tier2", "tier3", "enterprise"], + [TierFeature.SiteProvisioningKeys]: ["enterprise"] +}; diff --git a/server/lib/billing/tiers.ts b/server/lib/billing/tiers.ts deleted file mode 100644 index ae49a48f2..000000000 --- a/server/lib/billing/tiers.ts +++ /dev/null @@ -1,34 +0,0 @@ -export enum TierId { - STANDARD = "standard" -} - -export type TierPriceSet = { - [key in TierId]: string; -}; - -export const tierPriceSet: TierPriceSet = { - // Free tier matches the freeLimitSet - [TierId.STANDARD]: "price_1RrQ9cD3Ee2Ir7Wmqdy3KBa0" -}; - -export const tierPriceSetSandbox: TierPriceSet = { - // Free tier matches the freeLimitSet - // when matching tier the keys closer to 0 index are matched first so list the tiers in descending order of value - [TierId.STANDARD]: "price_1RrAYJDCpkOb237By2s1P32m" -}; - -export function getTierPriceSet( - environment?: string, - sandbox_mode?: boolean -): TierPriceSet { - if ( - (process.env.ENVIRONMENT == "prod" && - process.env.SANDBOX_MODE !== "true") || - (environment === "prod" && sandbox_mode !== true) - ) { - // THIS GETS LOADED CLIENT SIDE AND SERVER SIDE - return tierPriceSet; - } else { - return tierPriceSetSandbox; - } -} diff --git a/server/lib/billing/usageService.ts b/server/lib/billing/usageService.ts index 0fde8eba9..9cb24bbeb 100644 --- a/server/lib/billing/usageService.ts +++ b/server/lib/billing/usageService.ts @@ -1,74 +1,32 @@ import { eq, sql, and } from "drizzle-orm"; -import { v4 as uuidv4 } from "uuid"; -import { PutObjectCommand } from "@aws-sdk/client-s3"; -import * as fs from "fs/promises"; -import * as path from "path"; import { db, usage, customers, - sites, - newts, limits, Usage, Limit, - Transaction + Transaction, + orgs } from "@server/db"; import { FeatureId, getFeatureMeterId } from "./features"; import logger from "@server/logger"; -import { sendToClient } from "#dynamic/routers/ws"; import { build } from "@server/build"; -import { s3Client } from "@server/lib/s3"; -import cache from "@server/lib/cache"; - -interface StripeEvent { - identifier?: string; - timestamp: number; - event_name: string; - payload: { - value: number; - stripe_customer_id: string; - }; -} +import cache from "#dynamic/lib/cache"; export function noop() { - if ( - build !== "saas" || - !process.env.S3_BUCKET || - !process.env.LOCAL_FILE_PATH - ) { + if (build !== "saas") { return true; } return false; } export class UsageService { - private bucketName: string | undefined; - private currentEventFile: string | null = null; - private currentFileStartTime: number = 0; - private eventsDir: string | undefined; - private uploadingFiles: Set = new Set(); constructor() { if (noop()) { return; } - // this.bucketName = privateConfig.getRawPrivateConfig().stripe?.s3Bucket; - // this.eventsDir = privateConfig.getRawPrivateConfig().stripe?.localFilePath; - this.bucketName = process.env.S3_BUCKET || undefined; - this.eventsDir = process.env.LOCAL_FILE_PATH || undefined; - - // Ensure events directory exists - this.initializeEventsDirectory().then(() => { - this.uploadPendingEventFilesOnStartup(); - }); - - // Periodically check for old event files to upload - setInterval(() => { - this.uploadOldEventFiles().catch((err) => { - logger.error("Error in periodic event file upload:", err); - }); - }, 30000); // every 30 seconds } /** @@ -78,85 +36,6 @@ export class UsageService { return Math.round(value * 100000000000) / 100000000000; // 11 decimal places } - private async initializeEventsDirectory(): Promise { - if (!this.eventsDir) { - logger.warn( - "Stripe local file path is not configured, skipping events directory initialization." - ); - return; - } - try { - await fs.mkdir(this.eventsDir, { recursive: true }); - } catch (error) { - logger.error("Failed to create events directory:", error); - } - } - - private async uploadPendingEventFilesOnStartup(): Promise { - if (!this.eventsDir || !this.bucketName) { - logger.warn( - "Stripe local file path or bucket name is not configured, skipping leftover event file upload." - ); - return; - } - try { - const files = await fs.readdir(this.eventsDir); - for (const file of files) { - if (file.endsWith(".json")) { - const filePath = path.join(this.eventsDir, file); - try { - const fileContent = await fs.readFile( - filePath, - "utf-8" - ); - const events = JSON.parse(fileContent); - if (Array.isArray(events) && events.length > 0) { - // Upload to S3 - const uploadCommand = new PutObjectCommand({ - Bucket: this.bucketName, - Key: file, - Body: fileContent, - ContentType: "application/json" - }); - await s3Client.send(uploadCommand); - - // Check if file still exists before unlinking - try { - await fs.access(filePath); - await fs.unlink(filePath); - } catch (unlinkError) { - logger.debug( - `Startup file ${file} was already deleted` - ); - } - - logger.info( - `Uploaded leftover event file ${file} to S3 with ${events.length} events` - ); - } else { - // Remove empty file - try { - await fs.access(filePath); - await fs.unlink(filePath); - } catch (unlinkError) { - logger.debug( - `Empty startup file ${file} was already deleted` - ); - } - } - } catch (err) { - logger.error( - `Error processing leftover event file ${file}:`, - err - ); - } - } - } - } catch (error) { - logger.error("Failed to scan for leftover event files"); - } - } - public async add( orgId: string, featureId: FeatureId, @@ -176,28 +55,20 @@ export class UsageService { while (attempt <= maxRetries) { try { - // Get subscription data for this org (with caching) - const customerId = await this.getCustomerId(orgId, featureId); - - if (!customerId) { - logger.warn( - `No subscription data found for org ${orgId} and feature ${featureId}` - ); - return null; - } - let usage; if (transaction) { + const orgIdToUse = await this.getBillingOrg(orgId, transaction); usage = await this.internalAddUsage( - orgId, + orgIdToUse, featureId, value, transaction ); } else { await db.transaction(async (trx) => { + const orgIdToUse = await this.getBillingOrg(orgId, trx); usage = await this.internalAddUsage( - orgId, + orgIdToUse, featureId, value, trx @@ -205,9 +76,6 @@ export class UsageService { }); } - // Log event for Stripe - await this.logStripeEvent(featureId, value, customerId); - return usage || null; } catch (error: any) { // Check if this is a deadlock error @@ -243,7 +111,7 @@ export class UsageService { } private async internalAddUsage( - orgId: string, + orgId: string, // here the orgId is the billing org already resolved by getBillingOrg in updateCount featureId: FeatureId, value: number, trx: Transaction @@ -262,17 +130,22 @@ export class UsageService { featureId, orgId, meterId, - latestValue: value, + instantaneousValue: value || 0, + latestValue: value || 0, updatedAt: Math.floor(Date.now() / 1000) }) .onConflictDoUpdate({ target: usage.usageId, set: { - latestValue: sql`${usage.latestValue} + ${value}` + instantaneousValue: sql`COALESCE(${usage.instantaneousValue}, 0) + ${value}` } }) .returning(); + logger.debug( + `Added usage for org ${orgId} feature ${featureId}: +${value}, new instantaneousValue: ${returnUsage.instantaneousValue}` + ); + return returnUsage; } @@ -286,7 +159,7 @@ export class UsageService { return new Date(date * 1000).toISOString().split("T")[0]; } - async updateDaily( + async updateCount( orgId: string, featureId: FeatureId, value?: number, @@ -295,30 +168,20 @@ export class UsageService { if (noop()) { return; } - try { - if (!customerId) { - customerId = - (await this.getCustomerId(orgId, featureId)) || undefined; - if (!customerId) { - logger.warn( - `No subscription data found for org ${orgId} and feature ${featureId}` - ); - return; - } - } + const orgIdToUse = await this.getBillingOrg(orgId); + + try { // Truncate value to 11 decimal places if provided if (value !== undefined && value !== null) { value = this.truncateValue(value); } - const today = this.getTodayDateString(); - let currentUsage: Usage | null = null; await db.transaction(async (trx) => { // Get existing meter record - const usageId = `${orgId}-${featureId}`; + const usageId = `${orgIdToUse}-${featureId}`; // Get current usage record [currentUsage] = await trx .select() @@ -327,66 +190,34 @@ export class UsageService { .limit(1); if (currentUsage) { - const lastUpdateDate = this.getDateString( - currentUsage.updatedAt - ); - const currentRunningTotal = currentUsage.latestValue; - const lastDailyValue = currentUsage.instantaneousValue || 0; - - if (value == undefined || value === null) { - value = currentUsage.instantaneousValue || 0; - } - - if (lastUpdateDate === today) { - // Same day update: replace the daily value - // Remove old daily value from running total, add new value - const newRunningTotal = this.truncateValue( - currentRunningTotal - lastDailyValue + value - ); - - await trx - .update(usage) - .set({ - latestValue: newRunningTotal, - instantaneousValue: value, - updatedAt: Math.floor(Date.now() / 1000) - }) - .where(eq(usage.usageId, usageId)); - } else { - // New day: add to running total - const newRunningTotal = this.truncateValue( - currentRunningTotal + value - ); - - await trx - .update(usage) - .set({ - latestValue: newRunningTotal, - instantaneousValue: value, - updatedAt: Math.floor(Date.now() / 1000) - }) - .where(eq(usage.usageId, usageId)); - } + await trx + .update(usage) + .set({ + instantaneousValue: value, + updatedAt: Math.floor(Date.now() / 1000) + }) + .where(eq(usage.usageId, usageId)); } else { // First record for this meter const meterId = getFeatureMeterId(featureId); - const truncatedValue = this.truncateValue(value || 0); await trx.insert(usage).values({ usageId, featureId, - orgId, + orgId: orgIdToUse, meterId, - instantaneousValue: truncatedValue, - latestValue: truncatedValue, + instantaneousValue: value || 0, + latestValue: value || 0, updatedAt: Math.floor(Date.now() / 1000) }); } }); - await this.logStripeEvent(featureId, value || 0, customerId); + // if (privateConfig.getRawPrivateConfig().flags.usage_reporting) { + // await this.logStripeEvent(featureId, value || 0, customerId); + // } } catch (error) { logger.error( - `Failed to update daily usage for ${orgId}/${featureId}:`, + `Failed to update count usage for ${orgIdToUse}/${featureId}:`, error ); } @@ -396,8 +227,10 @@ export class UsageService { orgId: string, featureId: FeatureId ): Promise { - const cacheKey = `customer_${orgId}_${featureId}`; - const cached = cache.get(cacheKey); + const orgIdToUse = await this.getBillingOrg(orgId); + + const cacheKey = `customer_${orgIdToUse}_${featureId}`; + const cached = await cache.get(cacheKey); if (cached) { return cached; @@ -410,7 +243,7 @@ export class UsageService { customerId: customers.customerId }) .from(customers) - .where(eq(customers.orgId, orgId)) + .where(eq(customers.orgId, orgIdToUse)) .limit(1); if (!customer) { @@ -420,194 +253,18 @@ export class UsageService { const customerId = customer.customerId; // Cache the result - cache.set(cacheKey, customerId, 300); // 5 minute TTL + await cache.set(cacheKey, customerId, 300); // 5 minute TTL return customerId; } catch (error) { logger.error( - `Failed to get subscription data for ${orgId}/${featureId}:`, + `Failed to get subscription data for ${orgIdToUse}/${featureId}:`, error ); return null; } } - private async logStripeEvent( - featureId: FeatureId, - value: number, - customerId: string - ): Promise { - // Truncate value to 11 decimal places before sending to Stripe - const truncatedValue = this.truncateValue(value); - - const event: StripeEvent = { - identifier: uuidv4(), - timestamp: Math.floor(new Date().getTime() / 1000), - event_name: featureId, - payload: { - value: truncatedValue, - stripe_customer_id: customerId - } - }; - - await this.writeEventToFile(event); - await this.checkAndUploadFile(); - } - - private async writeEventToFile(event: StripeEvent): Promise { - if (!this.eventsDir || !this.bucketName) { - logger.warn( - "Stripe local file path or bucket name is not configured, skipping event file write." - ); - return; - } - if (!this.currentEventFile) { - this.currentEventFile = this.generateEventFileName(); - this.currentFileStartTime = Date.now(); - } - - const filePath = path.join(this.eventsDir, this.currentEventFile); - - try { - let events: StripeEvent[] = []; - - // Try to read existing file - try { - const fileContent = await fs.readFile(filePath, "utf-8"); - events = JSON.parse(fileContent); - } catch (error) { - // File doesn't exist or is empty, start with empty array - events = []; - } - - // Add new event - events.push(event); - - // Write back to file - await fs.writeFile(filePath, JSON.stringify(events, null, 2)); - } catch (error) { - logger.error("Failed to write event to file:", error); - } - } - - private async checkAndUploadFile(): Promise { - if (!this.currentEventFile) { - return; - } - - const now = Date.now(); - const fileAge = now - this.currentFileStartTime; - - // Check if file is at least 1 minute old - if (fileAge >= 60000) { - // 60 seconds - await this.uploadFileToS3(); - } - } - - private async uploadFileToS3(): Promise { - if (!this.bucketName || !this.eventsDir) { - logger.warn( - "Stripe local file path or bucket name is not configured, skipping S3 upload." - ); - return; - } - if (!this.currentEventFile) { - return; - } - - const fileName = this.currentEventFile; - const filePath = path.join(this.eventsDir, fileName); - - // Check if this file is already being uploaded - if (this.uploadingFiles.has(fileName)) { - logger.debug( - `File ${fileName} is already being uploaded, skipping` - ); - return; - } - - // Mark file as being uploaded - this.uploadingFiles.add(fileName); - - try { - // Check if file exists before trying to read it - try { - await fs.access(filePath); - } catch (error) { - logger.debug( - `File ${fileName} does not exist, may have been already processed` - ); - this.uploadingFiles.delete(fileName); - // Reset current file if it was this file - if (this.currentEventFile === fileName) { - this.currentEventFile = null; - this.currentFileStartTime = 0; - } - return; - } - - // Check if file exists and has content - const fileContent = await fs.readFile(filePath, "utf-8"); - const events = JSON.parse(fileContent); - - if (events.length === 0) { - // No events to upload, just clean up - try { - await fs.unlink(filePath); - } catch (unlinkError) { - // File may have been already deleted - logger.debug( - `File ${fileName} was already deleted during cleanup` - ); - } - this.currentEventFile = null; - this.uploadingFiles.delete(fileName); - return; - } - - // Upload to S3 - const uploadCommand = new PutObjectCommand({ - Bucket: this.bucketName, - Key: fileName, - Body: fileContent, - ContentType: "application/json" - }); - - await s3Client.send(uploadCommand); - - // Clean up local file - check if it still exists before unlinking - try { - await fs.access(filePath); - await fs.unlink(filePath); - } catch (unlinkError) { - // File may have been already deleted by another process - logger.debug( - `File ${fileName} was already deleted during upload` - ); - } - - logger.info( - `Uploaded ${fileName} to S3 with ${events.length} events` - ); - - // Reset for next file - this.currentEventFile = null; - this.currentFileStartTime = 0; - } catch (error) { - logger.error(`Failed to upload ${fileName} to S3:`, error); - } finally { - // Always remove from uploading set - this.uploadingFiles.delete(fileName); - } - } - - private generateEventFileName(): string { - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const uuid = uuidv4().substring(0, 8); - return `events-${timestamp}-${uuid}.json`; - } - public async getUsage( orgId: string, featureId: FeatureId, @@ -617,7 +274,9 @@ export class UsageService { return null; } - const usageId = `${orgId}-${featureId}`; + const orgIdToUse = await this.getBillingOrg(orgId, trx); + + const usageId = `${orgIdToUse}-${featureId}`; try { const [result] = await trx @@ -629,7 +288,7 @@ export class UsageService { if (!result) { // Lets create one if it doesn't exist using upsert to handle race conditions logger.info( - `Creating new usage record for ${orgId}/${featureId}` + `Creating new usage record for ${orgIdToUse}/${featureId}` ); const meterId = getFeatureMeterId(featureId); @@ -639,7 +298,7 @@ export class UsageService { .values({ usageId, featureId, - orgId, + orgId: orgIdToUse, meterId, latestValue: 0, updatedAt: Math.floor(Date.now() / 1000) @@ -661,7 +320,7 @@ export class UsageService { } catch (insertError) { // Fallback: try to fetch existing record in case of any insert issues logger.warn( - `Insert failed for ${orgId}/${featureId}, attempting to fetch existing record:`, + `Insert failed for ${orgIdToUse}/${featureId}, attempting to fetch existing record:`, insertError ); const [existingUsage] = await trx @@ -676,136 +335,45 @@ export class UsageService { return result; } catch (error) { logger.error( - `Failed to get usage for ${orgId}/${featureId}:`, + `Failed to get usage for ${orgIdToUse}/${featureId}:`, error ); throw error; } } - public async getUsageDaily( + public async getBillingOrg( orgId: string, - featureId: FeatureId - ): Promise { - if (noop()) { - return null; + trx: Transaction | typeof db = db + ): Promise { + let orgIdToUse = orgId; + + // get the org + const [org] = await trx + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + throw new Error(`Organization with ID ${orgId} not found`); } - await this.updateDaily(orgId, featureId); // Ensure daily usage is updated - return this.getUsage(orgId, featureId); - } - public async forceUpload(): Promise { - await this.uploadFileToS3(); - } - - /** - * Scan the events directory for files older than 1 minute and upload them if not empty. - */ - private async uploadOldEventFiles(): Promise { - if (!this.eventsDir || !this.bucketName) { - logger.warn( - "Stripe local file path or bucket name is not configured, skipping old event file upload." - ); - return; - } - try { - const files = await fs.readdir(this.eventsDir); - const now = Date.now(); - for (const file of files) { - if (!file.endsWith(".json")) continue; - - // Skip files that are already being uploaded - if (this.uploadingFiles.has(file)) { - logger.debug( - `Skipping file ${file} as it's already being uploaded` - ); - continue; - } - - const filePath = path.join(this.eventsDir, file); - - try { - // Check if file still exists before processing - try { - await fs.access(filePath); - } catch (accessError) { - logger.debug(`File ${file} does not exist, skipping`); - continue; - } - - const stat = await fs.stat(filePath); - const age = now - stat.mtimeMs; - if (age >= 90000) { - // 1.5 minutes - Mark as being uploaded - this.uploadingFiles.add(file); - - try { - const fileContent = await fs.readFile( - filePath, - "utf-8" - ); - const events = JSON.parse(fileContent); - if (Array.isArray(events) && events.length > 0) { - // Upload to S3 - const uploadCommand = new PutObjectCommand({ - Bucket: this.bucketName, - Key: file, - Body: fileContent, - ContentType: "application/json" - }); - await s3Client.send(uploadCommand); - - // Check if file still exists before unlinking - try { - await fs.access(filePath); - await fs.unlink(filePath); - } catch (unlinkError) { - logger.debug( - `File ${file} was already deleted during interval upload` - ); - } - - logger.info( - `Interval: Uploaded event file ${file} to S3 with ${events.length} events` - ); - // If this was the current event file, reset it - if (this.currentEventFile === file) { - this.currentEventFile = null; - this.currentFileStartTime = 0; - } - } else { - // Remove empty file - try { - await fs.access(filePath); - await fs.unlink(filePath); - } catch (unlinkError) { - logger.debug( - `Empty file ${file} was already deleted` - ); - } - } - } finally { - // Always remove from uploading set - this.uploadingFiles.delete(file); - } - } - } catch (err) { - logger.error( - `Interval: Error processing event file ${file}:`, - err - ); - // Remove from uploading set on error - this.uploadingFiles.delete(file); - } + if (!org.isBillingOrg) { + if (org.billingOrgId) { + orgIdToUse = org.billingOrgId; + } else { + throw new Error( + `Organization ${orgId} is not a billing org and does not have a billingOrgId set` + ); } - } catch (err) { - logger.error("Interval: Failed to scan for event files:", err); } + + return orgIdToUse; } public async checkLimitSet( orgId: string, - kickSites = false, featureId?: FeatureId, usage?: Usage, trx: Transaction | typeof db = db @@ -813,6 +381,9 @@ export class UsageService { if (noop()) { return false; } + + const orgIdToUse = await this.getBillingOrg(orgId, trx); + // This method should check the current usage against the limits set for the organization // and kick out all of the sites on the org let hasExceededLimits = false; @@ -826,7 +397,7 @@ export class UsageService { .from(limits) .where( and( - eq(limits.orgId, orgId), + eq(limits.orgId, orgIdToUse), eq(limits.featureId, featureId) ) ); @@ -835,11 +406,11 @@ export class UsageService { orgLimits = await trx .select() .from(limits) - .where(eq(limits.orgId, orgId)); + .where(eq(limits.orgId, orgIdToUse)); } if (orgLimits.length === 0) { - logger.debug(`No limits set for org ${orgId}`); + logger.debug(`No limits set for org ${orgIdToUse}`); return false; } @@ -850,7 +421,7 @@ export class UsageService { currentUsage = usage; } else { currentUsage = await this.getUsage( - orgId, + orgIdToUse, limit.featureId as FeatureId, trx ); @@ -861,10 +432,10 @@ export class UsageService { currentUsage?.latestValue || 0; logger.debug( - `Current usage for org ${orgId} on feature ${limit.featureId}: ${usageValue}` + `Current usage for org ${orgIdToUse} on feature ${limit.featureId}: ${usageValue}` ); logger.debug( - `Limit for org ${orgId} on feature ${limit.featureId}: ${limit.value}` + `Limit for org ${orgIdToUse} on feature ${limit.featureId}: ${limit.value}` ); if ( currentUsage && @@ -872,67 +443,15 @@ export class UsageService { usageValue > limit.value ) { logger.debug( - `Org ${orgId} has exceeded limit for ${limit.featureId}: ` + + `Org ${orgIdToUse} has exceeded limit for ${limit.featureId}: ` + `${usageValue} > ${limit.value}` ); hasExceededLimits = true; break; // Exit early if any limit is exceeded } } - - // If any limits are exceeded, disconnect all sites for this organization - if (hasExceededLimits && kickSites) { - logger.warn( - `Disconnecting all sites for org ${orgId} due to exceeded limits` - ); - - // Get all sites for this organization - const orgSites = await trx - .select() - .from(sites) - .where(eq(sites.orgId, orgId)); - - // Mark all sites as offline and send termination messages - const siteUpdates = orgSites.map((site) => site.siteId); - - if (siteUpdates.length > 0) { - // Send termination messages to newt sites - for (const site of orgSites) { - if (site.type === "newt") { - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, site.siteId)) - .limit(1); - - if (newt) { - const payload = { - type: `newt/wg/terminate`, - data: { - reason: "Usage limits exceeded" - } - }; - - // Don't await to prevent blocking - await sendToClient(newt.newtId, payload).catch( - (error: any) => { - logger.error( - `Failed to send termination message to newt ${newt.newtId}:`, - error - ); - } - ); - } - } - } - - logger.info( - `Disconnected ${orgSites.length} sites for org ${orgId} due to exceeded limits` - ); - } - } } catch (error) { - logger.error(`Error checking limits for org ${orgId}:`, error); + logger.error(`Error checking limits for org ${orgIdToUse}:`, error); } return hasExceededLimits; diff --git a/server/lib/blueprints/MaintenanceSchema.ts b/server/lib/blueprints/MaintenanceSchema.ts new file mode 100644 index 000000000..b1e233bd0 --- /dev/null +++ b/server/lib/blueprints/MaintenanceSchema.ts @@ -0,0 +1,3 @@ +import { z } from "zod"; + +export const MaintenanceSchema = z.object({}); diff --git a/server/lib/blueprints/applyBlueprint.ts b/server/lib/blueprints/applyBlueprint.ts index 6168f85dd..a304bb392 100644 --- a/server/lib/blueprints/applyBlueprint.ts +++ b/server/lib/blueprints/applyBlueprint.ts @@ -1,4 +1,14 @@ -import { db, newts, blueprints, Blueprint } from "@server/db"; +import { + db, + newts, + blueprints, + Blueprint, + Site, + siteResources, + roleSiteResources, + userSiteResources, + clientSiteResources +} from "@server/db"; import { Config, ConfigSchema } from "./types"; import { ProxyResourcesResults, updateProxyResources } from "./proxyResources"; import { fromError } from "zod-validation-error"; @@ -15,6 +25,7 @@ import { BlueprintSource } from "@server/routers/blueprints/types"; import { stringify as stringifyYaml } from "yaml"; import { faker } from "@faker-js/faker"; import { handleMessagingForUpdatedSiteResource } from "@server/routers/siteResource"; +import { rebuildClientAssociationsFromSiteResource } from "../rebuildClientAssociations"; type ApplyBlueprintArgs = { orgId: string; @@ -96,7 +107,7 @@ export async function applyBlueprint({ [target], matchingHealthcheck ? [matchingHealthcheck] : [], result.proxyResource.protocol, - result.proxyResource.proxyPort + site.newt.version ); } } @@ -108,38 +119,145 @@ export async function applyBlueprint({ // We need to update the targets on the newts from the successfully updated information for (const result of clientResourcesResults) { - const [site] = await trx - .select() - .from(sites) - .innerJoin(newts, eq(sites.siteId, newts.siteId)) - .where( - and( - eq(sites.siteId, result.newSiteResource.siteId), - eq(sites.orgId, orgId), - eq(sites.type, "newt"), - isNotNull(sites.pubKey) + if ( + result.oldSiteResource && + result.oldSiteResource.siteId != + result.newSiteResource.siteId + ) { + // query existing associations + const existingRoleIds = await trx + .select() + .from(roleSiteResources) + .where( + eq( + roleSiteResources.siteResourceId, + result.oldSiteResource.siteResourceId + ) ) - ) - .limit(1); + .then((rows) => rows.map((row) => row.roleId)); - if (!site) { - logger.debug( - `No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update` + const existingUserIds = await trx + .select() + .from(userSiteResources) + .where( + eq( + userSiteResources.siteResourceId, + result.oldSiteResource.siteResourceId + ) + ) + .then((rows) => rows.map((row) => row.userId)); + + const existingClientIds = await trx + .select() + .from(clientSiteResources) + .where( + eq( + clientSiteResources.siteResourceId, + result.oldSiteResource.siteResourceId + ) + ) + .then((rows) => rows.map((row) => row.clientId)); + + // delete the existing site resource + await trx + .delete(siteResources) + .where( + and( + eq( + siteResources.siteResourceId, + result.oldSiteResource.siteResourceId + ) + ) + ); + + await rebuildClientAssociationsFromSiteResource( + result.oldSiteResource, + trx + ); + + const [insertedSiteResource] = await trx + .insert(siteResources) + .values({ + ...result.newSiteResource + }) + .returning(); + + // wait some time to allow for messages to be handled + await new Promise((resolve) => setTimeout(resolve, 750)); + + //////////////////// update the associations //////////////////// + + if (existingRoleIds.length > 0) { + await trx.insert(roleSiteResources).values( + existingRoleIds.map((roleId) => ({ + roleId, + siteResourceId: + insertedSiteResource!.siteResourceId + })) + ); + } + + if (existingUserIds.length > 0) { + await trx.insert(userSiteResources).values( + existingUserIds.map((userId) => ({ + userId, + siteResourceId: + insertedSiteResource!.siteResourceId + })) + ); + } + + if (existingClientIds.length > 0) { + await trx.insert(clientSiteResources).values( + existingClientIds.map((clientId) => ({ + clientId, + siteResourceId: + insertedSiteResource!.siteResourceId + })) + ); + } + + await rebuildClientAssociationsFromSiteResource( + insertedSiteResource, + trx + ); + } else { + const [newSite] = await trx + .select() + .from(sites) + .innerJoin(newts, eq(sites.siteId, newts.siteId)) + .where( + and( + eq(sites.siteId, result.newSiteResource.siteId), + eq(sites.orgId, orgId), + eq(sites.type, "newt"), + isNotNull(sites.pubKey) + ) + ) + .limit(1); + + if (!newSite) { + logger.debug( + `No newt site found for client resource ${result.newSiteResource.siteResourceId}, skipping target update` + ); + continue; + } + + logger.debug( + `Updating client resource ${result.newSiteResource.siteResourceId} on site ${newSite.sites.siteId}` + ); + + await handleMessagingForUpdatedSiteResource( + result.oldSiteResource, + result.newSiteResource, + { + siteId: newSite.sites.siteId, + orgId: newSite.sites.orgId + }, + trx ); - continue; } - logger.debug( - `Updating client resource ${result.newSiteResource.siteResourceId} on site ${site.sites.siteId}` - ); - - await handleMessagingForUpdatedSiteResource( - result.oldSiteResource, - result.newSiteResource, - { siteId: site.sites.siteId, orgId: site.sites.orgId }, - trx - ); - // await addClientTargets( // site.newt.newtId, // result.resource.destination, diff --git a/server/lib/blueprints/applyNewtDockerBlueprint.ts b/server/lib/blueprints/applyNewtDockerBlueprint.ts index f27cc05bb..eb5fc7877 100644 --- a/server/lib/blueprints/applyNewtDockerBlueprint.ts +++ b/server/lib/blueprints/applyNewtDockerBlueprint.ts @@ -36,7 +36,9 @@ export async function applyNewtDockerBlueprint( if ( isEmptyObject(blueprint["proxy-resources"]) && - isEmptyObject(blueprint["client-resources"]) + isEmptyObject(blueprint["client-resources"]) && + isEmptyObject(blueprint["public-resources"]) && + isEmptyObject(blueprint["private-resources"]) ) { return; } diff --git a/server/lib/blueprints/clientResources.ts b/server/lib/blueprints/clientResources.ts index ab65336dd..80c691c63 100644 --- a/server/lib/blueprints/clientResources.ts +++ b/server/lib/blueprints/clientResources.ts @@ -11,9 +11,10 @@ import { userSiteResources } from "@server/db"; import { sites } from "@server/db"; -import { eq, and, ne, inArray } from "drizzle-orm"; +import { eq, and, ne, inArray, or } from "drizzle-orm"; import { Config } from "./types"; import logger from "@server/logger"; +import { getNextAvailableAliasAddress } from "../ip"; export type ClientResourcesResults = { newSiteResource: SiteResource; @@ -75,22 +76,20 @@ export async function updateClientResources( } if (existingResource) { - if (existingResource.siteId !== site.siteId) { - throw new Error( - `You can not change the site of an existing client resource (${resourceNiceId}). Please delete and recreate it instead.` - ); - } - // Update existing resource const [updatedResource] = await trx .update(siteResources) .set({ name: resourceData.name || resourceNiceId, + siteId: site.siteId, mode: resourceData.mode, destination: resourceData.destination, enabled: true, // hardcoded for now // enabled: resourceData.enabled ?? true, - alias: resourceData.alias || null + alias: resourceData.alias || null, + disableIcmp: resourceData["disable-icmp"], + tcpPortRangeString: resourceData["tcp-ports"], + udpPortRangeString: resourceData["udp-ports"] }) .where( eq( @@ -143,7 +142,10 @@ export async function updateClientResources( .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) .where( and( - inArray(users.username, resourceData.users), + or( + inArray(users.username, resourceData.users), + inArray(users.email, resourceData.users) + ), eq(userOrgs.orgId, orgId) ) ); @@ -205,6 +207,12 @@ export async function updateClientResources( oldSiteResource: existingResource }); } else { + let aliasAddress: string | null = null; + if (resourceData.mode == "host") { + // we can only have an alias on a host + aliasAddress = await getNextAvailableAliasAddress(orgId); + } + // Create new resource const [newResource] = await trx .insert(siteResources) @@ -217,7 +225,11 @@ export async function updateClientResources( destination: resourceData.destination, enabled: true, // hardcoded for now // enabled: resourceData.enabled ?? true, - alias: resourceData.alias || null + alias: resourceData.alias || null, + aliasAddress: aliasAddress, + disableIcmp: resourceData["disable-icmp"], + tcpPortRangeString: resourceData["tcp-ports"], + udpPortRangeString: resourceData["udp-ports"] }) .returning(); @@ -267,7 +279,10 @@ export async function updateClientResources( .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) .where( and( - inArray(users.username, resourceData.users), + or( + inArray(users.username, resourceData.users), + inArray(users.email, resourceData.users) + ), eq(userOrgs.orgId, orgId) ) ); diff --git a/server/lib/blueprints/parseDockerContainers.ts b/server/lib/blueprints/parseDockerContainers.ts index f2cdcfa22..5befcbc3c 100644 --- a/server/lib/blueprints/parseDockerContainers.ts +++ b/server/lib/blueprints/parseDockerContainers.ts @@ -54,10 +54,14 @@ function getContainerPort(container: Container): number | null { export function processContainerLabels(containers: Container[]): { "proxy-resources": { [key: string]: ResourceConfig }; "client-resources": { [key: string]: ResourceConfig }; + "public-resources": { [key: string]: ResourceConfig }; + "private-resources": { [key: string]: ResourceConfig }; } { const result = { "proxy-resources": {} as { [key: string]: ResourceConfig }, - "client-resources": {} as { [key: string]: ResourceConfig } + "client-resources": {} as { [key: string]: ResourceConfig }, + "public-resources": {} as { [key: string]: ResourceConfig }, + "private-resources": {} as { [key: string]: ResourceConfig } }; // Process each container @@ -68,8 +72,10 @@ export function processContainerLabels(containers: Container[]): { const proxyResourceLabels: DockerLabels = {}; const clientResourceLabels: DockerLabels = {}; + const publicResourceLabels: DockerLabels = {}; + const privateResourceLabels: DockerLabels = {}; - // Filter and separate proxy-resources and client-resources labels + // Filter and separate proxy-resources, client-resources, public-resources, and private-resources labels Object.entries(container.labels).forEach(([key, value]) => { if (key.startsWith("pangolin.proxy-resources.")) { // remove the pangolin.proxy- prefix to get "resources.xxx" @@ -79,6 +85,14 @@ export function processContainerLabels(containers: Container[]): { // remove the pangolin.client- prefix to get "resources.xxx" const strippedKey = key.replace("pangolin.client-", ""); clientResourceLabels[strippedKey] = value; + } else if (key.startsWith("pangolin.public-resources.")) { + // remove the pangolin.public- prefix to get "resources.xxx" + const strippedKey = key.replace("pangolin.public-", ""); + publicResourceLabels[strippedKey] = value; + } else if (key.startsWith("pangolin.private-resources.")) { + // remove the pangolin.private- prefix to get "resources.xxx" + const strippedKey = key.replace("pangolin.private-", ""); + privateResourceLabels[strippedKey] = value; } }); @@ -99,6 +113,24 @@ export function processContainerLabels(containers: Container[]): { result["client-resources"] ); } + + // Process public resources (alias for proxy resources) + if (Object.keys(publicResourceLabels).length > 0) { + processResourceLabels( + publicResourceLabels, + container, + result["public-resources"] + ); + } + + // Process private resources (alias for client resources) + if (Object.keys(privateResourceLabels).length > 0) { + processResourceLabels( + privateResourceLabels, + container, + result["private-resources"] + ); + } }); return result; diff --git a/server/lib/blueprints/proxyResources.ts b/server/lib/blueprints/proxyResources.ts index 706fab122..2696b68c8 100644 --- a/server/lib/blueprints/proxyResources.ts +++ b/server/lib/blueprints/proxyResources.ts @@ -3,6 +3,7 @@ import { orgDomains, Resource, resourceHeaderAuth, + resourceHeaderAuthExtendedCompatibility, resourcePincode, resourceRules, resourceWhitelist, @@ -30,7 +31,8 @@ import { pickPort } from "@server/routers/target/helpers"; import { resourcePassword } from "@server/db"; import { hashPassword } from "@server/auth/password"; import { isValidCIDR, isValidIP, isValidUrlGlobPattern } from "../validators"; -import { get } from "http"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "../billing/tierMatrix"; export type ProxyResourcesResults = { proxyResource: Resource; @@ -209,6 +211,15 @@ export async function updateProxyResources( resource = existingResource; } else { // Update existing resource + + const isLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.maintencePage + ); + if (!isLicensed) { + resourceData.maintenance = undefined; + } + [resource] = await trx .update(resources) .set({ @@ -233,7 +244,14 @@ export async function updateProxyResources( : false, headers: headers || null, applyRules: - resourceData.rules && resourceData.rules.length > 0 + resourceData.rules && resourceData.rules.length > 0, + maintenanceModeEnabled: + resourceData.maintenance?.enabled, + maintenanceModeType: resourceData.maintenance?.type, + maintenanceTitle: resourceData.maintenance?.title, + maintenanceMessage: resourceData.maintenance?.message, + maintenanceEstimatedTime: + resourceData.maintenance?.["estimated-time"] }) .where( eq(resources.resourceId, existingResource.resourceId) @@ -287,21 +305,47 @@ export async function updateProxyResources( existingResource.resourceId ) ); + + await trx + .delete(resourceHeaderAuthExtendedCompatibility) + .where( + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + existingResource.resourceId + ) + ); + if (resourceData.auth?.["basic-auth"]) { const headerAuthUser = resourceData.auth?.["basic-auth"]?.user; const headerAuthPassword = resourceData.auth?.["basic-auth"]?.password; - if (headerAuthUser && headerAuthPassword) { + const headerAuthExtendedCompatibility = + resourceData.auth?.["basic-auth"] + ?.extendedCompatibility; + if ( + headerAuthUser && + headerAuthPassword && + headerAuthExtendedCompatibility !== null + ) { const headerAuthHash = await hashPassword( Buffer.from( `${headerAuthUser}:${headerAuthPassword}` ).toString("base64") ); - await trx.insert(resourceHeaderAuth).values({ - resourceId: existingResource.resourceId, - headerAuthHash - }); + await Promise.all([ + trx.insert(resourceHeaderAuth).values({ + resourceId: existingResource.resourceId, + headerAuthHash + }), + trx + .insert(resourceHeaderAuthExtendedCompatibility) + .values({ + resourceId: existingResource.resourceId, + extendedCompatibilityIsActivated: + headerAuthExtendedCompatibility + }) + ]); } } @@ -542,13 +586,18 @@ export async function updateProxyResources( // Sync rules for (const [index, rule] of resourceData.rules?.entries() || []) { + const intendedPriority = rule.priority ?? index + 1; const existingRule = existingRules[index]; if (existingRule) { if ( existingRule.action !== getRuleAction(rule.action) || existingRule.match !== rule.match.toUpperCase() || existingRule.value !== - getRuleValue(rule.match.toUpperCase(), rule.value) + getRuleValue( + rule.match.toUpperCase(), + rule.value + ) || + existingRule.priority !== intendedPriority ) { validateRule(rule); await trx @@ -559,7 +608,8 @@ export async function updateProxyResources( value: getRuleValue( rule.match.toUpperCase(), rule.value - ) + ), + priority: intendedPriority }) .where( eq(resourceRules.ruleId, existingRule.ruleId) @@ -575,7 +625,7 @@ export async function updateProxyResources( rule.match.toUpperCase(), rule.value ), - priority: index + 1 // start priorities at 1 + priority: intendedPriority }); } } @@ -604,6 +654,14 @@ export async function updateProxyResources( ); } + const isLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.maintencePage + ); + if (!isLicensed) { + resourceData.maintenance = undefined; + } + // Create new resource const [newResource] = await trx .insert(resources) @@ -625,7 +683,13 @@ export async function updateProxyResources( ssl: resourceSsl, headers: headers || null, applyRules: - resourceData.rules && resourceData.rules.length > 0 + resourceData.rules && resourceData.rules.length > 0, + maintenanceModeEnabled: resourceData.maintenance?.enabled, + maintenanceModeType: resourceData.maintenance?.type, + maintenanceTitle: resourceData.maintenance?.title, + maintenanceMessage: resourceData.maintenance?.message, + maintenanceEstimatedTime: + resourceData.maintenance?.["estimated-time"] }) .returning(); @@ -656,18 +720,33 @@ export async function updateProxyResources( const headerAuthUser = resourceData.auth?.["basic-auth"]?.user; const headerAuthPassword = resourceData.auth?.["basic-auth"]?.password; + const headerAuthExtendedCompatibility = + resourceData.auth?.["basic-auth"]?.extendedCompatibility; - if (headerAuthUser && headerAuthPassword) { + if ( + headerAuthUser && + headerAuthPassword && + headerAuthExtendedCompatibility !== null + ) { const headerAuthHash = await hashPassword( Buffer.from( `${headerAuthUser}:${headerAuthPassword}` ).toString("base64") ); - await trx.insert(resourceHeaderAuth).values({ - resourceId: newResource.resourceId, - headerAuthHash - }); + await Promise.all([ + trx.insert(resourceHeaderAuth).values({ + resourceId: newResource.resourceId, + headerAuthHash + }), + trx + .insert(resourceHeaderAuthExtendedCompatibility) + .values({ + resourceId: newResource.resourceId, + extendedCompatibilityIsActivated: + headerAuthExtendedCompatibility + }) + ]); } } @@ -734,7 +813,7 @@ export async function updateProxyResources( action: getRuleAction(rule.action), match: rule.match.toUpperCase(), value: getRuleValue(rule.match.toUpperCase(), rule.value), - priority: index + 1 // start priorities at 1 + priority: rule.priority ?? index + 1 }); } @@ -865,7 +944,12 @@ async function syncUserResources( .select() .from(users) .innerJoin(userOrgs, eq(users.userId, userOrgs.userId)) - .where(and(eq(users.username, username), eq(userOrgs.orgId, orgId))) + .where( + and( + or(eq(users.username, username), eq(users.email, username)), + eq(userOrgs.orgId, orgId) + ) + ) .limit(1); if (!user) { diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index bb3cd09f1..2239e4f9a 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +import { portRangeStringSchema } from "@server/lib/ip"; +import { MaintenanceSchema } from "#dynamic/lib/blueprints/MaintenanceSchema"; export const SiteSchema = z.object({ name: z.string().min(1).max(100), @@ -55,7 +57,8 @@ export const AuthSchema = z.object({ "basic-auth": z .object({ user: z.string().min(1), - password: z.string().min(1) + password: z.string().min(1), + extendedCompatibility: z.boolean().default(true) }) .optional(), "sso-enabled": z.boolean().optional().default(false), @@ -66,7 +69,7 @@ export const AuthSchema = z.object({ .refine((roles) => !roles.includes("Admin"), { error: "Admin role cannot be included in sso-roles" }), - "sso-users": z.array(z.email()).optional().default([]), + "sso-users": z.array(z.string()).optional().default([]), "whitelist-users": z.array(z.email()).optional().default([]), "auto-login-idp": z.int().positive().optional() }); @@ -75,7 +78,8 @@ export const RuleSchema = z .object({ action: z.enum(["allow", "deny", "pass"]), match: z.enum(["cidr", "path", "ip", "country", "asn"]), - value: z.string() + value: z.string(), + priority: z.int().optional() }) .refine( (rule) => { @@ -108,32 +112,30 @@ export const RuleSchema = z .refine( (rule) => { if (rule.match === "country") { - // Check if it's a valid 2-letter country code - return /^[A-Z]{2}$/.test(rule.value); + // Check if it's a valid 2-letter country code or "ALL" + return /^[A-Z]{2}$/.test(rule.value) || rule.value === "ALL"; } return true; }, { path: ["value"], message: - "Value must be a 2-letter country code when match is 'country'" + "Value must be a 2-letter country code or 'ALL' when match is 'country'" } ) .refine( (rule) => { if (rule.match === "asn") { - // Check if it's either AS format or just a number + // Check if it's either AS format or "ALL" const asNumberPattern = /^AS\d+$/i; - const isASFormat = asNumberPattern.test(rule.value); - const isNumeric = /^\d+$/.test(rule.value); - return isASFormat || isNumeric; + return asNumberPattern.test(rule.value) || rule.value === "ALL"; } return true; }, { path: ["value"], message: - "Value must be either 'AS' format or a number when match is 'asn'" + "Value must be 'AS' format or 'ALL' when match is 'asn'" } ); @@ -156,7 +158,8 @@ export const ResourceSchema = z "host-header": z.string().optional(), "tls-server-name": z.string().optional(), headers: z.array(HeaderSchema).optional(), - rules: z.array(RuleSchema).optional() + rules: z.array(RuleSchema).optional(), + maintenance: MaintenanceSchema.optional() }) .refine( (resource) => { @@ -266,6 +269,39 @@ export const ResourceSchema = z path: ["auth"], error: "When protocol is 'tcp' or 'udp', 'auth' must not be provided" } + ) + .refine( + (resource) => { + // Skip validation for targets-only resources + if (isTargetsOnlyResource(resource)) { + return true; + } + // Skip validation if no rules are defined + if (!resource.rules || resource.rules.length === 0) return true; + + const finalPriorities: number[] = []; + let priorityCounter = 1; + + // Gather priorities, assigning auto-priorities where needed + // following the logic from the backend implementation where + // empty priorities are auto-assigned a value of 1 + index of rule + for (const rule of resource.rules) { + if (rule.priority !== undefined) { + finalPriorities.push(rule.priority); + } else { + finalPriorities.push(priorityCounter); + } + priorityCounter++; + } + + // Validate for duplicate priorities + return finalPriorities.length === new Set(finalPriorities).size; + }, + { + path: ["rules"], + message: + "Rules have conflicting or invalid priorities (must be unique, including auto-assigned ones)" + } ); export function isTargetsOnlyResource(resource: any): boolean { @@ -282,11 +318,14 @@ export const ClientResourceSchema = z // destinationPort: z.int().positive().optional(), destination: z.string().min(1), // enabled: z.boolean().default(true), + "tcp-ports": portRangeStringSchema.optional().default("*"), + "udp-ports": portRangeStringSchema.optional().default("*"), + "disable-icmp": z.boolean().optional().default(false), alias: z .string() .regex( - /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, - "Alias must be a fully qualified domain name (e.g., example.com)" + /^(?:[a-zA-Z0-9*?](?:[a-zA-Z0-9*?-]{0,61}[a-zA-Z0-9*?])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, + "Alias must be a fully qualified domain name with optional wildcards (e.g., example.com, *.example.com, host-0?.example.internal)" ) .optional(), roles: z @@ -296,7 +335,7 @@ export const ClientResourceSchema = z .refine((roles) => !roles.includes("Admin"), { error: "Admin role cannot be included in roles" }), - users: z.array(z.email()).optional().default([]), + users: z.array(z.string()).optional().default([]), machines: z.array(z.string()).optional().default([]) }) .refine( diff --git a/server/lib/cache.ts b/server/lib/cache.ts index 82c802802..f089a6387 100644 --- a/server/lib/cache.ts +++ b/server/lib/cache.ts @@ -1,5 +1,161 @@ import NodeCache from "node-cache"; +import logger from "@server/logger"; -export const cache = new NodeCache({ stdTTL: 3600, checkperiod: 120 }); +// Create local cache with maxKeys limit to prevent memory leaks +// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient +export const localCache = new NodeCache({ + stdTTL: 3600, + checkperiod: 120, + maxKeys: 10000 +}); +// Log cache statistics periodically for monitoring +setInterval(() => { + const stats = localCache.getStats(); + logger.debug( + `Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%` + ); +}, 300000); // Every 5 minutes + +/** + * Adaptive cache that uses Redis when available in multi-node environments, + * otherwise falls back to local memory cache for single-node deployments. + */ +class AdaptiveCache { + /** + * Set a value in the cache + * @param key - Cache key + * @param value - Value to cache (will be JSON stringified for Redis) + * @param ttl - Time to live in seconds (0 = no expiration) + * @returns boolean indicating success + */ + async set(key: string, value: any, ttl?: number): Promise { + const effectiveTtl = ttl === 0 ? undefined : ttl; + + // Use local cache as fallback or primary + const success = localCache.set(key, value, effectiveTtl || 0); + if (success) { + logger.debug(`Set key in local cache: ${key}`); + } + return success; + } + + /** + * Get a value from the cache + * @param key - Cache key + * @returns The cached value or undefined if not found + */ + async get(key: string): Promise { + // Use local cache as fallback or primary + const value = localCache.get(key); + if (value !== undefined) { + logger.debug(`Cache hit in local cache: ${key}`); + } else { + logger.debug(`Cache miss in local cache: ${key}`); + } + return value; + } + + /** + * Delete a value from the cache + * @param key - Cache key or array of keys + * @returns Number of deleted entries + */ + async del(key: string | string[]): Promise { + const keys = Array.isArray(key) ? key : [key]; + let deletedCount = 0; + + // Use local cache as fallback or primary + for (const k of keys) { + const success = localCache.del(k); + if (success > 0) { + deletedCount++; + logger.debug(`Deleted key from local cache: ${k}`); + } + } + + return deletedCount; + } + + /** + * Check if a key exists in the cache + * @param key - Cache key + * @returns boolean indicating if key exists + */ + async has(key: string): Promise { + // Use local cache as fallback or primary + return localCache.has(key); + } + + /** + * Get multiple values from the cache + * @param keys - Array of cache keys + * @returns Array of values (undefined for missing keys) + */ + async mget(keys: string[]): Promise<(T | undefined)[]> { + // Use local cache as fallback or primary + return keys.map((key) => localCache.get(key)); + } + + /** + * Flush all keys from the cache + */ + async flushAll(): Promise { + localCache.flushAll(); + logger.debug("Flushed local cache"); + } + + /** + * Get cache statistics + * Note: Only returns local cache stats, Redis stats are not included + */ + getStats() { + return localCache.getStats(); + } + + /** + * Get the current cache backend being used + * @returns "redis" if Redis is available and healthy, "local" otherwise + */ + getCurrentBackend(): "redis" | "local" { + return "local"; + } + + /** + * Take a key from the cache and delete it + * @param key - Cache key + * @returns The value or undefined if not found + */ + async take(key: string): Promise { + const value = await this.get(key); + if (value !== undefined) { + await this.del(key); + } + return value; + } + + /** + * Get TTL (time to live) for a key + * @param key - Cache key + * @returns TTL in seconds, 0 if no expiration, -1 if key doesn't exist + */ + getTtl(key: string): number { + const ttl = localCache.getTtl(key); + if (ttl === undefined) { + return -1; + } + return Math.max(0, Math.floor((ttl - Date.now()) / 1000)); + } + + /** + * Get all keys from the cache + * Note: Only returns local cache keys, Redis keys are not included + */ + keys(): string[] { + return localCache.keys(); + } +} + +// Export singleton instance +export const cache = new AdaptiveCache(); export default cache; diff --git a/server/lib/calculateUserClientsForOrgs.ts b/server/lib/calculateUserClientsForOrgs.ts index ac3d719f7..02ac0c417 100644 --- a/server/lib/calculateUserClientsForOrgs.ts +++ b/server/lib/calculateUserClientsForOrgs.ts @@ -1,21 +1,27 @@ +import { listExitNodes } from "#dynamic/lib/exitNodes"; +import { build } from "@server/build"; import { + approvals, clients, db, olms, orgs, roleClients, roles, + Transaction, userClients, - userOrgs, - Transaction + userOrgRoles, + userOrgs } from "@server/db"; -import { eq, and, notInArray } from "drizzle-orm"; -import { listExitNodes } from "#dynamic/lib/exitNodes"; -import { getNextAvailableClientSubnet } from "@server/lib/ip"; -import logger from "@server/logger"; -import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations"; -import { sendTerminateClient } from "@server/routers/client/terminate"; import { getUniqueClientName } from "@server/db/names"; +import { getNextAvailableClientSubnet } from "@server/lib/ip"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import logger from "@server/logger"; +import { sendTerminateClient } from "@server/routers/client/terminate"; +import { and, eq, notInArray, type InferInsertModel } from "drizzle-orm"; +import { rebuildClientAssociationsFromClient } from "./rebuildClientAssociations"; +import { OlmErrorCodes } from "@server/routers/olm/error"; +import { tierMatrix } from "./billing/tierMatrix"; export async function calculateUserClientsForOrgs( userId: string, @@ -34,18 +40,36 @@ export async function calculateUserClientsForOrgs( return; } - // Get all user orgs - const allUserOrgs = await transaction + // Get all user orgs with all roles (for org list and role-based logic) + const userOrgRoleRows = await transaction .select() .from(userOrgs) + .innerJoin( + userOrgRoles, + and( + eq(userOrgs.userId, userOrgRoles.userId), + eq(userOrgs.orgId, userOrgRoles.orgId) + ) + ) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .where(eq(userOrgs.userId, userId)); - const userOrgIds = allUserOrgs.map((uo) => uo.orgId); + const userOrgIds = [...new Set(userOrgRoleRows.map((r) => r.userOrgs.orgId))]; + const orgIdToRoleRows = new Map< + string, + (typeof userOrgRoleRows)[0][] + >(); + for (const r of userOrgRoleRows) { + const list = orgIdToRoleRows.get(r.userOrgs.orgId) ?? []; + list.push(r); + orgIdToRoleRows.set(r.userOrgs.orgId, list); + } // For each OLM, ensure there's a client in each org the user is in for (const olm of userOlms) { - for (const userOrg of allUserOrgs) { - const orgId = userOrg.orgId; + for (const orgId of orgIdToRoleRows.keys()) { + const roleRowsForOrg = orgIdToRoleRows.get(orgId)!; + const userOrg = roleRowsForOrg[0].userOrgs; const [org] = await transaction .select() @@ -182,21 +206,47 @@ export async function calculateUserClientsForOrgs( const niceId = await getUniqueClientName(orgId); + const isOrgLicensed = await isLicensedOrSubscribed( + userOrg.orgId, + tierMatrix.deviceApprovals + ); + const requireApproval = + build !== "oss" && + isOrgLicensed && + roleRowsForOrg.some((r) => r.roles.requireDeviceApproval); + + const newClientData: InferInsertModel = { + userId, + orgId: userOrg.orgId, + exitNodeId: randomExitNode.exitNodeId, + name: olm.name || "User Client", + subnet: updatedSubnet, + olmId: olm.olmId, + type: "olm", + niceId, + approvalState: requireApproval ? "pending" : null + }; + // Create the client const [newClient] = await transaction .insert(clients) - .values({ - userId, - orgId: userOrg.orgId, - exitNodeId: randomExitNode.exitNodeId, - name: olm.name || "User Client", - subnet: updatedSubnet, - olmId: olm.olmId, - type: "olm", - niceId - }) + .values(newClientData) .returning(); + // create approval request + if (requireApproval) { + await transaction + .insert(approvals) + .values({ + timestamp: Math.floor(new Date().getTime() / 1000), + orgId: userOrg.orgId, + clientId: newClient.clientId, + userId, + type: "user_device" + }) + .returning(); + } + await rebuildClientAssociationsFromClient( newClient, transaction @@ -275,6 +325,7 @@ async function cleanupOrphanedClients( if (deletedClient.olmId) { await sendTerminateClient( deletedClient.clientId, + OlmErrorCodes.TERMINATED_DELETED, deletedClient.olmId ); } diff --git a/server/lib/cleanupLogs.ts b/server/lib/cleanupLogs.ts index 7cdd6e3e1..f5b6d8b2f 100644 --- a/server/lib/cleanupLogs.ts +++ b/server/lib/cleanupLogs.ts @@ -2,9 +2,15 @@ import { db, orgs } from "@server/db"; import { cleanUpOldLogs as cleanUpOldAccessLogs } from "#dynamic/lib/logAccessAudit"; import { cleanUpOldLogs as cleanUpOldActionLogs } from "#dynamic/middlewares/logActionAudit"; import { cleanUpOldLogs as cleanUpOldRequestLogs } from "@server/routers/badger/logRequestAudit"; +import { cleanUpOldLogs as cleanUpOldConnectionLogs } from "#dynamic/routers/newt"; import { gt, or } from "drizzle-orm"; +import { cleanUpOldFingerprintSnapshots } from "@server/routers/olm/fingerprintingUtils"; +import { build } from "@server/build"; export function initLogCleanupInterval() { + if (build == "saas") { // skip log cleanup for saas builds + return null; + } return setInterval( async () => { const orgsToClean = await db @@ -15,23 +21,28 @@ export function initLogCleanupInterval() { settingsLogRetentionDaysAccess: orgs.settingsLogRetentionDaysAccess, settingsLogRetentionDaysRequest: - orgs.settingsLogRetentionDaysRequest + orgs.settingsLogRetentionDaysRequest, + settingsLogRetentionDaysConnection: + orgs.settingsLogRetentionDaysConnection }) .from(orgs) .where( or( gt(orgs.settingsLogRetentionDaysAction, 0), gt(orgs.settingsLogRetentionDaysAccess, 0), - gt(orgs.settingsLogRetentionDaysRequest, 0) + gt(orgs.settingsLogRetentionDaysRequest, 0), + gt(orgs.settingsLogRetentionDaysConnection, 0) ) ); + // TODO: handle when there are multiple nodes doing this clearing using redis for (const org of orgsToClean) { const { orgId, settingsLogRetentionDaysAction, settingsLogRetentionDaysAccess, - settingsLogRetentionDaysRequest + settingsLogRetentionDaysRequest, + settingsLogRetentionDaysConnection } = org; if (settingsLogRetentionDaysAction > 0) { @@ -54,7 +65,16 @@ export function initLogCleanupInterval() { settingsLogRetentionDaysRequest ); } + + if (settingsLogRetentionDaysConnection > 0) { + await cleanUpOldConnectionLogs( + orgId, + settingsLogRetentionDaysConnection + ); + } } + + await cleanUpOldFingerprintSnapshots(365); }, 3 * 60 * 60 * 1000 ); // every 3 hours diff --git a/server/lib/clientVersionChecks.ts b/server/lib/clientVersionChecks.ts new file mode 100644 index 000000000..330959e7c --- /dev/null +++ b/server/lib/clientVersionChecks.ts @@ -0,0 +1,20 @@ +import semver from "semver"; + +export function canCompress( + clientVersion: string | null | undefined, + type: "newt" | "olm" +): boolean { + try { + if (!clientVersion) return false; + // check if it is a valid semver + if (!semver.valid(clientVersion)) return false; + if (type === "newt") { + return semver.gte(clientVersion, "1.10.3"); + } else if (type === "olm") { + return semver.gte(clientVersion, "1.4.3"); + } + return false; + } catch { + return false; + } +} diff --git a/server/lib/config.ts b/server/lib/config.ts index 405db2d14..4cd8bbfdb 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -84,6 +84,10 @@ export class Config { ?.disable_basic_wireguard_sites ? "true" : "false"; + process.env.FLAGS_DISABLE_PRODUCT_HELP_BANNERS = parsedConfig.flags + ?.disable_product_help_banners + ? "true" + : "false"; process.env.PRODUCT_UPDATES_NOTIFICATION_ENABLED = parsedConfig.app .notifications.product_updates @@ -103,6 +107,11 @@ export class Config { process.env.MAXMIND_ASN_PATH = parsedConfig.server.maxmind_asn_path; } + process.env.DISABLE_ENTERPRISE_FEATURES = parsedConfig.flags + ?.disable_enterprise_features + ? "true" + : "false"; + this.rawConfig = parsedConfig; } diff --git a/server/lib/consts.ts b/server/lib/consts.ts index d1f66a9e3..d53bd70bb 100644 --- a/server/lib/consts.ts +++ b/server/lib/consts.ts @@ -2,7 +2,7 @@ import path from "path"; import { fileURLToPath } from "url"; // This is a placeholder value replaced by the build process -export const APP_VERSION = "1.13.1"; +export const APP_VERSION = "1.16.0"; export const __FILENAME = fileURLToPath(import.meta.url); export const __DIRNAME = path.dirname(__FILENAME); diff --git a/server/lib/createUserAccountOrg.ts b/server/lib/createUserAccountOrg.ts deleted file mode 100644 index 11f4e247e..000000000 --- a/server/lib/createUserAccountOrg.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { isValidCIDR } from "@server/lib/validators"; -import { getNextAvailableOrgSubnet } from "@server/lib/ip"; -import { - actions, - apiKeyOrg, - apiKeys, - db, - domains, - Org, - orgDomains, - orgs, - roleActions, - roles, - userOrgs -} from "@server/db"; -import { eq } from "drizzle-orm"; -import { defaultRoleAllowedActions } from "@server/routers/role"; -import { FeatureId, limitsService, sandboxLimitSet } from "@server/lib/billing"; -import { createCustomer } from "#dynamic/lib/billing"; -import { usageService } from "@server/lib/billing/usageService"; -import config from "@server/lib/config"; - -export async function createUserAccountOrg( - userId: string, - userEmail: string -): Promise<{ - success: boolean; - org?: { - orgId: string; - name: string; - subnet: string; - }; - error?: string; -}> { - // const subnet = await getNextAvailableOrgSubnet(); - const orgId = "org_" + userId; - const name = `${userEmail}'s Organization`; - - // if (!isValidCIDR(subnet)) { - // return { - // success: false, - // error: "Invalid subnet format. Please provide a valid CIDR notation." - // }; - // } - - // // make sure the subnet is unique - // const subnetExists = await db - // .select() - // .from(orgs) - // .where(eq(orgs.subnet, subnet)) - // .limit(1); - - // if (subnetExists.length > 0) { - // return { success: false, error: `Subnet ${subnet} already exists` }; - // } - - // make sure the orgId is unique - const orgExists = await db - .select() - .from(orgs) - .where(eq(orgs.orgId, orgId)) - .limit(1); - - if (orgExists.length > 0) { - return { - success: false, - error: `Organization with ID ${orgId} already exists` - }; - } - - let error = ""; - let org: Org | null = null; - - await db.transaction(async (trx) => { - const allDomains = await trx - .select() - .from(domains) - .where(eq(domains.configManaged, true)); - - const utilitySubnet = config.getRawConfig().orgs.utility_subnet_group; - - const newOrg = await trx - .insert(orgs) - .values({ - orgId, - name, - // subnet - subnet: "100.90.128.0/24", // TODO: this should not be hardcoded - or can it be the same in all orgs? - utilitySubnet: utilitySubnet, - createdAt: new Date().toISOString() - }) - .returning(); - - if (newOrg.length === 0) { - error = "Failed to create organization"; - trx.rollback(); - return; - } - - org = newOrg[0]; - - // Create admin role within the same transaction - const [insertedRole] = await trx - .insert(roles) - .values({ - orgId: newOrg[0].orgId, - isAdmin: true, - name: "Admin", - description: "Admin role with the most permissions" - }) - .returning({ roleId: roles.roleId }); - - if (!insertedRole || !insertedRole.roleId) { - error = "Failed to create Admin role"; - trx.rollback(); - return; - } - - const roleId = insertedRole.roleId; - - // Get all actions and create role actions - const actionIds = await trx.select().from(actions).execute(); - - if (actionIds.length > 0) { - await trx.insert(roleActions).values( - actionIds.map((action) => ({ - roleId, - actionId: action.actionId, - orgId: newOrg[0].orgId - })) - ); - } - - if (allDomains.length) { - await trx.insert(orgDomains).values( - allDomains.map((domain) => ({ - orgId: newOrg[0].orgId, - domainId: domain.domainId - })) - ); - } - - await trx.insert(userOrgs).values({ - userId, - orgId: newOrg[0].orgId, - roleId: roleId, - isOwner: true - }); - - const memberRole = await trx - .insert(roles) - .values({ - name: "Member", - description: "Members can only view resources", - orgId - }) - .returning(); - - await trx.insert(roleActions).values( - defaultRoleAllowedActions.map((action) => ({ - roleId: memberRole[0].roleId, - actionId: action, - orgId - })) - ); - }); - - await limitsService.applyLimitSetToOrg(orgId, sandboxLimitSet); - - if (!org) { - return { success: false, error: "Failed to create org" }; - } - - if (error) { - return { - success: false, - error: `Failed to create org: ${error}` - }; - } - - // make sure we have the stripe customer - const customerId = await createCustomer(orgId, userEmail); - - if (customerId) { - await usageService.updateDaily(orgId, FeatureId.USERS, 1, customerId); // Only 1 because we are crating the org - } - - return { - org: { - orgId, - name, - // subnet - subnet: "100.90.128.0/24" - }, - success: true - }; -} diff --git a/server/lib/deleteOrg.ts b/server/lib/deleteOrg.ts new file mode 100644 index 000000000..065f216a1 --- /dev/null +++ b/server/lib/deleteOrg.ts @@ -0,0 +1,246 @@ +import { + clients, + clientSiteResourcesAssociationsCache, + clientSitesAssociationsCache, + db, + domains, + exitNodeOrgs, + exitNodes, + olms, + orgDomains, + orgs, + remoteExitNodes, + resources, + sites, + userOrgs +} from "@server/db"; +import { newts, newtSessions } from "@server/db"; +import { eq, and, inArray, sql, count, countDistinct } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { sendToClient } from "#dynamic/routers/ws"; +import { deletePeer } from "@server/routers/gerbil/peers"; +import { OlmErrorCodes } from "@server/routers/olm/error"; +import { sendTerminateClient } from "@server/routers/client/terminate"; +import { usageService } from "./billing/usageService"; +import { FeatureId } from "./billing"; + +export type DeleteOrgByIdResult = { + deletedNewtIds: string[]; + olmsToTerminate: string[]; +}; + +/** + * Deletes one organization and its related data. Returns ids for termination + * messages; caller should call sendTerminationMessages with the result. + * Throws if org not found. + */ +export async function deleteOrgById( + orgId: string +): Promise { + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + throw createHttpError( + HttpCode.NOT_FOUND, + `Organization with ID ${orgId} not found` + ); + } + + const orgSites = await db + .select() + .from(sites) + .where(eq(sites.orgId, orgId)) + .limit(1); + + const orgClients = await db + .select() + .from(clients) + .where(eq(clients.orgId, orgId)); + + const deletedNewtIds: string[] = []; + const olmsToTerminate: string[] = []; + + let domainCount: number | null = null; + let siteCount: number | null = null; + let userCount: number | null = null; + let remoteExitNodeCount: number | null = null; + + await db.transaction(async (trx) => { + for (const site of orgSites) { + if (site.pubKey) { + if (site.type == "wireguard") { + await deletePeer(site.exitNodeId!, site.pubKey); + } else if (site.type == "newt") { + const [deletedNewt] = await trx + .delete(newts) + .where(eq(newts.siteId, site.siteId)) + .returning(); + if (deletedNewt) { + deletedNewtIds.push(deletedNewt.newtId); + await trx + .delete(newtSessions) + .where(eq(newtSessions.newtId, deletedNewt.newtId)); + } + } + } + logger.info(`Deleting site ${site.siteId}`); + await trx.delete(sites).where(eq(sites.siteId, site.siteId)); + } + for (const client of orgClients) { + const [olm] = await trx + .select() + .from(olms) + .where(eq(olms.clientId, client.clientId)) + .limit(1); + if (olm) { + olmsToTerminate.push(olm.olmId); + } + logger.info(`Deleting client ${client.clientId}`); + await trx + .delete(clients) + .where(eq(clients.clientId, client.clientId)); + await trx + .delete(clientSiteResourcesAssociationsCache) + .where( + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ) + ); + await trx + .delete(clientSitesAssociationsCache) + .where( + eq(clientSitesAssociationsCache.clientId, client.clientId) + ); + } + + await trx.delete(resources).where(eq(resources.orgId, orgId)); + + const allOrgDomains = await trx + .select() + .from(orgDomains) + .innerJoin(domains, eq(orgDomains.domainId, domains.domainId)) + .where( + and( + eq(orgDomains.orgId, orgId), + eq(domains.configManaged, false) + ) + ); + logger.info(`Found ${allOrgDomains.length} domains to delete`); + const domainIdsToDelete: string[] = []; + for (const orgDomain of allOrgDomains) { + const domainId = orgDomain.domains.domainId; + const [orgCount] = await trx + .select({ count: count() }) + .from(orgDomains) + .where(eq(orgDomains.domainId, domainId)); + logger.info(`Found ${orgCount.count} orgs using domain ${domainId}`); + if (orgCount.count === 1) { + domainIdsToDelete.push(domainId); + } + } + logger.info(`Found ${domainIdsToDelete.length} domains to delete`); + if (domainIdsToDelete.length > 0) { + await trx + .delete(domains) + .where(inArray(domains.domainId, domainIdsToDelete)); + } + + await usageService.add(orgId, FeatureId.ORGINIZATIONS, -1, trx); // here we are decreasing the org count BEFORE deleting the org because we need to still be able to get the org to get the billing org inside of here + + await trx.delete(orgs).where(eq(orgs.orgId, orgId)); + + if (org.billingOrgId) { + const billingOrgs = await trx + .select() + .from(orgs) + .where(eq(orgs.billingOrgId, org.billingOrgId)); + + if (billingOrgs.length > 0) { + const billingOrgIds = billingOrgs.map((org) => org.orgId); + + const [domainCountRes] = await trx + .select({ count: count() }) + .from(orgDomains) + .where(inArray(orgDomains.orgId, billingOrgIds)); + + domainCount = domainCountRes.count; + + const [siteCountRes] = await trx + .select({ count: count() }) + .from(sites) + .where(inArray(sites.orgId, billingOrgIds)); + + siteCount = siteCountRes.count; + + const [userCountRes] = await trx + .select({ count: countDistinct(userOrgs.userId) }) + .from(userOrgs) + .where(inArray(userOrgs.orgId, billingOrgIds)); + + userCount = userCountRes.count; + + const [remoteExitNodeCountRes] = await trx + .select({ count: countDistinct(exitNodeOrgs.exitNodeId) }) + .from(exitNodeOrgs) + .where(inArray(exitNodeOrgs.orgId, billingOrgIds)); + + remoteExitNodeCount = remoteExitNodeCountRes.count; + } + } + }); + + if (org.billingOrgId) { + usageService.updateCount( + org.billingOrgId, + FeatureId.DOMAINS, + domainCount ?? 0 + ); + usageService.updateCount( + org.billingOrgId, + FeatureId.SITES, + siteCount ?? 0 + ); + usageService.updateCount( + org.billingOrgId, + FeatureId.USERS, + userCount ?? 0 + ); + usageService.updateCount( + org.billingOrgId, + FeatureId.REMOTE_EXIT_NODES, + remoteExitNodeCount ?? 0 + ); + } + + return { deletedNewtIds, olmsToTerminate }; +} + +export function sendTerminationMessages(result: DeleteOrgByIdResult): void { + for (const newtId of result.deletedNewtIds) { + sendToClient(newtId, { type: `newt/wg/terminate`, data: {} }).catch( + (error) => { + logger.error( + "Failed to send termination message to newt:", + error + ); + } + ); + } + for (const olmId of result.olmsToTerminate) { + sendTerminateClient(0, OlmErrorCodes.TERMINATED_REKEYED, olmId).catch( + (error) => { + logger.error( + "Failed to send termination message to olm:", + error + ); + } + ); + } +} diff --git a/server/lib/getEnvOrYaml.ts b/server/lib/getEnvOrYaml.ts new file mode 100644 index 000000000..62081cef9 --- /dev/null +++ b/server/lib/getEnvOrYaml.ts @@ -0,0 +1,3 @@ +export const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => { + return process.env[envVar] ?? valFromYaml; +}; diff --git a/server/lib/ip.ts b/server/lib/ip.ts index 9c4128015..7f829bcef 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -1,15 +1,10 @@ -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"; import z from "zod"; import logger from "@server/logger"; +import semver from "semver"; interface IPRange { start: bigint; @@ -307,6 +302,26 @@ export function isIpInCidr(ip: string, cidr: string): boolean { return ipBigInt >= range.start && ipBigInt <= range.end; } +/** + * Checks if two CIDR ranges overlap + * @param cidr1 First CIDR string + * @param cidr2 Second CIDR string + * @returns boolean indicating if the two CIDRs overlap + */ +export function doCidrsOverlap(cidr1: string, cidr2: string): boolean { + const version1 = detectIpVersion(cidr1.split("/")[0]); + const version2 = detectIpVersion(cidr2.split("/")[0]); + if (version1 !== version2) { + // Different IP versions cannot overlap + return false; + } + const range1 = cidrToRange(cidr1); + const range2 = cidrToRange(cidr2); + + // Overlap if the ranges intersect + return range1.start <= range2.end && range2.start <= range1.end; +} + export async function getNextAvailableClientSubnet( orgId: string, transaction: Transaction | typeof db = db @@ -472,10 +487,12 @@ export function generateAliasConfig(allSiteResources: SiteResource[]): Alias[] { 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"; }[]; }; @@ -505,6 +522,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; @@ -515,7 +537,9 @@ export function generateSubnetProxyTargets( targets.push({ sourcePrefix: clientPrefix, - destPrefix: destination + destPrefix: destination, + portRange, + disableIcmp }); } @@ -524,13 +548,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 }); } } @@ -542,3 +570,276 @@ export function generateSubnetProxyTargets( return targets; } + +export type SubnetProxyTargetV2 = { + sourcePrefixes: string[]; // must be cidrs + destPrefix: string; // must be a cidr + disableIcmp?: boolean; + rewriteTo?: string; // must be a cidr + portRange?: { + min: number; + max: number; + protocol: "tcp" | "udp"; + }[]; + resourceId?: number; +}; + +export function generateSubnetProxyTargetV2( + siteResource: SiteResource, + clients: { + clientId: number; + pubKey: string | null; + subnet: string | null; + }[] +): SubnetProxyTargetV2 | undefined { + if (clients.length === 0) { + logger.debug( + `No clients have access to site resource ${siteResource.siteResourceId}, skipping target generation.` + ); + return; + } + + let target: SubnetProxyTargetV2 | null = null; + + const portRange = [ + ...parsePortRangeString(siteResource.tcpPortRangeString, "tcp"), + ...parsePortRangeString(siteResource.udpPortRangeString, "udp") + ]; + const disableIcmp = siteResource.disableIcmp ?? false; + + if (siteResource.mode == "host") { + let destination = siteResource.destination; + // check if this is a valid ip + const ipSchema = z.union([z.ipv4(), z.ipv6()]); + if (ipSchema.safeParse(destination).success) { + destination = `${destination}/32`; + + target = { + sourcePrefixes: [], + destPrefix: destination, + portRange, + disableIcmp, + resourceId: siteResource.siteResourceId, + }; + } + + if (siteResource.alias && siteResource.aliasAddress) { + // also push a match for the alias address + target = { + sourcePrefixes: [], + destPrefix: `${siteResource.aliasAddress}/32`, + rewriteTo: destination, + portRange, + disableIcmp, + resourceId: siteResource.siteResourceId, + }; + } + } else if (siteResource.mode == "cidr") { + target = { + sourcePrefixes: [], + destPrefix: siteResource.destination, + portRange, + disableIcmp, + resourceId: siteResource.siteResourceId, + }; + } + + if (!target) { + return; + } + + for (const clientSite of clients) { + if (!clientSite.subnet) { + logger.debug( + `Client ${clientSite.clientId} has no subnet, skipping for site resource ${siteResource.siteResourceId}.` + ); + continue; + } + + const clientPrefix = `${clientSite.subnet.split("/")[0]}/32`; + + // add client prefix to source prefixes + target.sourcePrefixes.push(clientPrefix); + } + + // print a nice representation of the targets + // logger.debug( + // `Generated subnet proxy targets for: ${JSON.stringify(targets, null, 2)}` + // ); + + return target; +} + + +/** + * Converts a SubnetProxyTargetV2 to an array of SubnetProxyTarget (v1) + * by expanding each source prefix into its own target entry. + * @param targetV2 - The v2 target to convert + * @returns Array of v1 SubnetProxyTarget objects + */ + export function convertSubnetProxyTargetsV2ToV1( + targetsV2: SubnetProxyTargetV2[] + ): SubnetProxyTarget[] { + return targetsV2.flatMap((targetV2) => + targetV2.sourcePrefixes.map((sourcePrefix) => ({ + sourcePrefix, + destPrefix: targetV2.destPrefix, + ...(targetV2.disableIcmp !== undefined && { + disableIcmp: targetV2.disableIcmp + }), + ...(targetV2.rewriteTo !== undefined && { + rewriteTo: targetV2.rewriteTo + }), + ...(targetV2.portRange !== undefined && { + portRange: targetV2.portRange + }) + })) + ); + } + + +// 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; +} + +export function stripPortFromHost(ip: string, badgerVersion?: string): string { + const isNewerBadger = + badgerVersion && + semver.valid(badgerVersion) && + semver.gte(badgerVersion, "1.3.1"); + + if (isNewerBadger) { + return ip; + } + + if (ip.startsWith("[") && ip.includes("]")) { + // if brackets are found, extract the IPv6 address from between the brackets + const ipv6Match = ip.match(/\[(.*?)\]/); + if (ipv6Match) { + return ipv6Match[1]; + } + } + + // Check if it looks like IPv4 (contains dots and matches IPv4 pattern) + // IPv4 format: x.x.x.x where x is 0-255 + const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}/; + if (ipv4Pattern.test(ip)) { + const lastColonIndex = ip.lastIndexOf(":"); + if (lastColonIndex !== -1) { + return ip.substring(0, lastColonIndex); + } + } + + // Return as is + return ip; +} diff --git a/server/lib/isLicencedOrSubscribed.ts b/server/lib/isLicencedOrSubscribed.ts new file mode 100644 index 000000000..9ebe19221 --- /dev/null +++ b/server/lib/isLicencedOrSubscribed.ts @@ -0,0 +1,8 @@ +import { Tier } from "@server/types/Tiers"; + +export async function isLicensedOrSubscribed( + orgId: string, + tiers: Tier[] +): Promise { + return false; +} diff --git a/server/lib/isSubscribed.ts b/server/lib/isSubscribed.ts new file mode 100644 index 000000000..533eec914 --- /dev/null +++ b/server/lib/isSubscribed.ts @@ -0,0 +1,8 @@ +import { Tier } from "@server/types/Tiers"; + +export async function isSubscribed( + orgId: string, + tiers: Tier[] +): Promise { + return false; +} diff --git a/server/lib/normalizePostAuthPath.ts b/server/lib/normalizePostAuthPath.ts new file mode 100644 index 000000000..7291f1842 --- /dev/null +++ b/server/lib/normalizePostAuthPath.ts @@ -0,0 +1,18 @@ +/** + * Normalizes a post-authentication path for safe use when building redirect URLs. + * Returns a path that starts with / and does not allow open redirects (no //, no :). + */ +export function normalizePostAuthPath(path: string | null | undefined): string | null { + if (path == null || typeof path !== "string") { + return null; + } + const trimmed = path.trim(); + if (trimmed === "") { + return null; + } + // Reject protocol-relative (//) or scheme (:) to avoid open redirect + if (trimmed.includes("//") || trimmed.includes(":")) { + return null; + } + return trimmed.startsWith("/") ? trimmed : `/${trimmed}`; +} diff --git a/server/lib/readConfigFile.ts b/server/lib/readConfigFile.ts index 33984e984..c3e796fc1 100644 --- a/server/lib/readConfigFile.ts +++ b/server/lib/readConfigFile.ts @@ -3,13 +3,10 @@ import yaml from "js-yaml"; import { configFilePath1, configFilePath2 } from "./consts"; import { z } from "zod"; import stoi from "./stoi"; +import { getEnvOrYaml } from "./getEnvOrYaml"; const portSchema = z.number().positive().gt(0).lte(65535); -const getEnvOrYaml = (envVar: string) => (valFromYaml: any) => { - return process.env[envVar] ?? valFromYaml; -}; - export const configSchema = z .object({ app: z @@ -82,6 +79,7 @@ export const configSchema = z .default(3001) .transform(stoi) .pipe(portSchema), + badger_override: z.string().optional(), next_port: portSchema .optional() .default(3002) @@ -192,6 +190,46 @@ export const configSchema = z .prefault({}) }) .optional(), + postgres_logs: z + .object({ + connection_string: z + .string() + .optional() + .transform(getEnvOrYaml("POSTGRES_LOGS_CONNECTION_STRING")), + replicas: z + .array( + z.object({ + connection_string: z.string() + }) + ) + .optional(), + pool: z + .object({ + max_connections: z + .number() + .positive() + .optional() + .default(20), + max_replica_connections: z + .number() + .positive() + .optional() + .default(10), + idle_timeout_ms: z + .number() + .positive() + .optional() + .default(30000), + connection_timeout_ms: z + .number() + .positive() + .optional() + .default(5000) + }) + .optional() + .prefault({}) + }) + .optional(), traefik: z .object({ http_entrypoint: z.string().optional().default("web"), @@ -256,17 +294,17 @@ export const configSchema = z orgs: z .object({ block_size: z.number().positive().gt(0).optional().default(24), - subnet_group: z.string().optional().default("100.90.128.0/24"), + subnet_group: z.string().optional().default("100.90.128.0/20"), utility_subnet_group: z .string() .optional() - .default("100.96.128.0/24") //just hardcode this for now as well + .default("100.96.128.0/20") //just hardcode this for now as well }) .optional() .default({ block_size: 24, - subnet_group: "100.90.128.0/24", - utility_subnet_group: "100.96.128.0/24" + subnet_group: "100.90.128.0/20", + utility_subnet_group: "100.96.128.0/20" }), rate_limits: z .object({ @@ -311,7 +349,10 @@ export const configSchema = z .object({ smtp_host: z.string().optional(), smtp_port: portSchema.optional(), - smtp_user: z.string().optional(), + smtp_user: z + .string() + .optional() + .transform(getEnvOrYaml("EMAIL_SMTP_USER")), smtp_pass: z .string() .optional() @@ -330,7 +371,9 @@ export const configSchema = z enable_integration_api: z.boolean().optional(), disable_local_sites: z.boolean().optional(), disable_basic_wireguard_sites: z.boolean().optional(), - disable_config_managed_domains: z.boolean().optional() + disable_config_managed_domains: z.boolean().optional(), + disable_product_help_banners: z.boolean().optional(), + disable_enterprise_features: z.boolean().optional() }) .optional(), dns: z diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 625e57935..8459ce249 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -14,6 +14,7 @@ import { siteResources, sites, Transaction, + userOrgRoles, userOrgs, userSiteResources } from "@server/db"; @@ -32,7 +33,7 @@ import logger from "@server/logger"; import { generateAliasConfig, generateRemoteSubnets, - generateSubnetProxyTargets, + generateSubnetProxyTargetV2, parseEndpoint, formatEndpoint } from "@server/lib/ip"; @@ -77,10 +78,10 @@ export async function getClientSiteResourceAccess( // get all of the users in these roles const userIdsFromRoles = await trx .select({ - userId: userOrgs.userId + userId: userOrgRoles.userId }) - .from(userOrgs) - .where(inArray(userOrgs.roleId, roleIds)) + .from(userOrgRoles) + .where(inArray(userOrgRoles.roleId, roleIds)) .then((rows) => rows.map((row) => row.userId)); const newAllUserIds = Array.from( @@ -477,6 +478,7 @@ async function handleMessagesForSiteClients( } if (isAdd) { + // TODO: if we are in jit mode here should we really be sending this? await initPeerAddHandshake( // this will kick off the add peer process for the client client.clientId, @@ -571,7 +573,7 @@ export async function updateClientSiteDestinations( destinations: [ { destinationIP: site.sites.subnet.split("/")[0], - destinationPort: site.sites.listenPort || 0 + destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated } ] }; @@ -579,7 +581,7 @@ export async function updateClientSiteDestinations( // add to the existing destinations destinations.destinations.push({ destinationIP: site.sites.subnet.split("/")[0], - destinationPort: site.sites.listenPort || 0 + destinationPort: site.sites.listenPort || 1 // this satisfies gerbil for now but should be reevaluated }); } @@ -659,17 +661,18 @@ async function handleSubnetProxyTargetUpdates( ); if (addedClients.length > 0) { - const targetsToAdd = generateSubnetProxyTargets( + const targetToAdd = generateSubnetProxyTargetV2( siteResource, addedClients ); - if (targetsToAdd.length > 0) { - logger.info( - `Adding ${targetsToAdd.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}` - ); + if (targetToAdd) { proxyJobs.push( - addSubnetProxyTargets(newt.newtId, targetsToAdd) + addSubnetProxyTargets( + newt.newtId, + [targetToAdd], + newt.version + ) ); } @@ -695,17 +698,18 @@ async function handleSubnetProxyTargetUpdates( ); if (removedClients.length > 0) { - const targetsToRemove = generateSubnetProxyTargets( + const targetToRemove = generateSubnetProxyTargetV2( siteResource, removedClients ); - if (targetsToRemove.length > 0) { - logger.info( - `Removing ${targetsToRemove.length} subnet proxy targets for siteResource ${siteResource.siteResourceId}` - ); + if (targetToRemove) { proxyJobs.push( - removeSubnetProxyTargets(newt.newtId, targetsToRemove) + removeSubnetProxyTargets( + newt.newtId, + [targetToRemove], + newt.version + ) ); } @@ -811,12 +815,12 @@ export async function rebuildClientAssociationsFromClient( // Role-based access const roleIds = await trx - .select({ roleId: userOrgs.roleId }) - .from(userOrgs) + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) .where( and( - eq(userOrgs.userId, client.userId), - eq(userOrgs.orgId, client.orgId) + eq(userOrgRoles.userId, client.userId), + eq(userOrgRoles.orgId, client.orgId) ) ) // this needs to be locked onto this org or else cross-org access could happen .then((rows) => rows.map((row) => row.roleId)); @@ -1080,6 +1084,7 @@ async function handleMessagesForClientSites( continue; } + // TODO: if we are in jit mode here should we really be sending this? await initPeerAddHandshake( // this will kick off the add peer process for the client client.clientId, @@ -1146,7 +1151,7 @@ async function handleMessagesForClientResources( // Add subnet proxy targets for each site for (const [siteId, resources] of addedBySite.entries()) { const [newt] = await trx - .select({ newtId: newts.newtId }) + .select({ newtId: newts.newtId, version: newts.version }) .from(newts) .where(eq(newts.siteId, siteId)) .limit(1); @@ -1159,7 +1164,7 @@ async function handleMessagesForClientResources( } for (const resource of resources) { - const targets = generateSubnetProxyTargets(resource, [ + const target = generateSubnetProxyTargetV2(resource, [ { clientId: client.clientId, pubKey: client.pubKey, @@ -1167,8 +1172,14 @@ async function handleMessagesForClientResources( } ]); - if (targets.length > 0) { - proxyJobs.push(addSubnetProxyTargets(newt.newtId, targets)); + if (target) { + proxyJobs.push( + addSubnetProxyTargets( + newt.newtId, + [target], + newt.version + ) + ); } try { @@ -1217,7 +1228,7 @@ async function handleMessagesForClientResources( // Remove subnet proxy targets for each site for (const [siteId, resources] of removedBySite.entries()) { const [newt] = await trx - .select({ newtId: newts.newtId }) + .select({ newtId: newts.newtId, version: newts.version }) .from(newts) .where(eq(newts.siteId, siteId)) .limit(1); @@ -1230,7 +1241,7 @@ async function handleMessagesForClientResources( } for (const resource of resources) { - const targets = generateSubnetProxyTargets(resource, [ + const target = generateSubnetProxyTargetV2(resource, [ { clientId: client.clientId, pubKey: client.pubKey, @@ -1238,9 +1249,13 @@ async function handleMessagesForClientResources( } ]); - if (targets.length > 0) { + if (target) { proxyJobs.push( - removeSubnetProxyTargets(newt.newtId, targets) + removeSubnetProxyTargets( + newt.newtId, + [target], + newt.version + ) ); } diff --git a/server/lib/resend.ts b/server/lib/resend.ts deleted file mode 100644 index 0c21b1bef..000000000 --- a/server/lib/resend.ts +++ /dev/null @@ -1,16 +0,0 @@ -export enum AudienceIds { - SignUps = "", - Subscribed = "", - Churned = "", - Newsletter = "" -} - -let resend; -export default resend; - -export async function moveEmailToAudience( - email: string, - audienceId: AudienceIds -) { - return; -} diff --git a/server/lib/sanitize.ts b/server/lib/sanitize.ts new file mode 100644 index 000000000..9eba8a583 --- /dev/null +++ b/server/lib/sanitize.ts @@ -0,0 +1,40 @@ +/** + * Sanitize a string field before inserting into a database TEXT column. + * + * Two passes are applied: + * + * 1. Lone UTF-16 surrogates – JavaScript strings can hold unpaired surrogates + * (e.g. \uD800 without a following \uDC00-\uDFFF codepoint). These are + * valid in JS but cannot be encoded as UTF-8, triggering + * `report_invalid_encoding` in SQLite / Postgres. They are replaced with + * the Unicode replacement character U+FFFD so the data is preserved as a + * visible signal that something was malformed. + * + * 2. Null bytes and C0 control characters – SQLite stores TEXT as + * null-terminated C strings, so \x00 in a value causes + * `report_invalid_encoding`. Bots and scanners routinely inject null bytes + * into URLs (e.g. `/path\u0000.jpg`). All C0 control characters in the + * range \x00-\x1F are stripped except for the three that are legitimate in + * text payloads: HT (\x09), LF (\x0A), and CR (\x0D). DEL (\x7F) is also + * stripped. + */ +export function sanitizeString(value: string): string; +export function sanitizeString( + value: string | null | undefined +): string | undefined; +export function sanitizeString( + value: string | null | undefined +): string | undefined { + if (value == null) return undefined; + return ( + value + // Replace lone high surrogates (not followed by a low surrogate) + // and lone low surrogates (not preceded by a high surrogate). + .replace( + /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?; + /** Extensions to enable */ + extensions?: string[]; +} + +/** + * Build the extensions section of the certificate + */ +function buildExtensions(extensions: string[]): Buffer { + // Extensions are a series of name-value pairs, sorted by name + // For boolean extensions, the value is empty + const sortedExtensions = [...extensions].sort(); + + const parts: Buffer[] = []; + for (const ext of sortedExtensions) { + parts.push(encodeString(ext)); + parts.push(encodeString("")); // Empty value for boolean extensions + } + + return encodeString(Buffer.concat(parts)); +} + +/** + * Build the critical options section + */ +function buildCriticalOptions(options: Map): Buffer { + const sortedKeys = [...options.keys()].sort(); + + const parts: Buffer[] = []; + for (const key of sortedKeys) { + parts.push(encodeString(key)); + parts.push(encodeString(encodeString(options.get(key)!))); + } + + return encodeString(Buffer.concat(parts)); +} + +/** + * Build the valid principals section + */ +function buildPrincipals(principals: string[]): Buffer { + const parts: Buffer[] = []; + for (const principal of principals) { + parts.push(encodeString(principal)); + } + return encodeString(Buffer.concat(parts)); +} + +/** + * Extract the raw Ed25519 public key from an OpenSSH public key blob + */ +function extractEd25519PublicKey(keyBlob: Buffer): Buffer { + const { newOffset } = decodeString(keyBlob, 0); // Skip key type + const { value: publicKey } = decodeString(keyBlob, newOffset); + return publicKey; +} + +// ============================================================================ +// CA Interface +// ============================================================================ + +export interface CAKeyPair { + /** CA private key in PEM format (keep this secret!) */ + privateKeyPem: string; + /** CA public key in PEM format */ + publicKeyPem: string; + /** CA public key in OpenSSH format (for TrustedUserCAKeys) */ + publicKeyOpenSSH: string; + /** Raw CA public key bytes (Ed25519) */ + publicKeyRaw: Buffer; +} + +export interface SignedCertificate { + /** The certificate in OpenSSH format (save as id_ed25519-cert.pub or similar) */ + certificate: string; + /** The certificate type string */ + certType: string; + /** Serial number */ + serial: bigint; + /** Key ID */ + keyId: string; + /** Valid principals */ + validPrincipals: string[]; + /** Valid from timestamp */ + validAfter: Date; + /** Valid until timestamp */ + validBefore: Date; +} + +// ============================================================================ +// Main Functions +// ============================================================================ + +/** + * Generate a new SSH Certificate Authority key pair. + * + * Returns the CA keys and the line to add to /etc/ssh/sshd_config: + * TrustedUserCAKeys /etc/ssh/ca.pub + * + * Then save the publicKeyOpenSSH to /etc/ssh/ca.pub on the server. + * + * @param comment - Optional comment for the CA public key + * @returns CA key pair and configuration info + */ +export function generateCA(comment: string = "pangolin-ssh-ca"): CAKeyPair { + // Generate Ed25519 key pair + const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519", { + publicKeyEncoding: { type: "spki", format: "pem" }, + privateKeyEncoding: { type: "pkcs8", format: "pem" } + }); + + // Get raw public key bytes + const pubKeyObj = crypto.createPublicKey(publicKey); + const rawPubKey = pubKeyObj.export({ type: "spki", format: "der" }); + // Ed25519 SPKI format: 12 byte header + 32 byte key + const ed25519PubKey = rawPubKey.subarray(rawPubKey.length - 32); + + // Create OpenSSH format public key + const pubKeyBlob = encodeEd25519PublicKey(ed25519PubKey); + const publicKeyOpenSSH = formatOpenSSHPublicKey(pubKeyBlob, comment); + + return { + privateKeyPem: privateKey, + publicKeyPem: publicKey, + publicKeyOpenSSH, + publicKeyRaw: ed25519PubKey + }; +} + +// ============================================================================ +// Helper Functions +// ============================================================================ + +/** + * Get and decrypt the SSH CA keys for an organization. + * + * @param orgId - Organization ID + * @param decryptionKey - Key to decrypt the CA private key (typically server.secret from config) + * @returns CA key pair or null if not found + */ +export async function getOrgCAKeys( + orgId: string, + decryptionKey: string +): Promise { + const { db, orgs } = await import("@server/db"); + const { eq } = await import("drizzle-orm"); + const { decrypt } = await import("@server/lib/crypto"); + + const [org] = await db + .select({ + sshCaPrivateKey: orgs.sshCaPrivateKey, + sshCaPublicKey: orgs.sshCaPublicKey + }) + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org || !org.sshCaPrivateKey || !org.sshCaPublicKey) { + return null; + } + + const privateKeyPem = decrypt(org.sshCaPrivateKey, decryptionKey); + + // Extract raw public key from the OpenSSH format + const { keyData } = parseOpenSSHPublicKey(org.sshCaPublicKey); + const { newOffset } = decodeString(keyData, 0); // Skip key type + const { value: publicKeyRaw } = decodeString(keyData, newOffset); + + // Get PEM format of public key + const pubKeyObj = crypto.createPublicKey({ + key: privateKeyPem, + format: "pem" + }); + const publicKeyPem = pubKeyObj.export({ + type: "spki", + format: "pem" + }) as string; + + return { + privateKeyPem, + publicKeyPem, + publicKeyOpenSSH: org.sshCaPublicKey, + publicKeyRaw + }; +} + +/** + * Sign a user's SSH public key with the CA, producing a certificate. + * + * The resulting certificate should be saved alongside the user's private key + * with a -cert.pub suffix. For example: + * - Private key: ~/.ssh/id_ed25519 + * - Certificate: ~/.ssh/id_ed25519-cert.pub + * + * @param caPrivateKeyPem - CA private key in PEM format + * @param userPublicKeyLine - User's public key in OpenSSH format + * @param options - Certificate options (principals, validity, etc.) + * @returns Signed certificate + */ +export function signPublicKey( + caPrivateKeyPem: string, + userPublicKeyLine: string, + options: CertificateOptions +): SignedCertificate { + // Parse the user's public key + const { keyType, keyData } = parseOpenSSHPublicKey(userPublicKeyLine); + + // Determine certificate type string + let certTypeString: string; + if (keyType === "ssh-ed25519") { + certTypeString = "ssh-ed25519-cert-v01@openssh.com"; + } else if (keyType === "ssh-rsa") { + certTypeString = "ssh-rsa-cert-v01@openssh.com"; + } else if (keyType === "ecdsa-sha2-nistp256") { + certTypeString = "ecdsa-sha2-nistp256-cert-v01@openssh.com"; + } else if (keyType === "ecdsa-sha2-nistp384") { + certTypeString = "ecdsa-sha2-nistp384-cert-v01@openssh.com"; + } else if (keyType === "ecdsa-sha2-nistp521") { + certTypeString = "ecdsa-sha2-nistp521-cert-v01@openssh.com"; + } else { + throw new Error(`Unsupported key type: ${keyType}`); + } + + // Get CA public key from private key + const caPrivKey = crypto.createPrivateKey(caPrivateKeyPem); + const caPubKey = crypto.createPublicKey(caPrivKey); + const caRawPubKey = caPubKey.export({ type: "spki", format: "der" }); + const caEd25519PubKey = caRawPubKey.subarray(caRawPubKey.length - 32); + const caPubKeyBlob = encodeEd25519PublicKey(caEd25519PubKey); + + // Set defaults + const serial = options.serial ?? BigInt(Date.now()); + const certType = options.certType ?? 1; // 1 = user cert + const now = BigInt(Math.floor(Date.now() / 1000)); + const validAfter = options.validAfter ?? now - 60n; // 1 minute ago + const validBefore = options.validBefore ?? now + 86400n * 365n; // 1 year from now + + // Default extensions for user certificates + const defaultExtensions = [ + "permit-X11-forwarding", + "permit-agent-forwarding", + "permit-port-forwarding", + "permit-pty", + "permit-user-rc" + ]; + const extensions = options.extensions ?? defaultExtensions; + const criticalOptions = options.criticalOptions ?? new Map(); + + // Generate nonce (random bytes) + const nonce = crypto.randomBytes(32); + + // Extract the public key portion from the user's key blob + // For Ed25519: skip the key type string, get the public key (already encoded) + let userKeyPortion: Buffer; + if (keyType === "ssh-ed25519") { + // Skip the key type string, take the rest (which is encodeString(32-byte-key)) + const { newOffset } = decodeString(keyData, 0); + userKeyPortion = keyData.subarray(newOffset); + } else { + // For other key types, extract everything after the key type + const { newOffset } = decodeString(keyData, 0); + userKeyPortion = keyData.subarray(newOffset); + } + + // Build the certificate body (to be signed) + const certBody = Buffer.concat([ + encodeString(certTypeString), + encodeString(nonce), + userKeyPortion, + encodeUInt64(serial), + encodeUInt32(certType), + encodeString(options.keyId), + buildPrincipals(options.validPrincipals), + encodeUInt64(validAfter), + encodeUInt64(validBefore), + buildCriticalOptions(criticalOptions), + buildExtensions(extensions), + encodeString(""), // reserved + encodeString(caPubKeyBlob) // signature key (CA public key) + ]); + + // Sign the certificate body + const signature = crypto.sign(null, certBody, caPrivKey); + + // Build the full signature blob (algorithm + signature) + const signatureBlob = Buffer.concat([ + encodeString("ssh-ed25519"), + encodeString(signature) + ]); + + // Build complete certificate + const certificate = Buffer.concat([certBody, encodeString(signatureBlob)]); + + // Format as OpenSSH certificate line + const certLine = `${certTypeString} ${certificate.toString("base64")} ${options.keyId}`; + + return { + certificate: certLine, + certType: certTypeString, + serial, + keyId: options.keyId, + validPrincipals: options.validPrincipals, + validAfter: new Date(Number(validAfter) * 1000), + validBefore: new Date(Number(validBefore) * 1000) + }; +} diff --git a/server/lib/tokenCache.ts b/server/lib/tokenCache.ts new file mode 100644 index 000000000..022f46c15 --- /dev/null +++ b/server/lib/tokenCache.ts @@ -0,0 +1,22 @@ +/** + * Returns a cached plaintext token from Redis if one exists and decrypts + * cleanly, otherwise calls `createSession` to mint a fresh token, stores the + * encrypted value in Redis with the given TTL, and returns it. + * + * Failures at the Redis layer are non-fatal – the function always falls + * through to session creation so the caller is never blocked by a Redis outage. + * + * @param cacheKey Unique Redis key, e.g. `"newt:token_cache:abc123"` + * @param secret Server secret used for AES encryption/decryption + * @param ttlSeconds Cache TTL in seconds (should match session expiry) + * @param createSession Factory that mints a new session and returns its raw token + */ +export async function getOrCreateCachedToken( + cacheKey: string, + secret: string, + ttlSeconds: number, + createSession: () => Promise +): Promise { + const token = await createSession(); + return token; +} diff --git a/server/lib/traefik/TraefikConfigManager.ts b/server/lib/traefik/TraefikConfigManager.ts index 46d5ccc85..4aed80e45 100644 --- a/server/lib/traefik/TraefikConfigManager.ts +++ b/server/lib/traefik/TraefikConfigManager.ts @@ -218,10 +218,11 @@ export class TraefikConfigManager { return true; } - // Fetch if it's been more than 24 hours (for renewals) const dayInMs = 24 * 60 * 60 * 1000; const timeSinceLastFetch = Date.now() - this.lastCertificateFetch.getTime(); + + // Fetch if it's been more than 24 hours (daily routine check) if (timeSinceLastFetch > dayInMs) { logger.info("Fetching certificates due to 24-hour renewal check"); return true; @@ -265,7 +266,7 @@ export class TraefikConfigManager { return true; } - // Check if any local certificates are missing or appear to be outdated + // Check if any local certificates are missing (needs immediate fetch) for (const domain of domainsNeedingCerts) { const localState = this.lastLocalCertificateState.get(domain); if (!localState || !localState.exists) { @@ -274,17 +275,46 @@ export class TraefikConfigManager { ); return true; } + } - // Check if certificate is expiring soon (within 30 days) - if (localState.expiresAt) { - const nowInSeconds = Math.floor(Date.now() / 1000); - const secondsUntilExpiry = localState.expiresAt - nowInSeconds; - const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24); - if (daysUntilExpiry < 30) { - logger.info( - `Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)` - ); - return true; + // For expiry checks, throttle to every 6 hours to avoid querying the + // API/DB on every monitor loop. The certificate-service renews certs + // 45 days before expiry, so checking every 6 hours is plenty frequent + // to pick up renewed certs promptly. + const renewalCheckIntervalMs = 6 * 60 * 60 * 1000; // 6 hours + if (timeSinceLastFetch > renewalCheckIntervalMs) { + // Check non-wildcard certs for expiry (within 45 days to match + // the server-side renewal window in certificate-service) + for (const domain of domainsNeedingCerts) { + const localState = this.lastLocalCertificateState.get(domain); + if (localState?.expiresAt) { + const nowInSeconds = Math.floor(Date.now() / 1000); + const secondsUntilExpiry = + localState.expiresAt - nowInSeconds; + const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24); + if (daysUntilExpiry < 45) { + logger.info( + `Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)` + ); + return true; + } + } + } + + // Also check wildcard certificates for expiry. These are not + // included in domainsNeedingCerts since their subdomains are + // filtered out, so we must check them separately. + for (const [certDomain, state] of this.lastLocalCertificateState) { + if (state.exists && state.wildcard && state.expiresAt) { + const nowInSeconds = Math.floor(Date.now() / 1000); + const secondsUntilExpiry = state.expiresAt - nowInSeconds; + const daysUntilExpiry = secondsUntilExpiry / (60 * 60 * 24); + if (daysUntilExpiry < 45) { + logger.info( + `Fetching certificates due to upcoming expiry for wildcard cert ${certDomain} (${Math.round(daysUntilExpiry)} days remaining)` + ); + return true; + } } } } @@ -361,6 +391,26 @@ export class TraefikConfigManager { } } + // Also include wildcard cert base domains that are + // expiring or expired so they get re-fetched even though + // their subdomains were filtered out above. + for (const [certDomain, state] of this + .lastLocalCertificateState) { + if (state.exists && state.wildcard && state.expiresAt) { + const nowInSeconds = Math.floor(Date.now() / 1000); + const secondsUntilExpiry = + state.expiresAt - nowInSeconds; + const daysUntilExpiry = + secondsUntilExpiry / (60 * 60 * 24); + if (daysUntilExpiry < 45) { + domainsToFetch.add(certDomain); + logger.info( + `Including expiring wildcard cert domain ${certDomain} in fetch (${Math.round(daysUntilExpiry)} days remaining)` + ); + } + } + } + if (domainsToFetch.size > 0) { // Get valid certificates for domains not covered by wildcards validCertificates = @@ -507,11 +557,18 @@ export class TraefikConfigManager { config.getRawConfig().server .session_cookie_name, - // deprecated accessTokenQueryParam: config.getRawConfig().server .resource_access_token_param, + accessTokenIdHeader: + config.getRawConfig().server + .resource_access_token_headers.id, + + accessTokenHeader: + config.getRawConfig().server + .resource_access_token_headers.token, + resourceSessionRequestParam: config.getRawConfig().server .resource_session_request_param diff --git a/server/lib/traefik/getTraefikConfig.ts b/server/lib/traefik/getTraefikConfig.ts index dc5e00817..abd0a8de0 100644 --- a/server/lib/traefik/getTraefikConfig.ts +++ b/server/lib/traefik/getTraefikConfig.ts @@ -14,29 +14,38 @@ import logger from "@server/logger"; import config from "@server/lib/config"; import { resources, sites, Target, targets } from "@server/db"; import createPathRewriteMiddleware from "./middleware"; -import { sanitize, validatePathRewriteConfig } from "./utils"; +import { sanitize, encodePath, validatePathRewriteConfig } from "./utils"; const redirectHttpsMiddlewareName = "redirect-to-https"; const badgerMiddlewareName = "badger"; +// Define extended target type with site information +type TargetWithSite = Target & { + resourceId: number; + targetId: number; + ip: string | null; + method: string | null; + port: number | null; + internalPort: number | null; + enabled: boolean; + health: string | null; + site: { + siteId: number; + type: string; + subnet: string | null; + exitNodeId: number | null; + online: boolean; + }; +}; + export async function getTraefikConfig( exitNodeId: number, siteTypes: string[], - filterOutNamespaceDomains = false, - generateLoginPageRouters = false, - allowRawResources = true + filterOutNamespaceDomains = false, // UNUSED BUT USED IN PRIVATE + generateLoginPageRouters = false, // UNUSED BUT USED IN PRIVATE + allowRawResources = true, + allowMaintenancePage = true // UNUSED BUT USED IN PRIVATE ): Promise { - // Define extended target type with site information - type TargetWithSite = Target & { - site: { - siteId: number; - type: string; - subnet: string | null; - exitNodeId: number | null; - online: boolean; - }; - }; - // Get resources with their targets and sites in a single optimized query // Start from sites on this exit node, then join to targets and resources const resourcesWithTargetsAndSites = await db @@ -59,6 +68,7 @@ export async function getTraefikConfig( headers: resources.headers, proxyProtocol: resources.proxyProtocol, proxyProtocolVersion: resources.proxyProtocolVersion, + // Target fields targetId: targets.targetId, targetEnabled: targets.enabled, @@ -103,10 +113,6 @@ export async function getTraefikConfig( eq(sites.type, "local") ) ), - or( - ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets - isNull(targetHealthCheck.hcHealth) // Include targets with no health check record - ), inArray(sites.type, siteTypes), allowRawResources ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true @@ -121,7 +127,7 @@ export async function getTraefikConfig( resourcesWithTargetsAndSites.forEach((row) => { const resourceId = row.resourceId; const resourceName = sanitize(row.resourceName) || ""; - const targetPath = sanitize(row.path) || ""; // Handle null/undefined paths + const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b") const pathMatchType = row.pathMatchType || ""; const rewritePath = row.rewritePath || ""; const rewritePathType = row.rewritePathType || ""; @@ -139,7 +145,7 @@ export async function getTraefikConfig( const mapKey = [resourceId, pathKey].filter(Boolean).join("-"); const key = sanitize(mapKey); - if (!resourcesMap.has(key)) { + if (!resourcesMap.has(mapKey)) { const validation = validatePathRewriteConfig( row.path, row.pathMatchType, @@ -154,9 +160,10 @@ export async function getTraefikConfig( return; } - resourcesMap.set(key, { + resourcesMap.set(mapKey, { resourceId: row.resourceId, name: resourceName, + key: key, fullDomain: row.fullDomain, ssl: row.ssl, http: row.http, @@ -184,8 +191,7 @@ export async function getTraefikConfig( }); } - // Add target with its associated site data - resourcesMap.get(key).targets.push({ + resourcesMap.get(mapKey).targets.push({ resourceId: row.resourceId, targetId: row.targetId, ip: row.ip, @@ -193,6 +199,7 @@ export async function getTraefikConfig( port: row.port, internalPort: row.internalPort, enabled: row.targetEnabled, + health: row.hcHealth, site: { siteId: row.siteId, type: row.siteType, @@ -221,8 +228,9 @@ export async function getTraefikConfig( }; // get the key and the resource - for (const [key, resource] of resourcesMap.entries()) { - const targets = resource.targets; + for (const [, resource] of resourcesMap.entries()) { + const targets = resource.targets as TargetWithSite[]; + const key = resource.key; const routerName = `${key}-${resource.name}-router`; const serviceName = `${key}-${resource.name}-service`; @@ -470,17 +478,24 @@ export async function getTraefikConfig( // RECEIVE BANDWIDTH ENDPOINT. // TODO: HOW TO HANDLE ^^^^^^ BETTER - const anySitesOnline = ( - targets as TargetWithSite[] - ).some((target: TargetWithSite) => target.site.online); + const anySitesOnline = targets.some( + (target) => + target.site.online || + target.site.type === "local" || + target.site.type === "wireguard" + ); return ( - (targets as TargetWithSite[]) - .filter((target: TargetWithSite) => { + targets + .filter((target) => { if (!target.enabled) { return false; } + if (target.health == "unhealthy") { + return false; + } + // If any sites are online, exclude offline sites if (anySitesOnline && !target.site.online) { return false; @@ -508,7 +523,7 @@ export async function getTraefikConfig( } return true; }) - .map((target: TargetWithSite) => { + .map((target) => { if ( target.site.type === "local" || target.site.type === "wireguard" @@ -594,16 +609,19 @@ export async function getTraefikConfig( loadBalancer: { servers: (() => { // Check if any sites are online - const anySitesOnline = ( - targets as TargetWithSite[] - ).some((target: TargetWithSite) => target.site.online); + const anySitesOnline = targets.some( + (target) => + target.site.online || + target.site.type === "local" || + target.site.type === "wireguard" + ); - return (targets as TargetWithSite[]) - .filter((target: TargetWithSite) => { + return targets + .filter((target) => { if (!target.enabled) { return false; } - + // If any sites are online, exclude offline sites if (anySitesOnline && !target.site.online) { return false; @@ -626,7 +644,7 @@ export async function getTraefikConfig( } return true; }) - .map((target: TargetWithSite) => { + .map((target) => { if ( target.site.type === "local" || target.site.type === "wireguard" diff --git a/server/lib/traefik/pathEncoding.test.ts b/server/lib/traefik/pathEncoding.test.ts new file mode 100644 index 000000000..83d53a039 --- /dev/null +++ b/server/lib/traefik/pathEncoding.test.ts @@ -0,0 +1,323 @@ +import { assertEquals } from "../../../test/assert"; + +// ── Pure function copies (inlined to avoid pulling in server dependencies) ── + +function sanitize(input: string | null | undefined): string | undefined { + if (!input) return undefined; + if (input.length > 50) { + input = input.substring(0, 50); + } + return input + .replace(/[^a-zA-Z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); +} + +function encodePath(path: string | null | undefined): string { + if (!path) return ""; + return path.replace(/[^a-zA-Z0-9]/g, (ch) => { + return ch.charCodeAt(0).toString(16); + }); +} + +// ── Helpers ────────────────────────────────────────────────────────── + +/** + * Exact replica of the OLD key computation from upstream main. + * Uses sanitize() for paths — this is what had the collision bug. + */ +function oldKeyComputation( + resourceId: number, + path: string | null, + pathMatchType: string | null, + rewritePath: string | null, + rewritePathType: string | null +): string { + const targetPath = sanitize(path) || ""; + const pmt = pathMatchType || ""; + const rp = rewritePath || ""; + const rpt = rewritePathType || ""; + const pathKey = [targetPath, pmt, rp, rpt].filter(Boolean).join("-"); + const mapKey = [resourceId, pathKey].filter(Boolean).join("-"); + return sanitize(mapKey) || ""; +} + +/** + * Replica of the NEW key computation from our fix. + * Uses encodePath() for paths — collision-free. + */ +function newKeyComputation( + resourceId: number, + path: string | null, + pathMatchType: string | null, + rewritePath: string | null, + rewritePathType: string | null +): string { + const targetPath = encodePath(path); + const pmt = pathMatchType || ""; + const rp = rewritePath || ""; + const rpt = rewritePathType || ""; + const pathKey = [targetPath, pmt, rp, rpt].filter(Boolean).join("-"); + const mapKey = [resourceId, pathKey].filter(Boolean).join("-"); + return sanitize(mapKey) || ""; +} + +// ── Tests ──────────────────────────────────────────────────────────── + +function runTests() { + console.log("Running path encoding tests...\n"); + + let passed = 0; + + // ── encodePath unit tests ──────────────────────────────────────── + + // Test 1: null/undefined/empty + { + assertEquals(encodePath(null), "", "null should return empty"); + assertEquals( + encodePath(undefined), + "", + "undefined should return empty" + ); + assertEquals(encodePath(""), "", "empty string should return empty"); + console.log(" PASS: encodePath handles null/undefined/empty"); + passed++; + } + + // Test 2: root path + { + assertEquals(encodePath("/"), "2f", "/ should encode to 2f"); + console.log(" PASS: encodePath encodes root path"); + passed++; + } + + // Test 3: alphanumeric passthrough + { + assertEquals(encodePath("/api"), "2fapi", "/api encodes slash only"); + assertEquals(encodePath("/v1"), "2fv1", "/v1 encodes slash only"); + assertEquals(encodePath("abc"), "abc", "plain alpha passes through"); + console.log(" PASS: encodePath preserves alphanumeric chars"); + passed++; + } + + // Test 4: all special chars produce unique hex + { + const paths = ["/a/b", "/a-b", "/a.b", "/a_b", "/a b"]; + const results = paths.map((p) => encodePath(p)); + const unique = new Set(results); + assertEquals( + unique.size, + paths.length, + "all special-char paths must produce unique encodings" + ); + console.log( + " PASS: encodePath produces unique output for different special chars" + ); + passed++; + } + + // Test 5: output is always alphanumeric (safe for Traefik names) + { + const paths = [ + "/", + "/api", + "/a/b", + "/a-b", + "/a.b", + "/complex/path/here" + ]; + for (const p of paths) { + const e = encodePath(p); + assertEquals( + /^[a-zA-Z0-9]+$/.test(e), + true, + `encodePath("${p}") = "${e}" must be alphanumeric` + ); + } + console.log(" PASS: encodePath output is always alphanumeric"); + passed++; + } + + // Test 6: deterministic + { + assertEquals( + encodePath("/api"), + encodePath("/api"), + "same input same output" + ); + assertEquals( + encodePath("/a/b/c"), + encodePath("/a/b/c"), + "same input same output" + ); + console.log(" PASS: encodePath is deterministic"); + passed++; + } + + // Test 7: many distinct paths never collide + { + const paths = [ + "/", + "/api", + "/api/v1", + "/api/v2", + "/a/b", + "/a-b", + "/a.b", + "/a_b", + "/health", + "/health/check", + "/admin", + "/admin/users", + "/api/v1/users", + "/api/v1/posts", + "/app", + "/app/dashboard" + ]; + const encoded = new Set(paths.map((p) => encodePath(p))); + assertEquals( + encoded.size, + paths.length, + `expected ${paths.length} unique encodings, got ${encoded.size}` + ); + console.log(" PASS: 16 realistic paths all produce unique encodings"); + passed++; + } + + // ── Collision fix: the actual bug we're fixing ─────────────────── + + // Test 8: /a/b and /a-b now have different keys (THE BUG FIX) + { + const keyAB = newKeyComputation(1, "/a/b", "prefix", null, null); + const keyDash = newKeyComputation(1, "/a-b", "prefix", null, null); + assertEquals( + keyAB !== keyDash, + true, + "/a/b and /a-b MUST have different keys" + ); + console.log(" PASS: collision fix — /a/b vs /a-b have different keys"); + passed++; + } + + // Test 9: demonstrate the old bug — old code maps /a/b and /a-b to same key + { + const oldKeyAB = oldKeyComputation(1, "/a/b", "prefix", null, null); + const oldKeyDash = oldKeyComputation(1, "/a-b", "prefix", null, null); + assertEquals( + oldKeyAB, + oldKeyDash, + "old code MUST have this collision (confirms the bug exists)" + ); + console.log(" PASS: confirmed old code bug — /a/b and /a-b collided"); + passed++; + } + + // Test 10: /api/v1 and /api-v1 — old code collision, new code fixes it + { + const oldKey1 = oldKeyComputation(1, "/api/v1", "prefix", null, null); + const oldKey2 = oldKeyComputation(1, "/api-v1", "prefix", null, null); + assertEquals( + oldKey1, + oldKey2, + "old code collision for /api/v1 vs /api-v1" + ); + + const newKey1 = newKeyComputation(1, "/api/v1", "prefix", null, null); + const newKey2 = newKeyComputation(1, "/api-v1", "prefix", null, null); + assertEquals( + newKey1 !== newKey2, + true, + "new code must separate /api/v1 and /api-v1" + ); + console.log(" PASS: collision fix — /api/v1 vs /api-v1"); + passed++; + } + + // Test 11: /app.v2 and /app/v2 and /app-v2 — three-way collision fixed + { + const a = newKeyComputation(1, "/app.v2", "prefix", null, null); + const b = newKeyComputation(1, "/app/v2", "prefix", null, null); + const c = newKeyComputation(1, "/app-v2", "prefix", null, null); + const keys = new Set([a, b, c]); + assertEquals( + keys.size, + 3, + "three paths must produce three unique keys" + ); + console.log( + " PASS: collision fix — three-way /app.v2, /app/v2, /app-v2" + ); + passed++; + } + + // ── Edge cases ─────────────────────────────────────────────────── + + // Test 12: same path in different resources — always separate + { + const key1 = newKeyComputation(1, "/api", "prefix", null, null); + const key2 = newKeyComputation(2, "/api", "prefix", null, null); + assertEquals( + key1 !== key2, + true, + "different resources with same path must have different keys" + ); + console.log(" PASS: edge case — same path, different resources"); + passed++; + } + + // Test 13: same resource, different pathMatchType — separate keys + { + const exact = newKeyComputation(1, "/api", "exact", null, null); + const prefix = newKeyComputation(1, "/api", "prefix", null, null); + assertEquals( + exact !== prefix, + true, + "exact vs prefix must have different keys" + ); + console.log(" PASS: edge case — same path, different match types"); + passed++; + } + + // Test 14: same resource and path, different rewrite config — separate keys + { + const noRewrite = newKeyComputation(1, "/api", "prefix", null, null); + const withRewrite = newKeyComputation( + 1, + "/api", + "prefix", + "/backend", + "prefix" + ); + assertEquals( + noRewrite !== withRewrite, + true, + "with vs without rewrite must have different keys" + ); + console.log(" PASS: edge case — same path, different rewrite config"); + passed++; + } + + // Test 15: paths with special URL characters + { + const paths = ["/api?foo", "/api#bar", "/api%20baz", "/api+qux"]; + const keys = new Set( + paths.map((p) => newKeyComputation(1, p, "prefix", null, null)) + ); + assertEquals( + keys.size, + paths.length, + "special URL chars must produce unique keys" + ); + console.log(" PASS: edge case — special URL characters in paths"); + passed++; + } + + console.log(`\nAll ${passed} tests passed!`); +} + +try { + runTests(); +} catch (error) { + console.error("Test failed:", error); + process.exit(1); +} diff --git a/server/lib/traefik/utils.ts b/server/lib/traefik/utils.ts index ec0eae5b3..34c293340 100644 --- a/server/lib/traefik/utils.ts +++ b/server/lib/traefik/utils.ts @@ -13,6 +13,26 @@ export function sanitize(input: string | null | undefined): string | undefined { .replace(/^-|-$/g, ""); } +/** + * Encode a URL path into a collision-free alphanumeric string suitable for use + * in Traefik map keys. + * + * Unlike sanitize(), this preserves uniqueness by encoding each non-alphanumeric + * character as its hex code. Different paths always produce different outputs. + * + * encodePath("/api") => "2fapi" + * encodePath("/a/b") => "2fa2fb" + * encodePath("/a-b") => "2fa2db" (different from /a/b) + * encodePath("/") => "2f" + * encodePath(null) => "" + */ +export function encodePath(path: string | null | undefined): string { + if (!path) return ""; + return path.replace(/[^a-zA-Z0-9]/g, (ch) => { + return ch.charCodeAt(0).toString(16); + }); +} + export function validatePathRewriteConfig( path: string | null, pathMatchType: string | null, diff --git a/server/lib/userOrg.ts b/server/lib/userOrg.ts new file mode 100644 index 000000000..809266b73 --- /dev/null +++ b/server/lib/userOrg.ts @@ -0,0 +1,163 @@ +import { + db, + Org, + orgs, + resources, + siteResources, + sites, + Transaction, + userOrgRoles, + userOrgs, + userResources, + userSiteResources, + userSites +} from "@server/db"; +import { eq, and, inArray, ne, exists } from "drizzle-orm"; +import { usageService } from "@server/lib/billing/usageService"; +import { FeatureId } from "@server/lib/billing"; + +export async function assignUserToOrg( + org: Org, + values: typeof userOrgs.$inferInsert, + roleIds: number[], + trx: Transaction | typeof db = db +) { + const uniqueRoleIds = [...new Set(roleIds)]; + if (uniqueRoleIds.length === 0) { + throw new Error("assignUserToOrg requires at least one roleId"); + } + + const [userOrg] = await trx.insert(userOrgs).values(values).returning(); + await trx.insert(userOrgRoles).values( + uniqueRoleIds.map((roleId) => ({ + userId: userOrg.userId, + orgId: userOrg.orgId, + roleId + })) + ); + + // calculate if the user is in any other of the orgs before we count it as an add to the billing org + if (org.billingOrgId) { + const otherBillingOrgs = await trx + .select() + .from(orgs) + .where( + and( + eq(orgs.billingOrgId, org.billingOrgId), + ne(orgs.orgId, org.orgId) + ) + ); + + const billingOrgIds = otherBillingOrgs.map((o) => o.orgId); + + const orgsInBillingDomainThatTheUserIsStillIn = await trx + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userOrg.userId), + inArray(userOrgs.orgId, billingOrgIds) + ) + ); + + if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) { + await usageService.add(org.orgId, FeatureId.USERS, 1, trx); + } + } +} + +export async function removeUserFromOrg( + org: Org, + userId: string, + trx: Transaction | typeof db = db +) { + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, org.orgId) + ) + ); + await trx + .delete(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, org.orgId))); + + await trx.delete(userResources).where( + and( + eq(userResources.userId, userId), + exists( + trx + .select() + .from(resources) + .where( + and( + eq(resources.resourceId, userResources.resourceId), + eq(resources.orgId, org.orgId) + ) + ) + ) + ) + ); + + await trx.delete(userSiteResources).where( + and( + eq(userSiteResources.userId, userId), + exists( + trx + .select() + .from(siteResources) + .where( + and( + eq( + siteResources.siteResourceId, + userSiteResources.siteResourceId + ), + eq(siteResources.orgId, org.orgId) + ) + ) + ) + ) + ); + + await trx.delete(userSites).where( + and( + eq(userSites.userId, userId), + exists( + db + .select() + .from(sites) + .where( + and( + eq(sites.siteId, userSites.siteId), + eq(sites.orgId, org.orgId) + ) + ) + ) + ) + ); + + // calculate if the user is in any other of the orgs before we count it as an remove to the billing org + if (org.billingOrgId) { + const billingOrgs = await trx + .select() + .from(orgs) + .where(eq(orgs.billingOrgId, org.billingOrgId)); + + const billingOrgIds = billingOrgs.map((o) => o.orgId); + + const orgsInBillingDomainThatTheUserIsStillIn = await trx + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + inArray(userOrgs.orgId, billingOrgIds) + ) + ); + + if (orgsInBillingDomainThatTheUserIsStillIn.length === 0) { + await usageService.add(org.orgId, FeatureId.USERS, -1, trx); + } + } +} diff --git a/server/lib/userOrgRoles.ts b/server/lib/userOrgRoles.ts new file mode 100644 index 000000000..c3db64af3 --- /dev/null +++ b/server/lib/userOrgRoles.ts @@ -0,0 +1,36 @@ +import { db, roles, userOrgRoles } from "@server/db"; +import { and, eq } from "drizzle-orm"; + +/** + * Get all role IDs a user has in an organization. + * Returns empty array if the user has no roles in the org (callers must treat as no access). + */ +export async function getUserOrgRoleIds( + userId: string, + orgId: string +): Promise { + const rows = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + return rows.map((r) => r.roleId); +} + +export async function getUserOrgRoles( + userId: string, + orgId: string +): Promise<{ roleId: number; roleName: string }[]> { + const rows = await db + .select({ roleId: userOrgRoles.roleId, roleName: roles.name }) + .from(userOrgRoles) + .innerJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and(eq(userOrgRoles.userId, userId), eq(userOrgRoles.orgId, orgId)) + ); + return rows; +} diff --git a/server/license/license.ts b/server/license/license.ts index cfa45d7c1..7c9609847 100644 --- a/server/license/license.ts +++ b/server/license/license.ts @@ -12,6 +12,10 @@ export type LicenseStatus = { isLicenseValid: boolean; // Is the license key valid? hostId: string; // Host ID tier?: LicenseKeyTier; + maxSites?: number; + usedSites?: number; + maxUsers?: number; + usedUsers?: number; }; export type LicenseKeyCache = { @@ -22,12 +26,14 @@ export type LicenseKeyCache = { type?: LicenseKeyType; tier?: LicenseKeyTier; terminateAt?: Date; + quantity?: number; + quantity_2?: number; }; export class License { private serverSecret!: string; - constructor(private hostMeta: HostMeta) {} + constructor(private hostMeta: HostMeta) { } public async check(): Promise { return { diff --git a/server/middlewares/getUserOrgs.ts b/server/middlewares/getUserOrgs.ts index d7905700e..fa9794fb9 100644 --- a/server/middlewares/getUserOrgs.ts +++ b/server/middlewares/getUserOrgs.ts @@ -21,8 +21,7 @@ export async function getUserOrgs( try { const userOrganizations = await db .select({ - orgId: userOrgs.orgId, - roleId: userOrgs.roleId + orgId: userOrgs.orgId }) .from(userOrgs) .where(eq(userOrgs.userId, userId)); diff --git a/server/middlewares/index.ts b/server/middlewares/index.ts index 305abaa8a..48025e8e7 100644 --- a/server/middlewares/index.ts +++ b/server/middlewares/index.ts @@ -17,6 +17,7 @@ export * from "./verifyAccessTokenAccess"; export * from "./requestTimeout"; export * from "./verifyClientAccess"; export * from "./verifyUserHasAction"; +export * from "./verifyUserCanSetUserOrgRoles"; export * from "./verifyUserIsServerAdmin"; export * from "./verifyIsLoggedInUser"; export * from "./verifyIsLoggedInUser"; @@ -24,8 +25,10 @@ export * from "./verifyClientAccess"; export * from "./integration"; export * from "./verifyUserHasAction"; export * from "./verifyApiKeyAccess"; +export * from "./verifySiteProvisioningKeyAccess"; export * from "./verifyDomainAccess"; export * from "./verifyUserIsOrgOwner"; export * from "./verifySiteResourceAccess"; export * from "./logActionAudit"; export * from "./verifyOlmAccess"; +export * from "./verifyLimits"; diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index 2e2e8ff0f..8a213c6d2 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -1,6 +1,7 @@ export * from "./verifyApiKey"; export * from "./verifyApiKeyOrgAccess"; export * from "./verifyApiKeyHasAction"; +export * from "./verifyApiKeyCanSetUserOrgRoles"; export * from "./verifyApiKeySiteAccess"; export * from "./verifyApiKeyResourceAccess"; export * from "./verifyApiKeyTargetAccess"; @@ -13,3 +14,5 @@ export * from "./verifyApiKeyIsRoot"; export * from "./verifyApiKeyApiKeyAccess"; export * from "./verifyApiKeyClientAccess"; export * from "./verifyApiKeySiteResourceAccess"; +export * from "./verifyApiKeyIdpAccess"; +export * from "./verifyApiKeyDomainAccess"; diff --git a/server/middlewares/integration/verifyApiKeyCanSetUserOrgRoles.ts b/server/middlewares/integration/verifyApiKeyCanSetUserOrgRoles.ts new file mode 100644 index 000000000..894665095 --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyCanSetUserOrgRoles.ts @@ -0,0 +1,74 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import { ActionsEnum } from "@server/auth/actions"; +import { db } from "@server/db"; +import { apiKeyActions } from "@server/db"; +import { and, eq } from "drizzle-orm"; + +async function apiKeyHasAction(apiKeyId: string, actionId: ActionsEnum) { + const [row] = await db + .select() + .from(apiKeyActions) + .where( + and( + eq(apiKeyActions.apiKeyId, apiKeyId), + eq(apiKeyActions.actionId, actionId) + ) + ); + return !!row; +} + +/** + * Allows setUserOrgRoles on the key, or both addUserRole and removeUserRole. + */ +export function verifyApiKeyCanSetUserOrgRoles() { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + if (!req.apiKey) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "API Key not authenticated" + ) + ); + } + + const keyId = req.apiKey.apiKeyId; + + if (await apiKeyHasAction(keyId, ActionsEnum.setUserOrgRoles)) { + return next(); + } + + const hasAdd = await apiKeyHasAction(keyId, ActionsEnum.addUserRole); + const hasRemove = await apiKeyHasAction( + keyId, + ActionsEnum.removeUserRole + ); + + if (hasAdd && hasRemove) { + return next(); + } + + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have permission perform this action" + ) + ); + } catch (error) { + logger.error("Error verifying API key set user org roles:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying key action access" + ) + ); + } + }; +} diff --git a/server/middlewares/integration/verifyApiKeyDomainAccess.ts b/server/middlewares/integration/verifyApiKeyDomainAccess.ts new file mode 100644 index 000000000..db0f5d95d --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyDomainAccess.ts @@ -0,0 +1,90 @@ +import { Request, Response, NextFunction } from "express"; +import { db, domains, orgDomains, apiKeyOrg } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyDomainAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const domainId = + req.params.domainId || req.body.domainId || req.query.domainId; + const orgId = req.params.orgId; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!domainId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid domain ID") + ); + } + + if (apiKey.isRoot) { + // Root keys can access any domain in any org + return next(); + } + + // Verify domain exists and belongs to the organization + const [domain] = await db + .select() + .from(domains) + .innerJoin(orgDomains, eq(orgDomains.domainId, domains.domainId)) + .where( + and( + eq(orgDomains.domainId, domainId), + eq(orgDomains.orgId, orgId) + ) + ) + .limit(1); + + if (!domain) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Domain with ID ${domainId} not found in organization ${orgId}` + ) + ); + } + + // Verify the API key has access to this organization + if (!req.apiKeyOrg) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ) + .limit(1); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying domain access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyIdpAccess.ts b/server/middlewares/integration/verifyApiKeyIdpAccess.ts new file mode 100644 index 000000000..99b7e76bc --- /dev/null +++ b/server/middlewares/integration/verifyApiKeyIdpAccess.ts @@ -0,0 +1,88 @@ +import { Request, Response, NextFunction } from "express"; +import { db } from "@server/db"; +import { idp, idpOrg, apiKeyOrg } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; + +export async function verifyApiKeyIdpAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const apiKey = req.apiKey; + const idpId = req.params.idpId || req.body.idpId || req.query.idpId; + const orgId = req.params.orgId; + + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!idpId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid IDP ID") + ); + } + + if (apiKey.isRoot) { + // Root keys can access any IDP in any org + return next(); + } + + const [idpRes] = await db + .select() + .from(idp) + .innerJoin(idpOrg, eq(idp.idpId, idpOrg.idpId)) + .where(and(eq(idp.idpId, idpId), eq(idpOrg.orgId, orgId))) + .limit(1); + + if (!idpRes || !idpRes.idp || !idpRes.idpOrg) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `IdP with ID ${idpId} not found for organization ${orgId}` + ) + ); + } + + if (!req.apiKeyOrg) { + const apiKeyOrgRes = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, idpRes.idpOrg.orgId) + ) + ); + req.apiKeyOrg = apiKeyOrgRes[0]; + } + + if (!req.apiKeyOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to this organization" + ) + ); + } + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying IDP access" + ) + ); + } +} diff --git a/server/middlewares/integration/verifyApiKeyOrgAccess.ts b/server/middlewares/integration/verifyApiKeyOrgAccess.ts index c705dc0fd..978800038 100644 --- a/server/middlewares/integration/verifyApiKeyOrgAccess.ts +++ b/server/middlewares/integration/verifyApiKeyOrgAccess.ts @@ -4,7 +4,6 @@ import { apiKeyOrg } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; -import logger from "@server/logger"; export async function verifyApiKeyOrgAccess( req: Request, diff --git a/server/middlewares/integration/verifyApiKeyRoleAccess.ts b/server/middlewares/integration/verifyApiKeyRoleAccess.ts index ffe223a62..62bfb9461 100644 --- a/server/middlewares/integration/verifyApiKeyRoleAccess.ts +++ b/server/middlewares/integration/verifyApiKeyRoleAccess.ts @@ -23,9 +23,14 @@ export async function verifyApiKeyRoleAccess( ); } - const { roleIds } = req.body; - const allRoleIds = - roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]); + let allRoleIds: number[] = []; + if (!isNaN(singleRoleId)) { + // If roleId is provided in URL params, query params, or body (single), use it exclusively + allRoleIds = [singleRoleId]; + } else if (req.body?.roleIds) { + // Only use body.roleIds if no single roleId was provided + allRoleIds = req.body.roleIds; + } if (allRoleIds.length === 0) { return next(); diff --git a/server/middlewares/verifyAccessTokenAccess.ts b/server/middlewares/verifyAccessTokenAccess.ts index 033b326d9..f1f2ca52e 100644 --- a/server/middlewares/verifyAccessTokenAccess.ts +++ b/server/middlewares/verifyAccessTokenAccess.ts @@ -6,6 +6,7 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { canUserAccessResource } from "@server/auth/canUserAccessResource"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyAccessTokenAccess( req: Request, @@ -93,7 +94,10 @@ export async function verifyAccessTokenAccess( ) ); } else { - req.userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + resource[0].orgId! + ); req.userOrgId = resource[0].orgId!; } @@ -118,7 +122,7 @@ export async function verifyAccessTokenAccess( const resourceAllowed = await canUserAccessResource({ userId, resourceId, - roleId: req.userOrgRoleId! + roleIds: req.userOrgRoleIds ?? [] }); if (!resourceAllowed) { diff --git a/server/middlewares/verifyAdmin.ts b/server/middlewares/verifyAdmin.ts index 253bfc2dd..0dbeac2cb 100644 --- a/server/middlewares/verifyAdmin.ts +++ b/server/middlewares/verifyAdmin.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { roles, userOrgs } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyAdmin( req: Request, @@ -62,13 +63,29 @@ export async function verifyAdmin( } } - const userRole = await db + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId!); + + if (req.userOrgRoleIds.length === 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have Admin access" + ) + ); + } + + const userAdminRoles = await db .select() .from(roles) - .where(eq(roles.roleId, req.userOrg.roleId)) + .where( + and( + inArray(roles.roleId, req.userOrgRoleIds), + eq(roles.isAdmin, true) + ) + ) .limit(1); - if (userRole.length === 0 || !userRole[0].isAdmin) { + if (userAdminRoles.length === 0) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/middlewares/verifyApiKeyAccess.ts b/server/middlewares/verifyApiKeyAccess.ts index 6edc5ab8e..b497892c8 100644 --- a/server/middlewares/verifyApiKeyAccess.ts +++ b/server/middlewares/verifyApiKeyAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { userOrgs, apiKeys, apiKeyOrg } from "@server/db"; -import { and, eq, or } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyApiKeyAccess( req: Request, @@ -103,8 +104,10 @@ export async function verifyApiKeyAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + orgId + ); return next(); } catch (error) { diff --git a/server/middlewares/verifyClientAccess.ts b/server/middlewares/verifyClientAccess.ts index d2df38a4b..1d994b53f 100644 --- a/server/middlewares/verifyClientAccess.ts +++ b/server/middlewares/verifyClientAccess.ts @@ -1,11 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { Client, db } from "@server/db"; import { userOrgs, clients, roleClients, userClients } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import logger from "@server/logger"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyClientAccess( req: Request, @@ -113,21 +114,30 @@ export async function verifyClientAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + client.orgId + ); req.userOrgId = client.orgId; - // Check role-based site access first - const [roleClientAccess] = await db - .select() - .from(roleClients) - .where( - and( - eq(roleClients.clientId, client.clientId), - eq(roleClients.roleId, userOrgRoleId) - ) - ) - .limit(1); + // Check role-based client access (any of user's roles) + const roleClientAccessList = + (req.userOrgRoleIds?.length ?? 0) > 0 + ? await db + .select() + .from(roleClients) + .where( + and( + eq(roleClients.clientId, client.clientId), + inArray( + roleClients.roleId, + req.userOrgRoleIds! + ) + ) + ) + .limit(1) + : []; + const [roleClientAccess] = roleClientAccessList; if (roleClientAccess) { // User has access to the site through their role diff --git a/server/middlewares/verifyDomainAccess.ts b/server/middlewares/verifyDomainAccess.ts index 88ffe678d..c9ecf42e0 100644 --- a/server/middlewares/verifyDomainAccess.ts +++ b/server/middlewares/verifyDomainAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db, domains, orgDomains } from "@server/db"; -import { userOrgs, apiKeyOrg } from "@server/db"; +import { userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyDomainAccess( req: Request, @@ -63,7 +64,7 @@ export async function verifyDomainAccess( .where( and( eq(userOrgs.userId, userId), - eq(userOrgs.orgId, apiKeyOrg.orgId) + eq(userOrgs.orgId, orgId) ) ) .limit(1); @@ -97,8 +98,7 @@ export async function verifyDomainAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); return next(); } catch (error) { diff --git a/server/middlewares/verifyLimits.ts b/server/middlewares/verifyLimits.ts new file mode 100644 index 000000000..667895309 --- /dev/null +++ b/server/middlewares/verifyLimits.ts @@ -0,0 +1,43 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { usageService } from "@server/lib/billing/usageService"; +import { build } from "@server/build"; + +export async function verifyLimits( + req: Request, + res: Response, + next: NextFunction +) { + if (build != "saas") { + return next(); + } + + const orgId = req.userOrgId || req.apiKeyOrg?.orgId || req.params.orgId; + + if (!orgId) { + return next(); // its fine if we silently fail here because this is not critical to operation or security and its better user experience if we dont fail + } + + try { + const reject = await usageService.checkLimitSet(orgId); + + if (reject) { + return next( + createHttpError( + HttpCode.PAYMENT_REQUIRED, + "Organization has exceeded its usage limits. Please upgrade your plan or contact support." + ) + ); + } + + return next(); + } catch (e) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error checking limits" + ) + ); + } +} diff --git a/server/middlewares/verifyOrgAccess.ts b/server/middlewares/verifyOrgAccess.ts index 729766abd..cb797afb0 100644 --- a/server/middlewares/verifyOrgAccess.ts +++ b/server/middlewares/verifyOrgAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; -import { db, orgs } from "@server/db"; +import { db } from "@server/db"; import { userOrgs } from "@server/db"; import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyOrgAccess( req: Request, @@ -64,8 +65,8 @@ export async function verifyOrgAccess( } } - // User has access, attach the user's role to the request for potential future use - req.userOrgRoleId = req.userOrg.roleId; + // User has access, attach the user's role(s) to the request for potential future use + req.userOrgRoleIds = await getUserOrgRoleIds(req.userOrg.userId, orgId); req.userOrgId = orgId; return next(); diff --git a/server/middlewares/verifyResourceAccess.ts b/server/middlewares/verifyResourceAccess.ts index 2ae591ee1..ba49f02e3 100644 --- a/server/middlewares/verifyResourceAccess.ts +++ b/server/middlewares/verifyResourceAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db, Resource } from "@server/db"; import { resources, userOrgs, userResources, roleResources } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyResourceAccess( req: Request, @@ -107,20 +108,28 @@ export async function verifyResourceAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + resource.orgId + ); req.userOrgId = resource.orgId; - const roleResourceAccess = await db - .select() - .from(roleResources) - .where( - and( - eq(roleResources.resourceId, resource.resourceId), - eq(roleResources.roleId, userOrgRoleId) - ) - ) - .limit(1); + const roleResourceAccess = + (req.userOrgRoleIds?.length ?? 0) > 0 + ? await db + .select() + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resource.resourceId), + inArray( + roleResources.roleId, + req.userOrgRoleIds! + ) + ) + ) + .limit(1) + : []; if (roleResourceAccess.length > 0) { return next(); diff --git a/server/middlewares/verifyRoleAccess.ts b/server/middlewares/verifyRoleAccess.ts index 91adf07c4..380b82048 100644 --- a/server/middlewares/verifyRoleAccess.ts +++ b/server/middlewares/verifyRoleAccess.ts @@ -6,6 +6,7 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyRoleAccess( req: Request, @@ -23,8 +24,14 @@ export async function verifyRoleAccess( ); } - const roleIds = req.body?.roleIds; - const allRoleIds = roleIds || (isNaN(singleRoleId) ? [] : [singleRoleId]); + let allRoleIds: number[] = []; + if (!isNaN(singleRoleId)) { + // If roleId is provided in URL params, query params, or body (single), use it exclusively + allRoleIds = [singleRoleId]; + } else if (req.body?.roleIds) { + // Only use body.roleIds if no single roleId was provided + allRoleIds = req.body.roleIds; + } if (allRoleIds.length === 0) { return next(); @@ -93,7 +100,6 @@ export async function verifyRoleAccess( } if (!req.userOrg) { - // get the userORg const userOrg = await db .select() .from(userOrgs) @@ -103,7 +109,7 @@ export async function verifyRoleAccess( .limit(1); req.userOrg = userOrg[0]; - req.userOrgRoleId = userOrg[0].roleId; + req.userOrgRoleIds = await getUserOrgRoleIds(userId, orgId!); } if (!req.userOrg) { diff --git a/server/middlewares/verifySiteAccess.ts b/server/middlewares/verifySiteAccess.ts index 98858cfb9..e630cf0f1 100644 --- a/server/middlewares/verifySiteAccess.ts +++ b/server/middlewares/verifySiteAccess.ts @@ -1,10 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { db } from "@server/db"; import { sites, Site, userOrgs, userSites, roleSites, roles } from "@server/db"; -import { and, eq, or } from "drizzle-orm"; +import { and, eq, inArray, or } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifySiteAccess( req: Request, @@ -112,21 +113,29 @@ export async function verifySiteAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + site.orgId + ); req.userOrgId = site.orgId; - // Check role-based site access first - const roleSiteAccess = await db - .select() - .from(roleSites) - .where( - and( - eq(roleSites.siteId, site.siteId), - eq(roleSites.roleId, userOrgRoleId) - ) - ) - .limit(1); + // Check role-based site access first (any of user's roles) + const roleSiteAccess = + (req.userOrgRoleIds?.length ?? 0) > 0 + ? await db + .select() + .from(roleSites) + .where( + and( + eq(roleSites.siteId, site.siteId), + inArray( + roleSites.roleId, + req.userOrgRoleIds! + ) + ) + ) + .limit(1) + : []; if (roleSiteAccess.length > 0) { // User's role has access to the site diff --git a/server/middlewares/verifySiteProvisioningKeyAccess.ts b/server/middlewares/verifySiteProvisioningKeyAccess.ts new file mode 100644 index 000000000..e0d446de6 --- /dev/null +++ b/server/middlewares/verifySiteProvisioningKeyAccess.ts @@ -0,0 +1,131 @@ +import { Request, Response, NextFunction } from "express"; +import { db, userOrgs, siteProvisioningKeys, siteProvisioningKeyOrg } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; + +export async function verifySiteProvisioningKeyAccess( + req: Request, + res: Response, + next: NextFunction +) { + try { + const userId = req.user!.userId; + const siteProvisioningKeyId = req.params.siteProvisioningKeyId; + const orgId = req.params.orgId; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (!orgId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid organization ID") + ); + } + + if (!siteProvisioningKeyId) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid key ID") + ); + } + + const [row] = await db + .select() + .from(siteProvisioningKeys) + .innerJoin( + siteProvisioningKeyOrg, + and( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyOrg.siteProvisioningKeyId + ), + eq(siteProvisioningKeyOrg.orgId, orgId) + ) + ) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ) + .limit(1); + + if (!row?.siteProvisioningKeys) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site provisioning key with ID ${siteProvisioningKeyId} not found` + ) + ); + } + + if (!row.siteProvisioningKeyOrg.orgId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + `Site provisioning key with ID ${siteProvisioningKeyId} does not have an organization ID` + ) + ); + } + + if (!req.userOrg) { + const userOrgRole = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, userId), + eq( + userOrgs.orgId, + row.siteProvisioningKeyOrg.orgId + ) + ) + ) + .limit(1); + req.userOrg = userOrgRole[0]; + } + + if (!req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + if (req.orgPolicyAllowed === undefined && req.userOrg.orgId) { + const policyCheck = await checkOrgAccessPolicy({ + orgId: req.userOrg.orgId, + userId, + session: req.session + }); + req.orgPolicyAllowed = policyCheck.allowed; + if (!policyCheck.allowed || policyCheck.error) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Failed organization access policy check: " + + (policyCheck.error || "Unknown error") + ) + ); + } + } + + const userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleId = userOrgRoleId; + + return next(); + } catch (error) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying site provisioning key access" + ) + ); + } +} diff --git a/server/middlewares/verifySiteResourceAccess.ts b/server/middlewares/verifySiteResourceAccess.ts index ca7d37fb3..8d5bd656f 100644 --- a/server/middlewares/verifySiteResourceAccess.ts +++ b/server/middlewares/verifySiteResourceAccess.ts @@ -1,11 +1,12 @@ import { Request, Response, NextFunction } from "express"; import { db, roleSiteResources, userOrgs, userSiteResources } from "@server/db"; import { siteResources } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { eq, and, inArray } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import logger from "@server/logger"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifySiteResourceAccess( req: Request, @@ -109,23 +110,34 @@ export async function verifySiteResourceAccess( } } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + siteResource.orgId + ); req.userOrgId = siteResource.orgId; // Attach the siteResource to the request for use in the next middleware/route req.siteResource = siteResource; - const roleResourceAccess = await db - .select() - .from(roleSiteResources) - .where( - and( - eq(roleSiteResources.siteResourceId, siteResourceIdNum), - eq(roleSiteResources.roleId, userOrgRoleId) - ) - ) - .limit(1); + const roleResourceAccess = + (req.userOrgRoleIds?.length ?? 0) > 0 + ? await db + .select() + .from(roleSiteResources) + .where( + and( + eq( + roleSiteResources.siteResourceId, + siteResourceIdNum + ), + inArray( + roleSiteResources.roleId, + req.userOrgRoleIds! + ) + ) + ) + .limit(1) + : []; if (roleResourceAccess.length > 0) { return next(); diff --git a/server/middlewares/verifyTargetAccess.ts b/server/middlewares/verifyTargetAccess.ts index 7e433fcb8..141a04549 100644 --- a/server/middlewares/verifyTargetAccess.ts +++ b/server/middlewares/verifyTargetAccess.ts @@ -6,6 +6,7 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { canUserAccessResource } from "../auth/canUserAccessResource"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyTargetAccess( req: Request, @@ -99,7 +100,10 @@ export async function verifyTargetAccess( ) ); } else { - req.userOrgRoleId = req.userOrg.roleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + resource[0].orgId! + ); req.userOrgId = resource[0].orgId!; } @@ -126,7 +130,7 @@ export async function verifyTargetAccess( const resourceAllowed = await canUserAccessResource({ userId, resourceId, - roleId: req.userOrgRoleId! + roleIds: req.userOrgRoleIds ?? [] }); if (!resourceAllowed) { diff --git a/server/middlewares/verifyUserCanSetUserOrgRoles.ts b/server/middlewares/verifyUserCanSetUserOrgRoles.ts new file mode 100644 index 000000000..1a7554ab3 --- /dev/null +++ b/server/middlewares/verifyUserCanSetUserOrgRoles.ts @@ -0,0 +1,54 @@ +import { Request, Response, NextFunction } from "express"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import logger from "@server/logger"; +import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; + +/** + * Allows the new setUserOrgRoles action, or legacy permission pair addUserRole + removeUserRole. + */ +export function verifyUserCanSetUserOrgRoles() { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + const canSet = await checkUserActionPermission( + ActionsEnum.setUserOrgRoles, + req + ); + if (canSet) { + return next(); + } + + const canAdd = await checkUserActionPermission( + ActionsEnum.addUserRole, + req + ); + const canRemove = await checkUserActionPermission( + ActionsEnum.removeUserRole, + req + ); + + if (canAdd && canRemove) { + return next(); + } + + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have permission perform this action" + ) + ); + } catch (error) { + logger.error("Error verifying set user org roles access:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying role access" + ) + ); + } + }; +} diff --git a/server/middlewares/verifyUserInRole.ts b/server/middlewares/verifyUserInRole.ts index 2a153114d..18eeb44f3 100644 --- a/server/middlewares/verifyUserInRole.ts +++ b/server/middlewares/verifyUserInRole.ts @@ -12,7 +12,7 @@ export async function verifyUserInRole( const roleId = parseInt( req.params.roleId || req.body.roleId || req.query.roleId ); - const userRoleId = req.userOrgRoleId; + const userOrgRoleIds = req.userOrgRoleIds ?? []; if (isNaN(roleId)) { return next( @@ -20,7 +20,7 @@ export async function verifyUserInRole( ); } - if (!userRoleId) { + if (userOrgRoleIds.length === 0) { return next( createHttpError( HttpCode.FORBIDDEN, @@ -29,7 +29,7 @@ export async function verifyUserInRole( ); } - if (userRoleId !== roleId) { + if (!userOrgRoleIds.includes(roleId)) { return next( createHttpError( HttpCode.FORBIDDEN, diff --git a/server/openApi.ts b/server/openApi.ts index 68b05a30d..26c9e2f2e 100644 --- a/server/openApi.ts +++ b/server/openApi.ts @@ -5,16 +5,20 @@ export const registry = new OpenAPIRegistry(); export enum OpenAPITags { Site = "Site", Org = "Organization", - Resource = "Resource", + PublicResource = "Public Resource", + PrivateResource = "Private Resource", Role = "Role", User = "User", - Invitation = "Invitation", - Target = "Target", + Invitation = "User Invitation", + Target = "Resource Target", Rule = "Rule", AccessToken = "Access Token", - Idp = "Identity Provider", + GlobalIdp = "Identity Provider (Global)", + OrgIdp = "Identity Provider (Organization Only)", Client = "Client", ApiKey = "API Key", Domain = "Domain", - Blueprint = "Blueprint" + Blueprint = "Blueprint", + Ssh = "SSH", + Logs = "Logs" } diff --git a/server/private/cleanup.ts b/server/private/cleanup.ts index e9b305270..17d823491 100644 --- a/server/private/cleanup.ts +++ b/server/private/cleanup.ts @@ -13,8 +13,16 @@ import { rateLimitService } from "#private/lib/rateLimit"; import { cleanup as wsCleanup } from "#private/routers/ws"; +import { flushBandwidthToDb } from "@server/routers/newt/handleReceiveBandwidthMessage"; +import { flushConnectionLogToDb } from "#dynamic/routers/newt"; +import { flushSiteBandwidthToDb } from "@server/routers/gerbil/receiveBandwidth"; +import { stopPingAccumulator } from "@server/routers/newt/pingAccumulator"; async function cleanup() { + await stopPingAccumulator(); + await flushBandwidthToDb(); + await flushConnectionLogToDb(); + await flushSiteBandwidthToDb(); await rateLimitService.cleanup(); await wsCleanup(); diff --git a/server/private/lib/billing/getOrgTierData.ts b/server/private/lib/billing/getOrgTierData.ts index fbfb5cb0d..9972dcfc5 100644 --- a/server/private/lib/billing/getOrgTierData.ts +++ b/server/private/lib/billing/getOrgTierData.ts @@ -11,36 +11,84 @@ * This file is not licensed under the AGPLv3. */ -import { getTierPriceSet } from "@server/lib/billing/tiers"; -import { getOrgSubscriptionData } from "#private/routers/billing/getOrgSubscription"; import { build } from "@server/build"; +import { db, customers, subscriptions, orgs } from "@server/db"; +import logger from "@server/logger"; +import { Tier } from "@server/types/Tiers"; +import { eq, and, ne } from "drizzle-orm"; export async function getOrgTierData( orgId: string -): Promise<{ tier: string | null; active: boolean }> { - let tier = null; +): Promise<{ tier: Tier | null; active: boolean }> { + let tier: Tier | null = null; let active = false; if (build !== "saas") { return { tier, active }; } - const { subscription, items } = await getOrgSubscriptionData(orgId); + try { + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); - if (items && items.length > 0) { - const tierPriceSet = getTierPriceSet(); - // Iterate through tiers in order (earlier keys are higher tiers) - for (const [tierId, priceId] of Object.entries(tierPriceSet)) { - // Check if any subscription item matches this tier's price ID - const matchingItem = items.find((item) => item.priceId === priceId); - if (matchingItem) { - tier = tierId; - break; + if (!org) { + return { tier, active }; + } + + let orgIdToUse = org.orgId; + if (!org.isBillingOrg) { + if (!org.billingOrgId) { + logger.warn( + `Org ${orgId} is not a billing org and does not have a billingOrgId` + ); + return { tier, active }; + } + orgIdToUse = org.billingOrgId; + } + + // Get customer for org + const [customer] = await db + .select() + .from(customers) + .where(eq(customers.orgId, orgIdToUse)) + .limit(1); + + if (!customer) { + return { tier, active }; + } + + // Query for active subscriptions that are not license type + const [subscription] = await db + .select() + .from(subscriptions) + .where( + and( + eq(subscriptions.customerId, customer.customerId), + eq(subscriptions.status, "active"), + ne(subscriptions.type, "license") + ) + ) + .limit(1); + + if (subscription) { + // Validate that subscription.type is one of the expected tier values + if ( + subscription.type === "tier1" || + subscription.type === "tier2" || + subscription.type === "tier3" || + subscription.type === "enterprise" + ) { + tier = subscription.type; + active = true; } } + } catch (error) { + // If org not found or error occurs, return null tier and inactive + // This is acceptable behavior as per the function signature } - if (subscription && subscription.status === "active") { - active = true; - } + return { tier, active }; } diff --git a/server/private/lib/blueprints/MaintenanceSchema.ts b/server/private/lib/blueprints/MaintenanceSchema.ts new file mode 100644 index 000000000..af6b525a3 --- /dev/null +++ b/server/private/lib/blueprints/MaintenanceSchema.ts @@ -0,0 +1,22 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { z } from "zod"; + +export const MaintenanceSchema = z.object({ + enabled: z.boolean().optional(), + type: z.enum(["forced", "automatic"]).optional(), + title: z.string().max(255).nullable().optional(), + message: z.string().max(2000).nullable().optional(), + "estimated-time": z.string().max(100).nullable().optional() +}); diff --git a/server/private/lib/cache.ts b/server/private/lib/cache.ts new file mode 100644 index 000000000..1a2006d46 --- /dev/null +++ b/server/private/lib/cache.ts @@ -0,0 +1,300 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import NodeCache from "node-cache"; +import logger from "@server/logger"; +import { redisManager } from "@server/private/lib/redis"; + +// Create local cache with maxKeys limit to prevent memory leaks +// With ~10k requests/day and 5min TTL, 10k keys should be more than sufficient +export const localCache = new NodeCache({ + stdTTL: 3600, + checkperiod: 120, + maxKeys: 10000 +}); + +// Log cache statistics periodically for monitoring +setInterval(() => { + const stats = localCache.getStats(); + logger.debug( + `Local cache stats - Keys: ${stats.keys}, Hits: ${stats.hits}, Misses: ${stats.misses}, Hit rate: ${stats.hits > 0 ? ((stats.hits / (stats.hits + stats.misses)) * 100).toFixed(2) : 0}%` + ); +}, 300000); // Every 5 minutes + +/** + * Adaptive cache that uses Redis when available in multi-node environments, + * otherwise falls back to local memory cache for single-node deployments. + */ +class AdaptiveCache { + private useRedis(): boolean { + return ( + redisManager.isRedisEnabled() && + redisManager.getHealthStatus().isHealthy + ); + } + + /** + * Set a value in the cache + * @param key - Cache key + * @param value - Value to cache (will be JSON stringified for Redis) + * @param ttl - Time to live in seconds (0 = no expiration; omit = 3600s for Redis) + * @returns boolean indicating success + */ + async set(key: string, value: any, ttl?: number): Promise { + const effectiveTtl = ttl === 0 ? undefined : ttl; + const redisTtl = ttl === 0 ? undefined : (ttl ?? 3600); + + if (this.useRedis()) { + try { + const serialized = JSON.stringify(value); + const success = await redisManager.set( + key, + serialized, + redisTtl + ); + + if (success) { + logger.debug(`Set key in Redis: ${key}`); + return true; + } + + // Redis failed, fall through to local cache + logger.debug( + `Redis set failed for key ${key}, falling back to local cache` + ); + } catch (error) { + logger.error(`Redis set error for key ${key}:`, error); + // Fall through to local cache + } + } + + // Use local cache as fallback or primary + const success = localCache.set(key, value, effectiveTtl || 0); + if (success) { + logger.debug(`Set key in local cache: ${key}`); + } + return success; + } + + /** + * Get a value from the cache + * @param key - Cache key + * @returns The cached value or undefined if not found + */ + async get(key: string): Promise { + if (this.useRedis()) { + try { + const value = await redisManager.get(key); + + if (value !== null) { + logger.debug(`Cache hit in Redis: ${key}`); + return JSON.parse(value) as T; + } + + logger.debug(`Cache miss in Redis: ${key}`); + return undefined; + } catch (error) { + logger.error(`Redis get error for key ${key}:`, error); + // Fall through to local cache + } + } + + // Use local cache as fallback or primary + const value = localCache.get(key); + if (value !== undefined) { + logger.debug(`Cache hit in local cache: ${key}`); + } else { + logger.debug(`Cache miss in local cache: ${key}`); + } + return value; + } + + /** + * Delete a value from the cache + * @param key - Cache key or array of keys + * @returns Number of deleted entries + */ + async del(key: string | string[]): Promise { + const keys = Array.isArray(key) ? key : [key]; + let deletedCount = 0; + + if (this.useRedis()) { + try { + for (const k of keys) { + const success = await redisManager.del(k); + if (success) { + deletedCount++; + logger.debug(`Deleted key from Redis: ${k}`); + } + } + + if (deletedCount === keys.length) { + return deletedCount; + } + + // Some Redis deletes failed, fall through to local cache + logger.debug( + `Some Redis deletes failed, falling back to local cache` + ); + } catch (error) { + logger.error( + `Redis del error for keys ${keys.join(", ")}:`, + error + ); + // Fall through to local cache + deletedCount = 0; + } + } + + // Use local cache as fallback or primary + for (const k of keys) { + const success = localCache.del(k); + if (success > 0) { + deletedCount++; + logger.debug(`Deleted key from local cache: ${k}`); + } + } + + return deletedCount; + } + + /** + * Check if a key exists in the cache + * @param key - Cache key + * @returns boolean indicating if key exists + */ + async has(key: string): Promise { + if (this.useRedis()) { + try { + const value = await redisManager.get(key); + return value !== null; + } catch (error) { + logger.error(`Redis has error for key ${key}:`, error); + // Fall through to local cache + } + } + + // Use local cache as fallback or primary + return localCache.has(key); + } + + /** + * Get multiple values from the cache + * @param keys - Array of cache keys + * @returns Array of values (undefined for missing keys) + */ + async mget(keys: string[]): Promise<(T | undefined)[]> { + if (this.useRedis()) { + try { + const results: (T | undefined)[] = []; + + for (const key of keys) { + const value = await redisManager.get(key); + if (value !== null) { + results.push(JSON.parse(value) as T); + } else { + results.push(undefined); + } + } + + return results; + } catch (error) { + logger.error(`Redis mget error:`, error); + // Fall through to local cache + } + } + + // Use local cache as fallback or primary + return keys.map((key) => localCache.get(key)); + } + + /** + * Flush all keys from the cache + */ + async flushAll(): Promise { + if (this.useRedis()) { + logger.warn( + "Adaptive cache flushAll called - Redis flush not implemented, only local cache will be flushed" + ); + } + + localCache.flushAll(); + logger.debug("Flushed local cache"); + } + + /** + * Get cache statistics + * Note: Only returns local cache stats, Redis stats are not included + */ + getStats() { + return localCache.getStats(); + } + + /** + * Get the current cache backend being used + * @returns "redis" if Redis is available and healthy, "local" otherwise + */ + getCurrentBackend(): "redis" | "local" { + return this.useRedis() ? "redis" : "local"; + } + + /** + * Take a key from the cache and delete it + * @param key - Cache key + * @returns The value or undefined if not found + */ + async take(key: string): Promise { + const value = await this.get(key); + if (value !== undefined) { + await this.del(key); + } + return value; + } + + /** + * Get TTL (time to live) for a key + * @param key - Cache key + * @returns TTL in seconds, 0 if no expiration, -1 if key doesn't exist + */ + getTtl(key: string): number { + // Note: This only works for local cache, Redis TTL is not supported + if (this.useRedis()) { + logger.warn( + `getTtl called for key ${key} but Redis TTL lookup is not implemented` + ); + } + + const ttl = localCache.getTtl(key); + if (ttl === undefined) { + return -1; + } + return Math.max(0, Math.floor((ttl - Date.now()) / 1000)); + } + + /** + * Get all keys from the cache + * Note: Only returns local cache keys, Redis keys are not included + */ + keys(): string[] { + if (this.useRedis()) { + logger.warn( + "keys() called but Redis keys are not included, only local cache keys returned" + ); + } + return localCache.keys(); + } +} + +// Export singleton instance +export const cache = new AdaptiveCache(); +export default cache; diff --git a/server/private/lib/certificates.ts b/server/private/lib/certificates.ts index 06571cac3..1ec524bb0 100644 --- a/server/private/lib/certificates.ts +++ b/server/private/lib/certificates.ts @@ -15,11 +15,9 @@ import config from "./config"; import { certificates, db } from "@server/db"; import { and, eq, isNotNull, or, inArray, sql } from "drizzle-orm"; import { decryptData } from "@server/lib/encryption"; -import * as fs from "fs"; import logger from "@server/logger"; -import cache from "@server/lib/cache"; +import cache from "#private/lib/cache"; -let encryptionKeyPath = ""; let encryptionKeyHex = ""; let encryptionKey: Buffer; function loadEncryptData() { @@ -27,15 +25,7 @@ function loadEncryptData() { return; // already loaded } - encryptionKeyPath = config.getRawPrivateConfig().server.encryption_key_path; - - if (!fs.existsSync(encryptionKeyPath)) { - throw new Error( - "Encryption key file not found. Please generate one first." - ); - } - - encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim(); + encryptionKeyHex = config.getRawPrivateConfig().server.encryption_key; encryptionKey = Buffer.from(encryptionKeyHex, "hex"); } @@ -64,7 +54,7 @@ export async function getValidCertificatesForDomains( if (useCache) { for (const domain of domains) { const cacheKey = `cert:${domain}`; - const cachedCert = cache.get(cacheKey); + const cachedCert = await cache.get(cacheKey); if (cachedCert) { finalResults.push(cachedCert); // Valid cache hit } else { @@ -178,7 +168,7 @@ export async function getValidCertificatesForDomains( // Add to cache for future requests, using the *requested domain* as the key if (useCache) { const cacheKey = `cert:${domain}`; - cache.set(cacheKey, resultCert, 180); + await cache.set(cacheKey, resultCert, 180); } } } diff --git a/server/private/lib/checkOrgAccessPolicy.ts b/server/private/lib/checkOrgAccessPolicy.ts index 7a78803d5..fee07a62a 100644 --- a/server/private/lib/checkOrgAccessPolicy.ts +++ b/server/private/lib/checkOrgAccessPolicy.ts @@ -13,8 +13,6 @@ import { build } from "@server/build"; import { db, Org, orgs, ResourceSession, sessions, users } from "@server/db"; -import { getOrgTierData } from "#private/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; import license from "#private/license/license"; import { eq } from "drizzle-orm"; import { @@ -23,10 +21,10 @@ import { } from "@server/lib/checkOrgAccessPolicy"; import { UserType } from "@server/types/UserTypes"; -export async function enforceResourceSessionLength( +export function enforceResourceSessionLength( resourceSession: ResourceSession, org: Org -): Promise<{ valid: boolean; error?: string }> { +): { valid: boolean; error?: string } { if (org.maxSessionLengthHours) { const sessionIssuedAt = resourceSession.issuedAt; // may be null const maxSessionLengthHours = org.maxSessionLengthHours; @@ -80,6 +78,8 @@ export async function checkOrgAccessPolicy( } } + // TODO: check that the org is subscribed + // get the needed data if (!props.org) { diff --git a/server/private/lib/config.ts b/server/private/lib/config.ts index 97baf1e05..8e635c93c 100644 --- a/server/private/lib/config.ts +++ b/server/private/lib/config.ts @@ -65,6 +65,11 @@ export class PrivateConfig { this.rawPrivateConfig.branding?.logo?.dark_path || undefined; } + if (this.rawPrivateConfig.app.identity_provider_mode) { + process.env.IDENTITY_PROVIDER_MODE = + this.rawPrivateConfig.app.identity_provider_mode; + } + process.env.BRANDING_LOGO_AUTH_WIDTH = this.rawPrivateConfig.branding ?.logo?.auth_page?.width ? this.rawPrivateConfig.branding?.logo?.auth_page?.width.toString() @@ -125,16 +130,6 @@ export class PrivateConfig { this.rawPrivateConfig.server.reo_client_id; } - if (this.rawPrivateConfig.stripe?.s3Bucket) { - process.env.S3_BUCKET = this.rawPrivateConfig.stripe.s3Bucket; - } - if (this.rawPrivateConfig.stripe?.localFilePath) { - process.env.LOCAL_FILE_PATH = - this.rawPrivateConfig.stripe.localFilePath; - } - if (this.rawPrivateConfig.stripe?.s3Region) { - process.env.S3_REGION = this.rawPrivateConfig.stripe.s3Region; - } if (this.rawPrivateConfig.flags.use_pangolin_dns) { process.env.USE_PANGOLIN_DNS = this.rawPrivateConfig.flags.use_pangolin_dns.toString(); diff --git a/server/private/lib/exitNodes/exitNodeComms.ts b/server/private/lib/exitNodes/exitNodeComms.ts index faf1153f1..2145f32ff 100644 --- a/server/private/lib/exitNodes/exitNodeComms.ts +++ b/server/private/lib/exitNodes/exitNodeComms.ts @@ -50,10 +50,14 @@ export async function sendToExitNode( ); } - return sendToClient(remoteExitNode.remoteExitNodeId, { - type: request.remoteType, - data: request.data - }); + return sendToClient( + remoteExitNode.remoteExitNodeId, + { + type: request.remoteType, + data: request.data + }, + { incrementConfigVersion: true } + ); } else { let hostname = exitNode.reachableAt; diff --git a/server/private/lib/exitNodes/exitNodes.ts b/server/private/lib/exitNodes/exitNodes.ts index 556fdcf79..97c896140 100644 --- a/server/private/lib/exitNodes/exitNodes.ts +++ b/server/private/lib/exitNodes/exitNodes.ts @@ -288,7 +288,7 @@ export function selectBestExitNode( const validNodes = pingResults.filter((n) => !n.error && n.weight > 0); if (validNodes.length === 0) { - logger.error("No valid exit nodes available"); + logger.debug("No valid exit nodes available"); return null; } diff --git a/server/private/lib/isLicencedOrSubscribed.ts b/server/private/lib/isLicencedOrSubscribed.ts new file mode 100644 index 000000000..d6063c6c0 --- /dev/null +++ b/server/private/lib/isLicencedOrSubscribed.ts @@ -0,0 +1,32 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { build } from "@server/build"; +import license from "#private/license/license"; +import { isSubscribed } from "#private/lib/isSubscribed"; +import { Tier } from "@server/types/Tiers"; + +export async function isLicensedOrSubscribed( + orgId: string, + tiers: Tier[] +): Promise { + if (build === "enterprise") { + return await license.isUnlocked(); + } + + if (build === "saas") { + return isSubscribed(orgId, tiers); + } + + return false; +} diff --git a/server/private/lib/isSubscribed.ts b/server/private/lib/isSubscribed.ts new file mode 100644 index 000000000..e6e4c877f --- /dev/null +++ b/server/private/lib/isSubscribed.ts @@ -0,0 +1,29 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { build } from "@server/build"; +import { getOrgTierData } from "#private/lib/billing"; +import { Tier } from "@server/types/Tiers"; + +export async function isSubscribed( + orgId: string, + tiers: Tier[] +): Promise { + if (build === "saas") { + const { tier, active } = await getOrgTierData(orgId); + const isTier = (tier && tiers.includes(tier)) || false; + return active && isTier; + } + + return false; +} diff --git a/server/private/lib/lock.ts b/server/private/lib/lock.ts index 08496f655..4462a454b 100644 --- a/server/private/lib/lock.ts +++ b/server/private/lib/lock.ts @@ -14,6 +14,9 @@ import { config } from "@server/lib/config"; import logger from "@server/logger"; import { redis } from "#private/lib/redis"; +import { v4 as uuidv4 } from "uuid"; + +const instanceId = uuidv4(); export class LockManager { /** @@ -24,60 +27,80 @@ export class LockManager { */ async acquireLock( lockKey: string, - ttlMs: number = 30000 + ttlMs: number = 30000, + maxRetries: number = 3, + retryDelayMs: number = 100 ): Promise { if (!redis || !redis.status || redis.status !== "ready") { return true; } const lockValue = `${ - config.getRawConfig().gerbil.exit_node_name + instanceId }:${Date.now()}`; const redisKey = `lock:${lockKey}`; - try { - // Use SET with NX (only set if not exists) and PX (expire in milliseconds) - // This is atomic and handles both setting and expiration - const result = await redis.set( - redisKey, - lockValue, - "PX", - ttlMs, - "NX" - ); - - if (result === "OK") { - logger.debug( - `Lock acquired: ${lockKey} by ${ - config.getRawConfig().gerbil.exit_node_name - }` + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + // Use SET with NX (only set if not exists) and PX (expire in milliseconds) + // This is atomic and handles both setting and expiration + const result = await redis.set( + redisKey, + lockValue, + "PX", + ttlMs, + "NX" ); - return true; - } - // Check if the existing lock is from this worker (reentrant behavior) - const existingValue = await redis.get(redisKey); - if ( - existingValue && - existingValue.startsWith( - `${config.getRawConfig().gerbil.exit_node_name}:` - ) - ) { - // Extend the lock TTL since it's the same worker - await redis.pexpire(redisKey, ttlMs); - logger.debug( - `Lock extended: ${lockKey} by ${ - config.getRawConfig().gerbil.exit_node_name - }` - ); - return true; - } + if (result === "OK") { + logger.debug( + `Lock acquired: ${lockKey} by ${ + instanceId + }` + ); + return true; + } - return false; - } catch (error) { - logger.error(`Failed to acquire lock ${lockKey}:`, error); - return false; + // Check if the existing lock is from this worker (reentrant behavior) + const existingValue = await redis.get(redisKey); + if ( + existingValue && + existingValue.startsWith( + `${instanceId}:` + ) + ) { + // Extend the lock TTL since it's the same worker + await redis.pexpire(redisKey, ttlMs); + logger.debug( + `Lock extended: ${lockKey} by ${ + instanceId + }` + ); + return true; + } + + // If this isn't our last attempt, wait before retrying with exponential backoff + if (attempt < maxRetries - 1) { + const delay = retryDelayMs * Math.pow(2, attempt); + logger.debug( + `Lock ${lockKey} not available, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})` + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } catch (error) { + logger.error(`Failed to acquire lock ${lockKey} (attempt ${attempt + 1}/${maxRetries}):`, error); + // On error, still retry if we have attempts left + if (attempt < maxRetries - 1) { + const delay = retryDelayMs * Math.pow(2, attempt); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } } + + logger.debug( + `Failed to acquire lock ${lockKey} after ${maxRetries} attempts` + ); + return false; } /** @@ -96,7 +119,7 @@ export class LockManager { local key = KEYS[1] local worker_prefix = ARGV[1] local current_value = redis.call('GET', key) - + if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then return redis.call('DEL', key) else @@ -109,19 +132,19 @@ export class LockManager { luaScript, 1, redisKey, - `${config.getRawConfig().gerbil.exit_node_name}:` + `${instanceId}:` )) as number; if (result === 1) { logger.debug( `Lock released: ${lockKey} by ${ - config.getRawConfig().gerbil.exit_node_name + instanceId }` ); } else { logger.warn( `Lock not released - not owned by worker: ${lockKey} by ${ - config.getRawConfig().gerbil.exit_node_name + instanceId }` ); } @@ -178,7 +201,7 @@ export class LockManager { const ownedByMe = exists && value!.startsWith( - `${config.getRawConfig().gerbil.exit_node_name}:` + `${instanceId}:` ); const owner = exists ? value!.split(":")[0] : undefined; @@ -213,7 +236,7 @@ export class LockManager { local worker_prefix = ARGV[1] local ttl = tonumber(ARGV[2]) local current_value = redis.call('GET', key) - + if current_value and string.find(current_value, worker_prefix, 1, true) == 1 then return redis.call('PEXPIRE', key, ttl) else @@ -226,14 +249,14 @@ export class LockManager { luaScript, 1, redisKey, - `${config.getRawConfig().gerbil.exit_node_name}:`, + `${instanceId}:`, ttlMs.toString() )) as number; if (result === 1) { logger.debug( `Lock extended: ${lockKey} by ${ - config.getRawConfig().gerbil.exit_node_name + instanceId } for ${ttlMs}ms` ); return true; @@ -336,7 +359,7 @@ export class LockManager { (value) => value && value.startsWith( - `${config.getRawConfig().gerbil.exit_node_name}:` + `${instanceId}:` ) ).length; } diff --git a/server/private/lib/logAccessAudit.ts b/server/private/lib/logAccessAudit.ts index 98eaa6ec4..e56490795 100644 --- a/server/private/lib/logAccessAudit.ts +++ b/server/private/lib/logAccessAudit.ts @@ -11,16 +11,17 @@ * This file is not licensed under the AGPLv3. */ -import { accessAuditLog, db, orgs } from "@server/db"; +import { accessAuditLog, logsDb, db, orgs } from "@server/db"; import { getCountryCodeForIp } from "@server/lib/geoip"; import logger from "@server/logger"; import { and, eq, lt } from "drizzle-orm"; -import cache from "@server/lib/cache"; +import cache from "#private/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; +import { stripPortFromHost } from "@server/lib/ip"; async function getAccessDays(orgId: string): Promise { // check cache first - const cached = cache.get(`org_${orgId}_accessDays`); + const cached = await cache.get(`org_${orgId}_accessDays`); if (cached !== undefined) { return cached; } @@ -38,7 +39,7 @@ async function getAccessDays(orgId: string): Promise { } // store the result in cache - cache.set( + await cache.set( `org_${orgId}_accessDays`, org.settingsLogRetentionDaysAction, 300 @@ -51,7 +52,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) { const cutoffTimestamp = calculateCutoffTimestamp(retentionDays); try { - await db + await logsDb .delete(accessAuditLog) .where( and( @@ -73,6 +74,7 @@ export async function logAccessAudit(data: { type: string; orgId: string; resourceId?: number; + siteResourceId?: number; user?: { username: string; userId: string }; apiKey?: { name: string | null; apiKeyId: string }; metadata?: any; @@ -116,26 +118,14 @@ export async function logAccessAudit(data: { } const clientIp = data.requestIp - ? (() => { - if ( - data.requestIp.startsWith("[") && - data.requestIp.includes("]") - ) { - // if brackets are found, extract the IPv6 address from between the brackets - const ipv6Match = data.requestIp.match(/\[(.*?)\]/); - if (ipv6Match) { - return ipv6Match[1]; - } - } - return data.requestIp; - })() + ? stripPortFromHost(data.requestIp) : undefined; const countryCode = data.requestIp ? await getCountryCodeFromIp(data.requestIp) : undefined; - await db.insert(accessAuditLog).values({ + await logsDb.insert(accessAuditLog).values({ timestamp: timestamp, orgId: data.orgId, actorType, @@ -145,6 +135,7 @@ export async function logAccessAudit(data: { type: data.type, metadata, resourceId: data.resourceId, + siteResourceId: data.siteResourceId, userAgent: data.userAgent, ip: clientIp, location: countryCode @@ -157,12 +148,15 @@ export async function logAccessAudit(data: { async function getCountryCodeFromIp(ip: string): Promise { const geoIpCacheKey = `geoip_access:${ip}`; - let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey); + let cachedCountryCode: string | undefined = await cache.get(geoIpCacheKey); if (!cachedCountryCode) { cachedCountryCode = await getCountryCodeForIp(ip); // do it locally - // Cache for longer since IP geolocation doesn't change frequently - cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes + // Only cache successful lookups to avoid filling cache with undefined values + if (cachedCountryCode) { + // Cache for longer since IP geolocation doesn't change frequently + await cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes + } } return cachedCountryCode; diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index c986e62d1..54260009b 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -17,6 +17,7 @@ import { privateConfigFilePath1 } from "@server/lib/consts"; import { z } from "zod"; import { colorsSchema } from "@server/lib/colorsSchema"; import { build } from "@server/build"; +import { getEnvOrYaml } from "@server/lib/getEnvOrYaml"; const portSchema = z.number().positive().gt(0).lte(65535); @@ -24,7 +25,8 @@ export const privateConfigSchema = z.object({ app: z .object({ region: z.string().optional().default("default"), - base_domain: z.string().optional() + base_domain: z.string().optional(), + identity_provider_mode: z.enum(["global", "org"]).optional() }) .optional() .default({ @@ -32,24 +34,33 @@ export const privateConfigSchema = z.object({ }), server: z .object({ - encryption_key_path: z + encryption_key: z .string() .optional() - .default("./config/encryption.pem") - .pipe(z.string().min(8)), - resend_api_key: z.string().optional(), - reo_client_id: z.string().optional(), - fossorial_api_key: z.string().optional() + .transform(getEnvOrYaml("SERVER_ENCRYPTION_KEY")), + reo_client_id: z + .string() + .optional() + .transform(getEnvOrYaml("REO_CLIENT_ID")), + fossorial_api: z + .string() + .optional() + .default("https://api.fossorial.io"), + fossorial_api_key: z + .string() + .optional() + .transform(getEnvOrYaml("FOSSORIAL_API_KEY")) }) .optional() - .default({ - encryption_key_path: "./config/encryption.pem" - }), + .prefault({}), redis: z .object({ host: z.string(), port: portSchema, - password: z.string().optional(), + password: z + .string() + .optional() + .transform(getEnvOrYaml("REDIS_PASSWORD")), db: z.int().nonnegative().optional().default(0), replicas: z .array( @@ -60,15 +71,15 @@ export const privateConfigSchema = z.object({ db: z.int().nonnegative().optional().default(0) }) ) + .optional(), + tls: z + .object({ + rejectUnauthorized: z + .boolean() + .optional() + .default(true) + }) .optional() - // tls: z - // .object({ - // reject_unauthorized: z - // .boolean() - // .optional() - // .default(true) - // }) - // .optional() }) .optional(), gerbil: z @@ -83,7 +94,8 @@ export const privateConfigSchema = z.object({ flags: z .object({ enable_redis: z.boolean().optional().default(false), - use_pangolin_dns: z.boolean().optional().default(false) + use_pangolin_dns: z.boolean().optional().default(false), + use_org_only_idp: z.boolean().optional() }) .optional() .prefault({}), @@ -156,14 +168,42 @@ export const privateConfigSchema = z.object({ .optional(), stripe: z .object({ - secret_key: z.string(), - webhook_secret: z.string(), - s3Bucket: z.string(), - s3Region: z.string().default("us-east-1"), - localFilePath: z.string() + secret_key: z + .string() + .optional() + .transform(getEnvOrYaml("STRIPE_SECRET_KEY")), + webhook_secret: z + .string() + .optional() + .transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")), + // s3Bucket: z.string(), + // s3Region: z.string().default("us-east-1"), + // localFilePath: z.string().optional() }) .optional() -}); +}) + .transform((data) => { + // this to maintain backwards compatibility with the old config file + const identityProviderMode = data.app?.identity_provider_mode; + const useOrgOnlyIdp = data.flags?.use_org_only_idp; + + if (identityProviderMode !== undefined) { + return data; + } + if (useOrgOnlyIdp === true) { + return { + ...data, + app: { ...data.app, identity_provider_mode: "org" as const } + }; + } + if (useOrgOnlyIdp === false) { + return { + ...data, + app: { ...data.app, identity_provider_mode: "global" as const } + }; + } + return data; + }); export function readPrivateConfigFile() { if (build == "oss") { diff --git a/server/private/lib/redis.ts b/server/private/lib/redis.ts index 6b7826ea6..69f563b44 100644 --- a/server/private/lib/redis.ts +++ b/server/private/lib/redis.ts @@ -108,11 +108,15 @@ class RedisManager { port: redisConfig.port!, password: redisConfig.password, db: redisConfig.db - // tls: { - // rejectUnauthorized: - // redisConfig.tls?.reject_unauthorized || false - // } }; + + // Enable TLS if configured (required for AWS ElastiCache in-transit encryption) + if (redisConfig.tls) { + opts.tls = { + rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true + }; + } + return opts; } @@ -130,11 +134,15 @@ class RedisManager { port: replica.port!, password: replica.password, db: replica.db || redisConfig.db - // tls: { - // rejectUnauthorized: - // replica.tls?.reject_unauthorized || false - // } }; + + // Enable TLS if configured (required for AWS ElastiCache in-transit encryption) + if (redisConfig.tls) { + opts.tls = { + rejectUnauthorized: redisConfig.tls.rejectUnauthorized ?? true + }; + } + return opts; } @@ -573,6 +581,20 @@ class RedisManager { } } + public async incr(key: string): Promise { + if (!this.isRedisEnabled() || !this.writeClient) return 0; + + try { + return await this.executeWithRetry( + () => this.writeClient!.incr(key), + "Redis INCR" + ); + } catch (error) { + logger.error("Redis INCR error:", error); + return 0; + } + } + public async sadd(key: string, member: string): Promise { if (!this.isRedisEnabled() || !this.writeClient) return false; diff --git a/server/private/lib/resend.ts b/server/private/lib/resend.ts deleted file mode 100644 index 42a11c152..000000000 --- a/server/private/lib/resend.ts +++ /dev/null @@ -1,127 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - -import { Resend } from "resend"; -import privateConfig from "#private/lib/config"; -import logger from "@server/logger"; - -export enum AudienceIds { - SignUps = "6c4e77b2-0851-4bd6-bac8-f51f91360f1a", - Subscribed = "870b43fd-387f-44de-8fc1-707335f30b20", - Churned = "f3ae92bd-2fdb-4d77-8746-2118afd62549", - Newsletter = "5500c431-191c-42f0-a5d4-8b6d445b4ea0" -} - -const resend = new Resend( - privateConfig.getRawPrivateConfig().server.resend_api_key || "missing" -); - -export default resend; - -export async function moveEmailToAudience( - email: string, - audienceId: AudienceIds -) { - if (process.env.ENVIRONMENT !== "prod") { - logger.debug( - `Skipping moving email ${email} to audience ${audienceId} in non-prod environment` - ); - return; - } - const { error, data } = await retryWithBackoff(async () => { - const { data, error } = await resend.contacts.create({ - email, - unsubscribed: false, - audienceId - }); - if (error) { - throw new Error( - `Error adding email ${email} to audience ${audienceId}: ${error}` - ); - } - return { error, data }; - }); - - if (error) { - logger.error( - `Error adding email ${email} to audience ${audienceId}: ${error}` - ); - return; - } - - if (data) { - logger.debug( - `Added email ${email} to audience ${audienceId} with contact ID ${data.id}` - ); - } - - const otherAudiences = Object.values(AudienceIds).filter( - (id) => id !== audienceId - ); - - for (const otherAudienceId of otherAudiences) { - const { error, data } = await retryWithBackoff(async () => { - const { data, error } = await resend.contacts.remove({ - email, - audienceId: otherAudienceId - }); - if (error) { - throw new Error( - `Error removing email ${email} from audience ${otherAudienceId}: ${error}` - ); - } - return { error, data }; - }); - - if (error) { - logger.error( - `Error removing email ${email} from audience ${otherAudienceId}: ${error}` - ); - } - - if (data) { - logger.info( - `Removed email ${email} from audience ${otherAudienceId}` - ); - } - } -} - -type RetryOptions = { - retries?: number; - initialDelayMs?: number; - factor?: number; -}; - -export async function retryWithBackoff( - fn: () => Promise, - options: RetryOptions = {} -): Promise { - const { retries = 5, initialDelayMs = 500, factor = 2 } = options; - - let attempt = 0; - let delay = initialDelayMs; - - while (true) { - try { - return await fn(); - } catch (err) { - attempt++; - - if (attempt > retries) throw err; - - await new Promise((resolve) => setTimeout(resolve, delay)); - delay *= factor; - } - } -} diff --git a/server/private/lib/tokenCache.ts b/server/private/lib/tokenCache.ts new file mode 100644 index 000000000..284f1d698 --- /dev/null +++ b/server/private/lib/tokenCache.ts @@ -0,0 +1,77 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import redisManager from "#private/lib/redis"; +import { encrypt, decrypt } from "@server/lib/crypto"; +import logger from "@server/logger"; + +/** + * Returns a cached plaintext token from Redis if one exists and decrypts + * cleanly, otherwise calls `createSession` to mint a fresh token, stores the + * encrypted value in Redis with the given TTL, and returns it. + * + * Failures at the Redis layer are non-fatal – the function always falls + * through to session creation so the caller is never blocked by a Redis outage. + * + * @param cacheKey Unique Redis key, e.g. `"newt:token_cache:abc123"` + * @param secret Server secret used for AES encryption/decryption + * @param ttlSeconds Cache TTL in seconds (should match session expiry) + * @param createSession Factory that mints a new session and returns its raw token + */ +export async function getOrCreateCachedToken( + cacheKey: string, + secret: string, + ttlSeconds: number, + createSession: () => Promise +): Promise { + if (redisManager.isRedisEnabled()) { + try { + const cached = await redisManager.get(cacheKey); + if (cached) { + const token = decrypt(cached, secret); + if (token) { + logger.debug(`Token cache hit for key: ${cacheKey}`); + return token; + } + // Decryption produced an empty string – treat as a miss + logger.warn( + `Token cache decryption returned empty string for key: ${cacheKey}, treating as miss` + ); + } + } catch (e) { + logger.warn( + `Token cache read/decrypt failed for key ${cacheKey}, falling through to session creation:`, + e + ); + } + } + + const token = await createSession(); + + if (redisManager.isRedisEnabled()) { + try { + const encrypted = encrypt(token, secret); + await redisManager.set(cacheKey, encrypted, ttlSeconds); + logger.debug( + `Token cached in Redis for key: ${cacheKey} (TTL ${ttlSeconds}s)` + ); + } catch (e) { + logger.warn( + `Token cache write failed for key ${cacheKey} (session was still created):`, + e + ); + } + } + + return token; +} diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 8060ccad2..7fc0ae647 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -34,7 +34,11 @@ import { import logger from "@server/logger"; import config from "@server/lib/config"; import { orgs, resources, sites, Target, targets } from "@server/db"; -import { sanitize, validatePathRewriteConfig } from "@server/lib/traefik/utils"; +import { + sanitize, + encodePath, + validatePathRewriteConfig +} from "@server/lib/traefik/utils"; import privateConfig from "#private/lib/config"; import createPathRewriteMiddleware from "@server/lib/traefik/middleware"; import { @@ -47,24 +51,33 @@ const redirectHttpsMiddlewareName = "redirect-to-https"; const redirectToRootMiddlewareName = "redirect-to-root"; const badgerMiddlewareName = "badger"; +// Define extended target type with site information +type TargetWithSite = Target & { + resourceId: number; + targetId: number; + ip: string | null; + method: string | null; + port: number | null; + internalPort: number | null; + enabled: boolean; + health: string | null; + site: { + siteId: number; + type: string; + subnet: string | null; + exitNodeId: number | null; + online: boolean; + }; +}; + export async function getTraefikConfig( exitNodeId: number, siteTypes: string[], filterOutNamespaceDomains = false, generateLoginPageRouters = false, - allowRawResources = true + allowRawResources = true, + allowMaintenancePage = true ): Promise { - // Define extended target type with site information - type TargetWithSite = Target & { - site: { - siteId: number; - type: string; - subnet: string | null; - exitNodeId: number | null; - online: boolean; - }; - }; - // Get resources with their targets and sites in a single optimized query // Start from sites on this exit node, then join to targets and resources const resourcesWithTargetsAndSites = await db @@ -87,6 +100,13 @@ export async function getTraefikConfig( headers: resources.headers, proxyProtocol: resources.proxyProtocol, proxyProtocolVersion: resources.proxyProtocolVersion, + + maintenanceModeEnabled: resources.maintenanceModeEnabled, + maintenanceModeType: resources.maintenanceModeType, + maintenanceTitle: resources.maintenanceTitle, + maintenanceMessage: resources.maintenanceMessage, + maintenanceEstimatedTime: resources.maintenanceEstimatedTime, + // Target fields targetId: targets.targetId, targetEnabled: targets.enabled, @@ -140,10 +160,6 @@ export async function getTraefikConfig( sql`(${build != "saas" ? 1 : 0} = 1)` // Dont allow undefined local sites in cloud ) ), - or( - ne(targetHealthCheck.hcHealth, "unhealthy"), // Exclude unhealthy targets - isNull(targetHealthCheck.hcHealth) // Include targets with no health check record - ), inArray(sites.type, siteTypes), allowRawResources ? isNotNull(resources.http) // ignore the http check if allow_raw_resources is true @@ -158,7 +174,7 @@ export async function getTraefikConfig( resourcesWithTargetsAndSites.forEach((row) => { const resourceId = row.resourceId; const resourceName = sanitize(row.resourceName) || ""; - const targetPath = sanitize(row.path) || ""; // Handle null/undefined paths + const targetPath = encodePath(row.path); // Use encodePath to avoid collisions (e.g. "/a/b" vs "/a-b") const pathMatchType = row.pathMatchType || ""; const rewritePath = row.rewritePath || ""; const rewritePathType = row.rewritePathType || ""; @@ -180,7 +196,7 @@ export async function getTraefikConfig( const mapKey = [resourceId, pathKey].filter(Boolean).join("-"); const key = sanitize(mapKey); - if (!resourcesMap.has(key)) { + if (!resourcesMap.has(mapKey)) { const validation = validatePathRewriteConfig( row.path, row.pathMatchType, @@ -195,9 +211,10 @@ export async function getTraefikConfig( return; } - resourcesMap.set(key, { + resourcesMap.set(mapKey, { resourceId: row.resourceId, name: resourceName, + key: key, fullDomain: row.fullDomain, ssl: row.ssl, http: row.http, @@ -220,12 +237,18 @@ export async function getTraefikConfig( rewritePathType: row.rewritePathType, priority: priority, // may be null, we fallback later domainCertResolver: row.domainCertResolver, - preferWildcardCert: row.preferWildcardCert + preferWildcardCert: row.preferWildcardCert, + + maintenanceModeEnabled: row.maintenanceModeEnabled, + maintenanceModeType: row.maintenanceModeType, + maintenanceTitle: row.maintenanceTitle, + maintenanceMessage: row.maintenanceMessage, + maintenanceEstimatedTime: row.maintenanceEstimatedTime }); } // Add target with its associated site data - resourcesMap.get(key).targets.push({ + resourcesMap.get(mapKey).targets.push({ resourceId: row.resourceId, targetId: row.targetId, ip: row.ip, @@ -233,6 +256,7 @@ export async function getTraefikConfig( port: row.port, internalPort: row.internalPort, enabled: row.targetEnabled, + health: row.hcHealth, site: { siteId: row.siteId, type: row.siteType, @@ -277,8 +301,9 @@ export async function getTraefikConfig( }; // get the key and the resource - for (const [key, resource] of resourcesMap.entries()) { - const targets = resource.targets; + for (const [, resource] of resourcesMap.entries()) { + const targets = resource.targets as TargetWithSite[]; + const key = resource.key; const routerName = `${key}-${resource.name}-router`; const serviceName = `${key}-${resource.name}-service`; @@ -308,20 +333,37 @@ export async function getTraefikConfig( config_output.http.services = {}; } - const domainParts = fullDomain.split("."); - let wildCard; - if (domainParts.length <= 2) { - wildCard = `*.${domainParts.join(".")}`; + const additionalMiddlewares = + config.getRawConfig().traefik.additional_middlewares || []; + + const routerMiddlewares = [ + badgerMiddlewareName, + ...additionalMiddlewares + ]; + + let rule = `Host(\`${fullDomain}\`)`; + + // priority logic + let priority: number; + if (resource.priority && resource.priority != 100) { + priority = resource.priority; } else { - wildCard = `*.${domainParts.slice(1).join(".")}`; + priority = 100; + if (resource.path && resource.pathMatchType) { + priority += 10; + if (resource.pathMatchType === "exact") { + priority += 5; + } else if (resource.pathMatchType === "prefix") { + priority += 3; + } else if (resource.pathMatchType === "regex") { + priority += 2; + } + if (resource.path === "/") { + priority = 1; // lowest for catch-all + } + } } - if (!resource.subdomain) { - wildCard = resource.fullDomain; - } - - const configDomain = config.getDomain(resource.domainId); - let tls = {}; if (!privateConfig.getRawPrivateConfig().flags.use_pangolin_dns) { const domainParts = fullDomain.split("."); @@ -387,13 +429,117 @@ export async function getTraefikConfig( } } - const additionalMiddlewares = - config.getRawConfig().traefik.additional_middlewares || []; + if (resource.ssl) { + config_output.http.routers![routerName + "-redirect"] = { + entryPoints: [ + config.getRawConfig().traefik.http_entrypoint + ], + middlewares: [redirectHttpsMiddlewareName], + service: serviceName, + rule: rule, + priority: priority + }; + } - const routerMiddlewares = [ - badgerMiddlewareName, - ...additionalMiddlewares - ]; + const availableServers = targets.filter((target) => { + if (!target.enabled) return false; + + if (!target.site.online) return false; + + if (target.health == "unhealthy") return false; + + return true; + }); + + const hasHealthyServers = availableServers.length > 0; + + let showMaintenancePage = false; + if (resource.maintenanceModeEnabled) { + if (resource.maintenanceModeType === "forced") { + showMaintenancePage = true; + // logger.debug( + // `Resource ${resource.name} (${fullDomain}) is in FORCED maintenance mode` + // ); + } else if (resource.maintenanceModeType === "automatic") { + showMaintenancePage = !hasHealthyServers; + // if (showMaintenancePage) { + // logger.warn( + // `Resource ${resource.name} (${fullDomain}) has no healthy servers - showing maintenance page (AUTOMATIC mode)` + // ); + // } + } + } + + if (showMaintenancePage && allowMaintenancePage) { + const maintenanceServiceName = `${key}-maintenance-service`; + const maintenanceRouterName = `${key}-maintenance-router`; + const rewriteMiddlewareName = `${key}-maintenance-rewrite`; + + const entrypointHttp = + config.getRawConfig().traefik.http_entrypoint; + const entrypointHttps = + config.getRawConfig().traefik.https_entrypoint; + + const fullDomain = resource.fullDomain; + const domainParts = fullDomain.split("."); + const wildCard = resource.subdomain + ? `*.${domainParts.slice(1).join(".")}` + : fullDomain; + + const maintenancePort = config.getRawConfig().server.next_port; + const maintenanceHost = + config.getRawConfig().server.internal_hostname; + + config_output.http.services[maintenanceServiceName] = { + loadBalancer: { + servers: [ + { + url: `http://${maintenanceHost}:${maintenancePort}` + } + ], + passHostHeader: true + } + }; + + // middleware to rewrite path to /maintenance-screen + if (!config_output.http.middlewares) { + config_output.http.middlewares = {}; + } + + config_output.http.middlewares[rewriteMiddlewareName] = { + replacePathRegex: { + regex: "^/(.*)", + replacement: "/maintenance-screen" + } + }; + + config_output.http.routers[maintenanceRouterName] = { + entryPoints: [ + resource.ssl ? entrypointHttps : entrypointHttp + ], + service: maintenanceServiceName, + middlewares: [rewriteMiddlewareName], + rule: rule, + priority: 2000, + ...(resource.ssl ? { tls } : {}) + }; + + // Router to allow Next.js assets to load without rewrite + config_output.http.routers[`${maintenanceRouterName}-assets`] = + { + entryPoints: [ + resource.ssl ? entrypointHttps : entrypointHttp + ], + service: maintenanceServiceName, + rule: `Host(\`${fullDomain}\`) && (PathPrefix(\`/_next\`) || PathRegexp(\`^/__nextjs*\`))`, + priority: 2001, + ...(resource.ssl ? { tls } : {}) + }; + + // logger.info(`Maintenance mode active for ${fullDomain}`); + + continue; + } // Handle path rewriting middleware if ( @@ -485,29 +631,6 @@ export async function getTraefikConfig( } } - let rule = `Host(\`${fullDomain}\`)`; - - // priority logic - let priority: number; - if (resource.priority && resource.priority != 100) { - priority = resource.priority; - } else { - priority = 100; - if (resource.path && resource.pathMatchType) { - priority += 10; - if (resource.pathMatchType === "exact") { - priority += 5; - } else if (resource.pathMatchType === "prefix") { - priority += 3; - } else if (resource.pathMatchType === "regex") { - priority += 2; - } - if (resource.path === "/") { - priority = 1; // lowest for catch-all - } - } - } - if (resource.path && resource.pathMatchType) { //priority += 1; // add path to rule based on match type @@ -538,18 +661,6 @@ export async function getTraefikConfig( ...(resource.ssl ? { tls } : {}) }; - if (resource.ssl) { - config_output.http.routers![routerName + "-redirect"] = { - entryPoints: [ - config.getRawConfig().traefik.http_entrypoint - ], - middlewares: [redirectHttpsMiddlewareName], - service: serviceName, - rule: rule, - priority: priority - }; - } - config_output.http.services![serviceName] = { loadBalancer: { servers: (() => { @@ -559,17 +670,24 @@ export async function getTraefikConfig( // RECEIVE BANDWIDTH ENDPOINT. // TODO: HOW TO HANDLE ^^^^^^ BETTER - const anySitesOnline = ( - targets as TargetWithSite[] - ).some((target: TargetWithSite) => target.site.online); + const anySitesOnline = targets.some( + (target) => + target.site.online || + target.site.type === "local" || + target.site.type === "wireguard" + ); return ( - (targets as TargetWithSite[]) - .filter((target: TargetWithSite) => { + targets + .filter((target) => { if (!target.enabled) { return false; } + if (target.health == "unhealthy") { + return false; + } + // If any sites are online, exclude offline sites if (anySitesOnline && !target.site.online) { return false; @@ -597,7 +715,7 @@ export async function getTraefikConfig( } return true; }) - .map((target: TargetWithSite) => { + .map((target) => { if ( target.site.type === "local" || target.site.type === "wireguard" @@ -683,12 +801,15 @@ export async function getTraefikConfig( loadBalancer: { servers: (() => { // Check if any sites are online - const anySitesOnline = ( - targets as TargetWithSite[] - ).some((target: TargetWithSite) => target.site.online); + const anySitesOnline = targets.some( + (target) => + target.site.online || + target.site.type === "local" || + target.site.type === "wireguard" + ); - return (targets as TargetWithSite[]) - .filter((target: TargetWithSite) => { + return targets + .filter((target) => { if (!target.enabled) { return false; } @@ -715,7 +836,7 @@ export async function getTraefikConfig( } return true; }) - .map((target: TargetWithSite) => { + .map((target) => { if ( target.site.type === "local" || target.site.type === "wireguard" @@ -823,7 +944,7 @@ export async function getTraefikConfig( (cert) => cert.queriedDomain === lp.fullDomain ); if (!matchingCert) { - logger.warn( + logger.debug( `No matching certificate found for login page domain: ${lp.fullDomain}` ); continue; diff --git a/server/private/license/license.ts b/server/private/license/license.ts index f8f774c68..972dbc82f 100644 --- a/server/private/license/license.ts +++ b/server/private/license/license.ts @@ -11,12 +11,12 @@ * This file is not licensed under the AGPLv3. */ -import { db, HostMeta } from "@server/db"; +import { db, HostMeta, sites, users } from "@server/db"; import { hostMeta, licenseKey } from "@server/db"; import logger from "@server/logger"; import NodeCache from "node-cache"; import { validateJWT } from "./licenseJwt"; -import { eq } from "drizzle-orm"; +import { count, eq } from "drizzle-orm"; import moment from "moment"; import { encrypt, decrypt } from "@server/lib/crypto"; import { @@ -54,6 +54,7 @@ type TokenPayload = { type: LicenseKeyType; tier: LicenseKeyTier; quantity: number; + quantity_2: number; terminateAt: string; // ISO iat: number; // Issued at }; @@ -140,10 +141,20 @@ LQIDAQAB }; } + // Count used sites and users for license comparison + const [siteCountRes] = await db + .select({ value: count() }) + .from(sites); + const [userCountRes] = await db + .select({ value: count() }) + .from(users); + const status: LicenseStatus = { hostId: this.hostMeta.hostMetaId, isHostLicensed: true, - isLicenseValid: false + isLicenseValid: false, + usedSites: siteCountRes?.value ?? 0, + usedUsers: userCountRes?.value ?? 0 }; this.checkInProgress = true; @@ -151,6 +162,8 @@ LQIDAQAB try { if (!this.doRecheck && this.statusCache.has(this.statusKey)) { const res = this.statusCache.get("status") as LicenseStatus; + res.usedSites = status.usedSites; + res.usedUsers = status.usedUsers; return res; } logger.debug("Checking license status..."); @@ -193,7 +206,9 @@ LQIDAQAB type: payload.type, tier: payload.tier, iat: new Date(payload.iat * 1000), - terminateAt: new Date(payload.terminateAt) + terminateAt: new Date(payload.terminateAt), + quantity: payload.quantity, + quantity_2: payload.quantity_2 }); if (payload.type === "host") { @@ -292,6 +307,8 @@ LQIDAQAB cached.tier = payload.tier; cached.iat = new Date(payload.iat * 1000); cached.terminateAt = new Date(payload.terminateAt); + cached.quantity = payload.quantity; + cached.quantity_2 = payload.quantity_2; // Encrypt the updated token before storing const encryptedKey = encrypt( @@ -317,7 +334,7 @@ LQIDAQAB } } - // Compute host status + // Compute host status: quantity = users, quantity_2 = sites for (const key of keys) { const cached = newCache.get(key.licenseKey)!; @@ -329,6 +346,28 @@ LQIDAQAB if (!cached.valid) { continue; } + + // Only consider quantity if defined and >= 0 (quantity = users, quantity_2 = sites) + if ( + cached.quantity_2 !== undefined && + cached.quantity_2 >= 0 + ) { + status.maxSites = + (status.maxSites ?? 0) + cached.quantity_2; + } + if (cached.quantity !== undefined && cached.quantity >= 0) { + status.maxUsers = (status.maxUsers ?? 0) + cached.quantity; + } + } + + // Invalidate license if over user or site limits + if ( + (status.maxSites !== undefined && + (status.usedSites ?? 0) > status.maxSites) || + (status.maxUsers !== undefined && + (status.usedUsers ?? 0) > status.maxUsers) + ) { + status.isLicenseValid = false; } // Invalidate old cache and set new cache @@ -502,7 +541,7 @@ LQIDAQAB // Calculate exponential backoff delay const retryDelay = Math.floor( initialRetryDelay * - Math.pow(exponentialFactor, attempt - 1) + Math.pow(exponentialFactor, attempt - 1) ); logger.debug( diff --git a/server/private/middlewares/logActionAudit.ts b/server/private/middlewares/logActionAudit.ts index 17cc67c08..f62f43d3a 100644 --- a/server/private/middlewares/logActionAudit.ts +++ b/server/private/middlewares/logActionAudit.ts @@ -12,18 +12,18 @@ */ import { ActionsEnum } from "@server/auth/actions"; -import { actionAuditLog, db, orgs } from "@server/db"; +import { actionAuditLog, logsDb, db, orgs } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; import { Request, Response, NextFunction } from "express"; import createHttpError from "http-errors"; import { and, eq, lt } from "drizzle-orm"; -import cache from "@server/lib/cache"; +import cache from "#private/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; async function getActionDays(orgId: string): Promise { // check cache first - const cached = cache.get(`org_${orgId}_actionDays`); + const cached = await cache.get(`org_${orgId}_actionDays`); if (cached !== undefined) { return cached; } @@ -41,7 +41,7 @@ async function getActionDays(orgId: string): Promise { } // store the result in cache - cache.set( + await cache.set( `org_${orgId}_actionDays`, org.settingsLogRetentionDaysAction, 300 @@ -54,7 +54,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) { const cutoffTimestamp = calculateCutoffTimestamp(retentionDays); try { - await db + await logsDb .delete(actionAuditLog) .where( and( @@ -123,7 +123,7 @@ export function logActionAudit(action: ActionsEnum) { metadata = JSON.stringify(req.params); } - await db.insert(actionAuditLog).values({ + await logsDb.insert(actionAuditLog).values({ timestamp, orgId, actorType, diff --git a/server/private/middlewares/verifyIdpAccess.ts b/server/private/middlewares/verifyIdpAccess.ts index 410956844..2dbc1b8ff 100644 --- a/server/private/middlewares/verifyIdpAccess.ts +++ b/server/private/middlewares/verifyIdpAccess.ts @@ -13,9 +13,10 @@ import { Request, Response, NextFunction } from "express"; import { userOrgs, db, idp, idpOrg } from "@server/db"; -import { and, eq, or } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyIdpAccess( req: Request, @@ -84,8 +85,10 @@ export async function verifyIdpAccess( ); } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + idpRes.idpOrg.orgId + ); return next(); } catch (error) { diff --git a/server/private/middlewares/verifyRemoteExitNodeAccess.ts b/server/private/middlewares/verifyRemoteExitNodeAccess.ts index a2cd2bace..7d6128d8f 100644 --- a/server/private/middlewares/verifyRemoteExitNodeAccess.ts +++ b/server/private/middlewares/verifyRemoteExitNodeAccess.ts @@ -12,11 +12,12 @@ */ import { Request, Response, NextFunction } from "express"; -import { db, exitNodeOrgs, exitNodes, remoteExitNodes } from "@server/db"; -import { sites, userOrgs, userSites, roleSites, roles } from "@server/db"; -import { and, eq, or } from "drizzle-orm"; +import { db, exitNodeOrgs, remoteExitNodes } from "@server/db"; +import { userOrgs } from "@server/db"; +import { and, eq } from "drizzle-orm"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; +import { getUserOrgRoleIds } from "@server/lib/userOrgRoles"; export async function verifyRemoteExitNodeAccess( req: Request, @@ -103,8 +104,10 @@ export async function verifyRemoteExitNodeAccess( ); } - const userOrgRoleId = req.userOrg.roleId; - req.userOrgRoleId = userOrgRoleId; + req.userOrgRoleIds = await getUserOrgRoleIds( + req.userOrg.userId, + exitNodeOrg.orgId + ); return next(); } catch (error) { diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts index 5249c026e..8a8f8e3b3 100644 --- a/server/private/middlewares/verifySubscription.ts +++ b/server/private/middlewares/verifySubscription.ts @@ -16,35 +16,61 @@ import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; import { build } from "@server/build"; import { getOrgTierData } from "#private/lib/billing"; +import { Tier } from "@server/types/Tiers"; + +export function verifyValidSubscription(tiers: Tier[]) { + return async function ( + req: Request, + res: Response, + next: NextFunction + ): Promise { + try { + if (build != "saas") { + return next(); + } + + const orgId = + req.params.orgId || + req.body.orgId || + req.query.orgId || + req.userOrgId; + + if (!orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization ID is required to verify subscription" + ) + ); + } + + const { tier, active } = await getOrgTierData(orgId); + const isTier = tiers.includes(tier as Tier); + if (!active) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Organization does not have an active subscription" + ) + ); + } + if (!isTier) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Organization subscription tier does not have access to this feature" + ) + ); + } -export async function verifyValidSubscription( - req: Request, - res: Response, - next: NextFunction -) { - try { - if (build != "saas") { return next(); - } - - const tier = await getOrgTierData(req.params.orgId); - - if (!tier.active) { + } catch (e) { return next( createHttpError( - HttpCode.FORBIDDEN, - "Organization does not have an active subscription" + HttpCode.INTERNAL_SERVER_ERROR, + "Error verifying subscription" ) ); } - - return next(); - } catch (e) { - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Error verifying subscription" - ) - ); - } + }; } diff --git a/server/private/routers/approvals/countApprovals.ts b/server/private/routers/approvals/countApprovals.ts new file mode 100644 index 000000000..0885c7e88 --- /dev/null +++ b/server/private/routers/approvals/countApprovals.ts @@ -0,0 +1,110 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +import type { Request, Response, NextFunction } from "express"; +import { approvals, db, type Approval } from "@server/db"; +import { eq, sql, and, inArray } from "drizzle-orm"; +import response from "@server/lib/response"; + +const paramsSchema = z.strictObject({ + orgId: z.string() +}); + +const querySchema = z.strictObject({ + approvalState: z + .enum(["pending", "approved", "denied", "all"]) + .optional() + .default("all") + .catch("all") +}); + +export type CountApprovalsResponse = { + count: number; +}; + +export async function countApprovals( + req: Request, + res: Response, + next: NextFunction +) { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { approvalState } = parsedQuery.data; + const { orgId } = parsedParams.data; + + let state: Array = []; + switch (approvalState) { + case "pending": + state = ["pending"]; + break; + case "approved": + state = ["approved"]; + break; + case "denied": + state = ["denied"]; + break; + default: + state = ["approved", "denied", "pending"]; + } + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(approvals) + .where( + and( + eq(approvals.orgId, orgId), + inArray(approvals.decision, state) + ) + ); + + return response(res, { + data: { + count + }, + success: true, + error: false, + message: "Approval count retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/approvals/index.ts b/server/private/routers/approvals/index.ts new file mode 100644 index 000000000..118b3d28c --- /dev/null +++ b/server/private/routers/approvals/index.ts @@ -0,0 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./listApprovals"; +export * from "./processPendingApproval"; +export * from "./countApprovals"; diff --git a/server/private/routers/approvals/listApprovals.ts b/server/private/routers/approvals/listApprovals.ts new file mode 100644 index 000000000..fcac27f92 --- /dev/null +++ b/server/private/routers/approvals/listApprovals.ts @@ -0,0 +1,309 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +import type { Request, Response, NextFunction } from "express"; +import { build } from "@server/build"; +import { + approvals, + clients, + db, + users, + olms, + currentFingerprint, + type Approval +} from "@server/db"; +import { eq, isNull, sql, not, and, desc, gte, lte } from "drizzle-orm"; +import response from "@server/lib/response"; +import { getUserDeviceName } from "@server/db/names"; + +const paramsSchema = z.strictObject({ + orgId: z.string() +}); + +const querySchema = z.strictObject({ + limit: z.coerce + .number() // for prettier formatting + .int() + .positive() + .optional() + .catch(20) + .default(20), + cursorPending: z.coerce // pending cursor + .number() + .int() + .max(1) // 0 means non pending + .min(0) // 1 means pending + .optional() + .catch(undefined), + cursorTimestamp: z.coerce + .number() + .int() + .positive() + .optional() + .catch(undefined), + approvalState: z + .enum(["pending", "approved", "denied", "all"]) + .optional() + .default("all") + .catch("all"), + clientId: z + .string() + .optional() + .transform((val) => (val ? Number(val) : undefined)) + .pipe(z.number().int().positive().optional()) +}); + +async function queryApprovals({ + orgId, + limit, + approvalState, + cursorPending, + cursorTimestamp, + clientId +}: { + orgId: string; + limit: number; + approvalState: z.infer["approvalState"]; + cursorPending?: number; + cursorTimestamp?: number; + clientId?: number; +}) { + let state: Array = []; + switch (approvalState) { + case "pending": + state = ["pending"]; + break; + case "approved": + state = ["approved"]; + break; + case "denied": + state = ["denied"]; + break; + default: + state = ["approved", "denied", "pending"]; + } + + const conditions = [ + eq(approvals.orgId, orgId), + sql`${approvals.decision} in ${state}` + ]; + + if (clientId) { + conditions.push(eq(approvals.clientId, clientId)); + } + + const pendingSortKey = sql`CASE ${approvals.decision} WHEN 'pending' THEN 1 ELSE 0 END`; + + if (cursorPending != null && cursorTimestamp != null) { + // https://stackoverflow.com/a/79720298/10322846 + // composite cursor, next data means (pending, timestamp) <= cursor + conditions.push( + lte(pendingSortKey, cursorPending), + lte(approvals.timestamp, cursorTimestamp) + ); + } + + const res = await db + .select({ + approvalId: approvals.approvalId, + orgId: approvals.orgId, + clientId: approvals.clientId, + decision: approvals.decision, + type: approvals.type, + user: { + name: users.name, + userId: users.userId, + username: users.username, + email: users.email + }, + clientName: clients.name, + niceId: clients.niceId, + deviceModel: currentFingerprint.deviceModel, + fingerprintPlatform: currentFingerprint.platform, + fingerprintOsVersion: currentFingerprint.osVersion, + fingerprintKernelVersion: currentFingerprint.kernelVersion, + fingerprintArch: currentFingerprint.arch, + fingerprintSerialNumber: currentFingerprint.serialNumber, + fingerprintUsername: currentFingerprint.username, + fingerprintHostname: currentFingerprint.hostname, + timestamp: approvals.timestamp + }) + .from(approvals) + .innerJoin(users, and(eq(approvals.userId, users.userId))) + .leftJoin( + clients, + and( + eq(approvals.clientId, clients.clientId), + not(isNull(clients.userId)) // only user devices + ) + ) + .leftJoin(olms, eq(clients.clientId, olms.clientId)) + .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)) + .where(and(...conditions)) + .orderBy(desc(pendingSortKey), desc(approvals.timestamp)) + .limit(limit + 1); // the `+1` is used for the cursor + + // Process results to format device names and build fingerprint objects + const approvalsList = res.slice(0, limit).map((approval) => { + const model = approval.deviceModel || null; + const deviceName = approval.clientName + ? getUserDeviceName(model, approval.clientName) + : null; + + // Build fingerprint object if any fingerprint data exists + const hasFingerprintData = + approval.fingerprintPlatform || + approval.fingerprintOsVersion || + approval.fingerprintKernelVersion || + approval.fingerprintArch || + approval.fingerprintSerialNumber || + approval.fingerprintUsername || + approval.fingerprintHostname || + approval.deviceModel; + + const fingerprint = hasFingerprintData + ? { + platform: approval.fingerprintPlatform ?? null, + osVersion: approval.fingerprintOsVersion ?? null, + kernelVersion: approval.fingerprintKernelVersion ?? null, + arch: approval.fingerprintArch ?? null, + deviceModel: approval.deviceModel ?? null, + serialNumber: approval.fingerprintSerialNumber ?? null, + username: approval.fingerprintUsername ?? null, + hostname: approval.fingerprintHostname ?? null + } + : null; + + const { + clientName, + deviceModel, + fingerprintPlatform, + fingerprintOsVersion, + fingerprintKernelVersion, + fingerprintArch, + fingerprintSerialNumber, + fingerprintUsername, + fingerprintHostname, + ...rest + } = approval; + + return { + ...rest, + deviceName, + fingerprint, + niceId: approval.niceId || null + }; + }); + let nextCursorPending: number | null = null; + let nextCursorTimestamp: number | null = null; + if (res.length > limit) { + const lastItem = res[limit]; + nextCursorPending = lastItem.decision === "pending" ? 1 : 0; + nextCursorTimestamp = lastItem.timestamp; + } + return { + approvalsList, + nextCursorPending, + nextCursorTimestamp + }; +} + +export type ListApprovalsResponse = { + approvals: NonNullable< + Awaited> + >["approvalsList"]; + pagination: { + total: number; + limit: number; + cursorPending: number | null; + cursorTimestamp: number | null; + }; +}; + +export async function listApprovals( + req: Request, + res: Response, + next: NextFunction +) { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { + limit, + cursorPending, + cursorTimestamp, + approvalState, + clientId + } = parsedQuery.data; + + const { orgId } = parsedParams.data; + + const { approvalsList, nextCursorPending, nextCursorTimestamp } = + await queryApprovals({ + orgId: orgId.toString(), + limit, + cursorPending, + cursorTimestamp, + approvalState, + clientId + }); + + const [{ count }] = await db + .select({ count: sql`count(*)` }) + .from(approvals); + + return response(res, { + data: { + approvals: approvalsList, + pagination: { + total: count, + limit, + cursorPending: nextCursorPending, + cursorTimestamp: nextCursorTimestamp + } + }, + success: true, + error: false, + message: "Approvals retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/approvals/processPendingApproval.ts b/server/private/routers/approvals/processPendingApproval.ts new file mode 100644 index 000000000..fa60445f4 --- /dev/null +++ b/server/private/routers/approvals/processPendingApproval.ts @@ -0,0 +1,125 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +import { approvals, clients, db, orgs, type Approval } from "@server/db"; +import response from "@server/lib/response"; +import { and, eq, type InferInsertModel } from "drizzle-orm"; +import type { NextFunction, Request, Response } from "express"; + +const paramsSchema = z.strictObject({ + orgId: z.string(), + approvalId: z.string().transform(Number).pipe(z.int().positive()) +}); + +const bodySchema = z.strictObject({ + decision: z.enum(["approved", "denied"]) +}); + +export type ProcessApprovalResponse = Approval; + +export async function processPendingApproval( + req: Request, + res: Response, + next: NextFunction +) { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, approvalId } = parsedParams.data; + const updateData = parsedBody.data; + + const approval = await db + .select() + .from(approvals) + .where( + and( + eq(approvals.approvalId, approvalId), + eq(approvals.decision, "pending") + ) + ) + .innerJoin(orgs, eq(approvals.orgId, approvals.orgId)) + .limit(1); + + if (approval.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Pending Approval with ID ${approvalId} not found` + ) + ); + } + + const [updatedApproval] = await db + .update(approvals) + .set(updateData) + .where(eq(approvals.approvalId, approvalId)) + .returning(); + + // Update user device approval state too + if ( + updatedApproval.type === "user_device" && + updatedApproval.clientId + ) { + const updateDataBody: Partial> = { + approvalState: updateData.decision + }; + + if (updateData.decision === "denied") { + updateDataBody.blocked = true; + } + + await db + .update(clients) + .set(updateDataBody) + .where(eq(clients.clientId, updatedApproval.clientId)); + } + + return response(res, { + data: updatedApproval, + success: true, + error: false, + message: "Approval updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/auditLogs/exportAccessAuditLog.ts b/server/private/routers/auditLogs/exportAccessAuditLog.ts index 7e912f8c8..68a78ff6e 100644 --- a/server/private/routers/auditLogs/exportAccessAuditLog.ts +++ b/server/private/routers/auditLogs/exportAccessAuditLog.ts @@ -32,7 +32,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/logs/access/export", description: "Export the access audit log for an organization as CSV", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.Logs], request: { query: queryAccessAuditLogsQuery, params: queryAccessAuditLogsParams diff --git a/server/private/routers/auditLogs/exportActionAuditLog.ts b/server/private/routers/auditLogs/exportActionAuditLog.ts index d8987916b..853183b92 100644 --- a/server/private/routers/auditLogs/exportActionAuditLog.ts +++ b/server/private/routers/auditLogs/exportActionAuditLog.ts @@ -32,7 +32,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/logs/action/export", description: "Export the action audit log for an organization as CSV", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.Logs], request: { query: queryActionAuditLogsQuery, params: queryActionAuditLogsParams diff --git a/server/private/routers/auditLogs/exportConnectionAuditLog.ts b/server/private/routers/auditLogs/exportConnectionAuditLog.ts new file mode 100644 index 000000000..9349528ad --- /dev/null +++ b/server/private/routers/auditLogs/exportConnectionAuditLog.ts @@ -0,0 +1,99 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { OpenAPITags } from "@server/openApi"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { + queryConnectionAuditLogsParams, + queryConnectionAuditLogsQuery, + queryConnection, + countConnectionQuery +} from "./queryConnectionAuditLog"; +import { generateCSV } from "@server/routers/auditLogs/generateCSV"; +import { MAX_EXPORT_LIMIT } from "@server/routers/auditLogs"; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/connection/export", + description: "Export the connection audit log for an organization as CSV", + tags: [OpenAPITags.Logs], + request: { + query: queryConnectionAuditLogsQuery, + params: queryConnectionAuditLogsParams + }, + responses: {} +}); + +export async function exportConnectionAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryConnectionAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const parsedParams = queryConnectionAuditLogsParams.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + const [{ count }] = await countConnectionQuery(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 = queryConnection(data); + + const log = await baseQuery.limit(data.limit).offset(data.offset); + + const csvData = generateCSV(log); + + res.setHeader("Content-Type", "text/csv"); + res.setHeader( + "Content-Disposition", + `attachment; filename="connection-audit-logs-${data.orgId}-${Date.now()}.csv"` + ); + + return res.send(csvData); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/auditLogs/index.ts b/server/private/routers/auditLogs/index.ts index e1849a617..122455fea 100644 --- a/server/private/routers/auditLogs/index.ts +++ b/server/private/routers/auditLogs/index.ts @@ -15,3 +15,5 @@ export * from "./queryActionAuditLog"; export * from "./exportActionAuditLog"; export * from "./queryAccessAuditLog"; export * from "./exportAccessAuditLog"; +export * from "./queryConnectionAuditLog"; +export * from "./exportConnectionAuditLog"; diff --git a/server/private/routers/auditLogs/queryAccessAuditLog.ts b/server/private/routers/auditLogs/queryAccessAuditLog.ts index eb0cae5df..f9951c1ab 100644 --- a/server/private/routers/auditLogs/queryAccessAuditLog.ts +++ b/server/private/routers/auditLogs/queryAccessAuditLog.ts @@ -11,11 +11,11 @@ * This file is not licensed under the AGPLv3. */ -import { accessAuditLog, db, resources } from "@server/db"; +import { accessAuditLog, logsDb, resources, siteResources, db, primaryDb } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; -import { eq, gt, lt, and, count, desc } from "drizzle-orm"; +import { eq, gt, lt, and, count, desc, inArray, isNull } from "drizzle-orm"; import { OpenAPITags } from "@server/openApi"; import { z } from "zod"; import createHttpError from "http-errors"; @@ -48,7 +48,7 @@ export const queryAccessAuditLogsQuery = z.object({ }) .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .optional() - .prefault(new Date().toISOString()) + .prefault(() => new Date().toISOString()) .openapi({ type: "string", format: "date-time", @@ -115,15 +115,14 @@ function getWhere(data: Q) { } export function queryAccess(data: Q) { - return db + return logsDb .select({ orgId: accessAuditLog.orgId, action: accessAuditLog.action, actorType: accessAuditLog.actorType, actorId: accessAuditLog.actorId, resourceId: accessAuditLog.resourceId, - resourceName: resources.name, - resourceNiceId: resources.niceId, + siteResourceId: accessAuditLog.siteResourceId, ip: accessAuditLog.ip, location: accessAuditLog.location, userAgent: accessAuditLog.userAgent, @@ -133,16 +132,82 @@ export function queryAccess(data: Q) { actor: accessAuditLog.actor }) .from(accessAuditLog) - .leftJoin( - resources, - eq(accessAuditLog.resourceId, resources.resourceId) - ) .where(getWhere(data)) .orderBy(desc(accessAuditLog.timestamp), desc(accessAuditLog.id)); } +async function enrichWithResourceDetails(logs: Awaited>) { + const resourceIds = logs + .map(log => log.resourceId) + .filter((id): id is number => id !== null && id !== undefined); + + const siteResourceIds = logs + .filter(log => log.resourceId == null && log.siteResourceId != null) + .map(log => log.siteResourceId) + .filter((id): id is number => id !== null && id !== undefined); + + if (resourceIds.length === 0 && siteResourceIds.length === 0) { + return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null })); + } + + const resourceMap = new Map(); + + if (resourceIds.length > 0) { + const resourceDetails = await primaryDb + .select({ + resourceId: resources.resourceId, + name: resources.name, + niceId: resources.niceId + }) + .from(resources) + .where(inArray(resources.resourceId, resourceIds)); + + for (const r of resourceDetails) { + resourceMap.set(r.resourceId, { name: r.name, niceId: r.niceId }); + } + } + + const siteResourceMap = new Map(); + + if (siteResourceIds.length > 0) { + const siteResourceDetails = await primaryDb + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name, + niceId: siteResources.niceId + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)); + + for (const r of siteResourceDetails) { + siteResourceMap.set(r.siteResourceId, { name: r.name, niceId: r.niceId }); + } + } + + // Enrich logs with resource details + return logs.map(log => { + if (log.resourceId != null) { + const details = resourceMap.get(log.resourceId); + return { + ...log, + resourceName: details?.name ?? null, + resourceNiceId: details?.niceId ?? null + }; + } else if (log.siteResourceId != null) { + const details = siteResourceMap.get(log.siteResourceId); + return { + ...log, + resourceId: log.siteResourceId, + resourceName: details?.name ?? null, + resourceNiceId: details?.niceId ?? null + }; + } + return { ...log, resourceName: null, resourceNiceId: null }; + }); +} + export function countAccessQuery(data: Q) { - const countQuery = db + const countQuery = logsDb .select({ count: count() }) .from(accessAuditLog) .where(getWhere(data)); @@ -161,7 +226,7 @@ async function queryUniqueFilterAttributes( ); // Get unique actors - const uniqueActors = await db + const uniqueActors = await logsDb .selectDistinct({ actor: accessAuditLog.actor }) @@ -169,7 +234,7 @@ async function queryUniqueFilterAttributes( .where(baseConditions); // Get unique locations - const uniqueLocations = await db + const uniqueLocations = await logsDb .selectDistinct({ locations: accessAuditLog.location }) @@ -177,25 +242,73 @@ async function queryUniqueFilterAttributes( .where(baseConditions); // Get unique resources with names - const uniqueResources = await db + const uniqueResources = await logsDb .selectDistinct({ - id: accessAuditLog.resourceId, - name: resources.name + id: accessAuditLog.resourceId }) .from(accessAuditLog) - .leftJoin( - resources, - eq(accessAuditLog.resourceId, resources.resourceId) - ) .where(baseConditions); + // Get unique siteResources (only for logs where resourceId is null) + const uniqueSiteResources = await logsDb + .selectDistinct({ + id: accessAuditLog.siteResourceId + }) + .from(accessAuditLog) + .where(and(baseConditions, isNull(accessAuditLog.resourceId))); + + // Fetch resource names from main database for the unique resource IDs + const resourceIds = uniqueResources + .map(row => row.id) + .filter((id): id is number => id !== null); + + const siteResourceIds = uniqueSiteResources + .map(row => row.id) + .filter((id): id is number => id !== null); + + let resourcesWithNames: Array<{ id: number; name: string | null }> = []; + + if (resourceIds.length > 0) { + const resourceDetails = await primaryDb + .select({ + resourceId: resources.resourceId, + name: resources.name + }) + .from(resources) + .where(inArray(resources.resourceId, resourceIds)); + + resourcesWithNames = [ + ...resourcesWithNames, + ...resourceDetails.map(r => ({ + id: r.resourceId, + name: r.name + })) + ]; + } + + if (siteResourceIds.length > 0) { + const siteResourceDetails = await primaryDb + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)); + + resourcesWithNames = [ + ...resourcesWithNames, + ...siteResourceDetails.map(r => ({ + id: r.siteResourceId, + name: r.name + })) + ]; + } + return { actors: uniqueActors .map((row) => row.actor) .filter((actor): actor is string => actor !== null), - resources: uniqueResources.filter( - (row): row is { id: number; name: string | null } => row.id !== null - ), + resources: resourcesWithNames, locations: uniqueLocations .map((row) => row.locations) .filter((location): location is string => location !== null) @@ -206,7 +319,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/logs/access", description: "Query the access audit log for an organization", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.Logs], request: { query: queryAccessAuditLogsQuery, params: queryAccessAuditLogsParams @@ -243,7 +356,10 @@ export async function queryAccessAuditLogs( const baseQuery = queryAccess(data); - const log = await baseQuery.limit(data.limit).offset(data.offset); + const logsRaw = await baseQuery.limit(data.limit).offset(data.offset); + + // Enrich with resource details (handles cross-database scenario) + const log = await enrichWithResourceDetails(logsRaw); const totalCountResult = await countAccessQuery(data); const totalCount = totalCountResult[0].count; diff --git a/server/private/routers/auditLogs/queryActionAuditLog.ts b/server/private/routers/auditLogs/queryActionAuditLog.ts index 518eb9824..8bbe73ee1 100644 --- a/server/private/routers/auditLogs/queryActionAuditLog.ts +++ b/server/private/routers/auditLogs/queryActionAuditLog.ts @@ -11,7 +11,7 @@ * This file is not licensed under the AGPLv3. */ -import { actionAuditLog, db } from "@server/db"; +import { actionAuditLog, logsDb } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; @@ -48,7 +48,7 @@ export const queryActionAuditLogsQuery = z.object({ }) .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .optional() - .prefault(new Date().toISOString()) + .prefault(() => new Date().toISOString()) .openapi({ type: "string", format: "date-time", @@ -97,7 +97,7 @@ function getWhere(data: Q) { } export function queryAction(data: Q) { - return db + return logsDb .select({ orgId: actionAuditLog.orgId, action: actionAuditLog.action, @@ -113,7 +113,7 @@ export function queryAction(data: Q) { } export function countActionQuery(data: Q) { - const countQuery = db + const countQuery = logsDb .select({ count: count() }) .from(actionAuditLog) .where(getWhere(data)); @@ -132,14 +132,14 @@ async function queryUniqueFilterAttributes( ); // Get unique actors - const uniqueActors = await db + const uniqueActors = await logsDb .selectDistinct({ actor: actionAuditLog.actor }) .from(actionAuditLog) .where(baseConditions); - const uniqueActions = await db + const uniqueActions = await logsDb .selectDistinct({ action: actionAuditLog.action }) @@ -160,7 +160,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/logs/action", description: "Query the action audit log for an organization", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.Logs], request: { query: queryActionAuditLogsQuery, params: queryActionAuditLogsParams diff --git a/server/private/routers/auditLogs/queryConnectionAuditLog.ts b/server/private/routers/auditLogs/queryConnectionAuditLog.ts new file mode 100644 index 000000000..b638ed488 --- /dev/null +++ b/server/private/routers/auditLogs/queryConnectionAuditLog.ts @@ -0,0 +1,524 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { + connectionAuditLog, + logsDb, + siteResources, + sites, + clients, + users, + primaryDb +} from "@server/db"; +import { registry } from "@server/openApi"; +import { NextFunction } from "express"; +import { Request, Response } from "express"; +import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm"; +import { OpenAPITags } from "@server/openApi"; +import { z } from "zod"; +import createHttpError from "http-errors"; +import HttpCode from "@server/types/HttpCode"; +import { fromError } from "zod-validation-error"; +import { QueryConnectionAuditLogResponse } from "@server/routers/auditLogs/types"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; + +export const queryConnectionAuditLogsQuery = z.object({ + // iso string just validate its a parseable date + timeStart: z + .string() + .refine((val) => !isNaN(Date.parse(val)), { + error: "timeStart must be a valid ISO date string" + }) + .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)), { + error: "timeEnd must be a valid ISO date string" + }) + .transform((val) => Math.floor(new Date(val).getTime() / 1000)) + .optional() + .prefault(() => new Date().toISOString()) + .openapi({ + type: "string", + format: "date-time", + description: + "End time as ISO date string (defaults to current time)" + }), + protocol: z.string().optional(), + sourceAddr: z.string().optional(), + destAddr: z.string().optional(), + clientId: z + .string() + .optional() + .transform(Number) + .pipe(z.int().positive()) + .optional(), + siteId: z + .string() + .optional() + .transform(Number) + .pipe(z.int().positive()) + .optional(), + siteResourceId: z + .string() + .optional() + .transform(Number) + .pipe(z.int().positive()) + .optional(), + userId: z.string().optional(), + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.int().nonnegative()) +}); + +export const queryConnectionAuditLogsParams = z.object({ + orgId: z.string() +}); + +export const queryConnectionAuditLogsCombined = + queryConnectionAuditLogsQuery.merge(queryConnectionAuditLogsParams); +type Q = z.infer; + +function getWhere(data: Q) { + return and( + gt(connectionAuditLog.startedAt, data.timeStart), + lt(connectionAuditLog.startedAt, data.timeEnd), + eq(connectionAuditLog.orgId, data.orgId), + data.protocol + ? eq(connectionAuditLog.protocol, data.protocol) + : undefined, + data.sourceAddr + ? eq(connectionAuditLog.sourceAddr, data.sourceAddr) + : undefined, + data.destAddr + ? eq(connectionAuditLog.destAddr, data.destAddr) + : undefined, + data.clientId + ? eq(connectionAuditLog.clientId, data.clientId) + : undefined, + data.siteId + ? eq(connectionAuditLog.siteId, data.siteId) + : undefined, + data.siteResourceId + ? eq(connectionAuditLog.siteResourceId, data.siteResourceId) + : undefined, + data.userId + ? eq(connectionAuditLog.userId, data.userId) + : undefined + ); +} + +export function queryConnection(data: Q) { + return logsDb + .select({ + sessionId: connectionAuditLog.sessionId, + siteResourceId: connectionAuditLog.siteResourceId, + orgId: connectionAuditLog.orgId, + siteId: connectionAuditLog.siteId, + clientId: connectionAuditLog.clientId, + userId: connectionAuditLog.userId, + sourceAddr: connectionAuditLog.sourceAddr, + destAddr: connectionAuditLog.destAddr, + protocol: connectionAuditLog.protocol, + startedAt: connectionAuditLog.startedAt, + endedAt: connectionAuditLog.endedAt, + bytesTx: connectionAuditLog.bytesTx, + bytesRx: connectionAuditLog.bytesRx + }) + .from(connectionAuditLog) + .where(getWhere(data)) + .orderBy( + desc(connectionAuditLog.startedAt), + desc(connectionAuditLog.id) + ); +} + +export function countConnectionQuery(data: Q) { + const countQuery = logsDb + .select({ count: count() }) + .from(connectionAuditLog) + .where(getWhere(data)); + return countQuery; +} + +async function enrichWithDetails( + logs: Awaited> +) { + // Collect unique IDs from logs + const siteResourceIds = [ + ...new Set( + logs + .map((log) => log.siteResourceId) + .filter((id): id is number => id !== null && id !== undefined) + ) + ]; + const siteIds = [ + ...new Set( + logs + .map((log) => log.siteId) + .filter((id): id is number => id !== null && id !== undefined) + ) + ]; + const clientIds = [ + ...new Set( + logs + .map((log) => log.clientId) + .filter((id): id is number => id !== null && id !== undefined) + ) + ]; + const userIds = [ + ...new Set( + logs + .map((log) => log.userId) + .filter((id): id is string => id !== null && id !== undefined) + ) + ]; + + // Fetch resource details from main database + const resourceMap = new Map< + number, + { name: string; niceId: string } + >(); + if (siteResourceIds.length > 0) { + const resourceDetails = await primaryDb + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name, + niceId: siteResources.niceId + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, siteResourceIds)); + + for (const r of resourceDetails) { + resourceMap.set(r.siteResourceId, { + name: r.name, + niceId: r.niceId + }); + } + } + + // Fetch site details from main database + const siteMap = new Map(); + if (siteIds.length > 0) { + const siteDetails = await primaryDb + .select({ + siteId: sites.siteId, + name: sites.name, + niceId: sites.niceId + }) + .from(sites) + .where(inArray(sites.siteId, siteIds)); + + for (const s of siteDetails) { + siteMap.set(s.siteId, { name: s.name, niceId: s.niceId }); + } + } + + // Fetch client details from main database + const clientMap = new Map< + number, + { name: string; niceId: string; type: string } + >(); + if (clientIds.length > 0) { + const clientDetails = await primaryDb + .select({ + clientId: clients.clientId, + name: clients.name, + niceId: clients.niceId, + type: clients.type + }) + .from(clients) + .where(inArray(clients.clientId, clientIds)); + + for (const c of clientDetails) { + clientMap.set(c.clientId, { + name: c.name, + niceId: c.niceId, + type: c.type + }); + } + } + + // Fetch user details from main database + const userMap = new Map< + string, + { email: string | null } + >(); + if (userIds.length > 0) { + const userDetails = await primaryDb + .select({ + userId: users.userId, + email: users.email + }) + .from(users) + .where(inArray(users.userId, userIds)); + + for (const u of userDetails) { + userMap.set(u.userId, { email: u.email }); + } + } + + // Enrich logs with details + return logs.map((log) => ({ + ...log, + resourceName: log.siteResourceId + ? resourceMap.get(log.siteResourceId)?.name ?? null + : null, + resourceNiceId: log.siteResourceId + ? resourceMap.get(log.siteResourceId)?.niceId ?? null + : null, + siteName: log.siteId + ? siteMap.get(log.siteId)?.name ?? null + : null, + siteNiceId: log.siteId + ? siteMap.get(log.siteId)?.niceId ?? null + : null, + clientName: log.clientId + ? clientMap.get(log.clientId)?.name ?? null + : null, + clientNiceId: log.clientId + ? clientMap.get(log.clientId)?.niceId ?? null + : null, + clientType: log.clientId + ? clientMap.get(log.clientId)?.type ?? null + : null, + userEmail: log.userId + ? userMap.get(log.userId)?.email ?? null + : null + })); +} + +async function queryUniqueFilterAttributes( + timeStart: number, + timeEnd: number, + orgId: string +) { + const baseConditions = and( + gt(connectionAuditLog.startedAt, timeStart), + lt(connectionAuditLog.startedAt, timeEnd), + eq(connectionAuditLog.orgId, orgId) + ); + + // Get unique protocols + const uniqueProtocols = await logsDb + .selectDistinct({ + protocol: connectionAuditLog.protocol + }) + .from(connectionAuditLog) + .where(baseConditions); + + // Get unique destination addresses + const uniqueDestAddrs = await logsDb + .selectDistinct({ + destAddr: connectionAuditLog.destAddr + }) + .from(connectionAuditLog) + .where(baseConditions); + + // Get unique client IDs + const uniqueClients = await logsDb + .selectDistinct({ + clientId: connectionAuditLog.clientId + }) + .from(connectionAuditLog) + .where(baseConditions); + + // Get unique resource IDs + const uniqueResources = await logsDb + .selectDistinct({ + siteResourceId: connectionAuditLog.siteResourceId + }) + .from(connectionAuditLog) + .where(baseConditions); + + // Get unique user IDs + const uniqueUsers = await logsDb + .selectDistinct({ + userId: connectionAuditLog.userId + }) + .from(connectionAuditLog) + .where(baseConditions); + + // Enrich client IDs with names from main database + const clientIds = uniqueClients + .map((row) => row.clientId) + .filter((id): id is number => id !== null); + + let clientsWithNames: Array<{ id: number; name: string }> = []; + if (clientIds.length > 0) { + const clientDetails = await primaryDb + .select({ + clientId: clients.clientId, + name: clients.name + }) + .from(clients) + .where(inArray(clients.clientId, clientIds)); + + clientsWithNames = clientDetails.map((c) => ({ + id: c.clientId, + name: c.name + })); + } + + // Enrich resource IDs with names from main database + const resourceIds = uniqueResources + .map((row) => row.siteResourceId) + .filter((id): id is number => id !== null); + + let resourcesWithNames: Array<{ id: number; name: string | null }> = []; + if (resourceIds.length > 0) { + const resourceDetails = await primaryDb + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name + }) + .from(siteResources) + .where(inArray(siteResources.siteResourceId, resourceIds)); + + resourcesWithNames = resourceDetails.map((r) => ({ + id: r.siteResourceId, + name: r.name + })); + } + + // Enrich user IDs with emails from main database + const userIdsList = uniqueUsers + .map((row) => row.userId) + .filter((id): id is string => id !== null); + + let usersWithEmails: Array<{ id: string; email: string | null }> = []; + if (userIdsList.length > 0) { + const userDetails = await primaryDb + .select({ + userId: users.userId, + email: users.email + }) + .from(users) + .where(inArray(users.userId, userIdsList)); + + usersWithEmails = userDetails.map((u) => ({ + id: u.userId, + email: u.email + })); + } + + return { + protocols: uniqueProtocols + .map((row) => row.protocol) + .filter((protocol): protocol is string => protocol !== null), + destAddrs: uniqueDestAddrs + .map((row) => row.destAddr) + .filter((addr): addr is string => addr !== null), + clients: clientsWithNames, + resources: resourcesWithNames, + users: usersWithEmails + }; +} + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/logs/connection", + description: "Query the connection audit log for an organization", + tags: [OpenAPITags.Logs], + request: { + query: queryConnectionAuditLogsQuery, + params: queryConnectionAuditLogsParams + }, + responses: {} +}); + +export async function queryConnectionAuditLogs( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = queryConnectionAuditLogsQuery.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const parsedParams = queryConnectionAuditLogsParams.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const data = { ...parsedQuery.data, ...parsedParams.data }; + + const baseQuery = queryConnection(data); + + const logsRaw = await baseQuery.limit(data.limit).offset(data.offset); + + // Enrich with resource, site, client, and user details + const log = await enrichWithDetails(logsRaw); + + const totalCountResult = await countConnectionQuery(data); + const totalCount = totalCountResult[0].count; + + const filterAttributes = await queryUniqueFilterAttributes( + data.timeStart, + data.timeEnd, + data.orgId + ); + + return response(res, { + data: { + log: log, + pagination: { + total: totalCount, + limit: data.limit, + offset: data.offset + }, + filterAttributes + }, + success: true, + error: false, + message: "Connection audit logs retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} \ No newline at end of file diff --git a/server/private/routers/auth/index.ts b/server/private/routers/auth/index.ts index 535d58873..25adfa788 100644 --- a/server/private/routers/auth/index.ts +++ b/server/private/routers/auth/index.ts @@ -13,4 +13,3 @@ export * from "./transferSession"; export * from "./getSessionTransferToken"; -export * from "./quickStart"; diff --git a/server/private/routers/auth/quickStart.ts b/server/private/routers/auth/quickStart.ts deleted file mode 100644 index 612a3951a..000000000 --- a/server/private/routers/auth/quickStart.ts +++ /dev/null @@ -1,585 +0,0 @@ -/* - * This file is part of a proprietary work. - * - * Copyright (c) 2025 Fossorial, Inc. - * All rights reserved. - * - * This file is licensed under the Fossorial Commercial License. - * You may not use this file except in compliance with the License. - * Unauthorized use, copying, modification, or distribution is strictly prohibited. - * - * This file is not licensed under the AGPLv3. - */ - -import { NextFunction, Request, Response } from "express"; -import { - account, - db, - domainNamespaces, - domains, - exitNodes, - newts, - newtSessions, - orgs, - passwordResetTokens, - Resource, - resourcePassword, - resourcePincode, - resources, - resourceWhitelist, - roleResources, - roles, - roleSites, - sites, - targetHealthCheck, - targets, - userResources, - userSites -} from "@server/db"; -import HttpCode from "@server/types/HttpCode"; -import { z } from "zod"; -import { users } from "@server/db"; -import { fromError } from "zod-validation-error"; -import createHttpError from "http-errors"; -import response from "@server/lib/response"; -import { SqliteError } from "better-sqlite3"; -import { eq, and, sql } from "drizzle-orm"; -import moment from "moment"; -import { generateId } from "@server/auth/sessions/app"; -import config from "@server/lib/config"; -import logger from "@server/logger"; -import { hashPassword } from "@server/auth/password"; -import { UserType } from "@server/types/UserTypes"; -import { createUserAccountOrg } from "@server/lib/createUserAccountOrg"; -import { sendEmail } from "@server/emails"; -import WelcomeQuickStart from "@server/emails/templates/WelcomeQuickStart"; -import { alphabet, generateRandomString } from "oslo/crypto"; -import { createDate, TimeSpan } from "oslo"; -import { getUniqueResourceName, getUniqueSiteName } from "@server/db/names"; -import { pickPort } from "@server/routers/target/helpers"; -import { addTargets } from "@server/routers/newt/targets"; -import { isTargetValid } from "@server/lib/validators"; -import { listExitNodes } from "#private/lib/exitNodes"; - -const bodySchema = z.object({ - email: z.email().toLowerCase(), - ip: z.string().refine(isTargetValid), - method: z.enum(["http", "https"]), - port: z.int().min(1).max(65535), - pincode: z - .string() - .regex(/^\d{6}$/) - .optional(), - password: z.string().min(4).max(100).optional(), - enableWhitelist: z.boolean().optional().default(true), - animalId: z.string() // This is actually the secret key for the backend -}); - -export type QuickStartBody = z.infer; - -export type QuickStartResponse = { - newtId: string; - newtSecret: string; - resourceUrl: string; - completeSignUpLink: string; -}; - -const DEMO_UBO_KEY = "b460293f-347c-4b30-837d-4e06a04d5a22"; - -export async function quickStart( - req: Request, - res: Response, - next: NextFunction -): Promise { - const parsedBody = bodySchema.safeParse(req.body); - - if (!parsedBody.success) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - fromError(parsedBody.error).toString() - ) - ); - } - - const { - email, - ip, - method, - port, - pincode, - password, - enableWhitelist, - animalId - } = parsedBody.data; - - try { - const tokenValidation = validateTokenOnApi(animalId); - - if (!tokenValidation.isValid) { - logger.warn( - `Quick start failed for ${email} token ${animalId}: ${tokenValidation.message}` - ); - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid or expired token" - ) - ); - } - - if (animalId === DEMO_UBO_KEY) { - if (email !== "mehrdad@getubo.com") { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "Invalid email for demo Ubo key" - ) - ); - } - - const [existing] = await db - .select() - .from(users) - .where( - and( - eq(users.email, email), - eq(users.type, UserType.Internal) - ) - ); - - if (existing) { - // delete the user if it already exists - await db.delete(users).where(eq(users.userId, existing.userId)); - const orgId = `org_${existing.userId}`; - await db.delete(orgs).where(eq(orgs.orgId, orgId)); - } - } - - const tempPassword = generateId(15); - const passwordHash = await hashPassword(tempPassword); - const userId = generateId(15); - - // TODO: see if that user already exists? - - // Create the sandbox user - const existing = await db - .select() - .from(users) - .where( - and(eq(users.email, email), eq(users.type, UserType.Internal)) - ); - - if (existing && existing.length > 0) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "A user with that email address already exists" - ) - ); - } - - let newtId: string; - let secret: string; - let fullDomain: string; - let resource: Resource; - let completeSignUpLink: string; - - await db.transaction(async (trx) => { - await trx.insert(users).values({ - userId: userId, - type: UserType.Internal, - username: email, - email: email, - passwordHash, - dateCreated: moment().toISOString() - }); - - // create user"s account - await trx.insert(account).values({ - userId - }); - }); - - const { success, error, org } = await createUserAccountOrg( - userId, - email - ); - if (!success) { - if (error) { - throw new Error(error); - } - throw new Error("Failed to create user account and organization"); - } - if (!org) { - throw new Error("Failed to create user account and organization"); - } - - const orgId = org.orgId; - - await db.transaction(async (trx) => { - const token = generateRandomString( - 8, - alphabet("0-9", "A-Z", "a-z") - ); - - await trx - .delete(passwordResetTokens) - .where(eq(passwordResetTokens.userId, userId)); - - const tokenHash = await hashPassword(token); - - await trx.insert(passwordResetTokens).values({ - userId: userId, - email: email, - tokenHash, - expiresAt: createDate(new TimeSpan(7, "d")).getTime() - }); - - // // Create the sandbox newt - // const newClientAddress = await getNextAvailableClientSubnet(orgId); - // if (!newClientAddress) { - // throw new Error("No available subnet found"); - // } - - // const clientAddress = newClientAddress.split("/")[0]; - - newtId = generateId(15); - secret = generateId(48); - - // Create the sandbox site - const siteNiceId = await getUniqueSiteName(orgId); - const siteName = `First Site`; - - // pick a random exit node - const exitNodesList = await listExitNodes(orgId); - - // select a random exit node - const randomExitNode = - exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; - - if (!randomExitNode) { - throw new Error("No exit nodes available"); - } - - const [newSite] = await trx - .insert(sites) - .values({ - orgId, - exitNodeId: randomExitNode.exitNodeId, - name: siteName, - niceId: siteNiceId, - // address: clientAddress, - type: "newt", - dockerSocketEnabled: true - }) - .returning(); - - const siteId = newSite.siteId; - - const adminRole = await trx - .select() - .from(roles) - .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) - .limit(1); - - if (adminRole.length === 0) { - throw new Error("Admin role not found"); - } - - await trx.insert(roleSites).values({ - roleId: adminRole[0].roleId, - siteId: newSite.siteId - }); - - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { - // make sure the user can access the site - await trx.insert(userSites).values({ - userId: req.user?.userId!, - siteId: newSite.siteId - }); - } - - // add the peer to the exit node - const secretHash = await hashPassword(secret!); - - await trx.insert(newts).values({ - newtId: newtId!, - secretHash, - siteId: newSite.siteId, - dateCreated: moment().toISOString() - }); - - const [randomNamespace] = await trx - .select() - .from(domainNamespaces) - .orderBy(sql`RANDOM()`) - .limit(1); - - if (!randomNamespace) { - throw new Error("No domain namespace available"); - } - - const [randomNamespaceDomain] = await trx - .select() - .from(domains) - .where(eq(domains.domainId, randomNamespace.domainId)) - .limit(1); - - if (!randomNamespaceDomain) { - throw new Error("No domain found for the namespace"); - } - - const resourceNiceId = await getUniqueResourceName(orgId); - - // Create sandbox resource - const subdomain = `${resourceNiceId}-${generateId(5)}`; - fullDomain = `${subdomain}.${randomNamespaceDomain.baseDomain}`; - - const resourceName = `First Resource`; - - const newResource = await trx - .insert(resources) - .values({ - niceId: resourceNiceId, - fullDomain, - domainId: randomNamespaceDomain.domainId, - orgId, - name: resourceName, - subdomain, - http: true, - protocol: "tcp", - ssl: true, - sso: false, - emailWhitelistEnabled: enableWhitelist - }) - .returning(); - - await trx.insert(roleResources).values({ - roleId: adminRole[0].roleId, - resourceId: newResource[0].resourceId - }); - - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { - // make sure the user can access the resource - await trx.insert(userResources).values({ - userId: req.user?.userId!, - resourceId: newResource[0].resourceId - }); - } - - resource = newResource[0]; - - // Create the sandbox target - const { internalPort, targetIps } = await pickPort(siteId!, trx); - - if (!internalPort) { - throw new Error("No available internal port"); - } - - const newTarget = await trx - .insert(targets) - .values({ - resourceId: resource.resourceId, - siteId: siteId!, - internalPort, - ip, - method, - port, - enabled: true - }) - .returning(); - - const newHealthcheck = await trx - .insert(targetHealthCheck) - .values({ - targetId: newTarget[0].targetId, - hcEnabled: false - }) - .returning(); - - // add the new target to the targetIps array - targetIps.push(`${ip}/32`); - - const [newt] = await trx - .select() - .from(newts) - .where(eq(newts.siteId, siteId!)) - .limit(1); - - await addTargets( - newt.newtId, - newTarget, - newHealthcheck, - resource.protocol - ); - - // Set resource pincode if provided - if (pincode) { - await trx - .delete(resourcePincode) - .where( - eq(resourcePincode.resourceId, resource!.resourceId) - ); - - const pincodeHash = await hashPassword(pincode); - - await trx.insert(resourcePincode).values({ - resourceId: resource!.resourceId, - pincodeHash, - digitLength: 6 - }); - } - - // Set resource password if provided - if (password) { - await trx - .delete(resourcePassword) - .where( - eq(resourcePassword.resourceId, resource!.resourceId) - ); - - const passwordHash = await hashPassword(password); - - await trx.insert(resourcePassword).values({ - resourceId: resource!.resourceId, - passwordHash - }); - } - - // Set resource OTP if whitelist is enabled - if (enableWhitelist) { - await trx.insert(resourceWhitelist).values({ - email, - resourceId: resource!.resourceId - }); - } - - completeSignUpLink = `${config.getRawConfig().app.dashboard_url}/auth/reset-password?quickstart=true&email=${email}&token=${token}`; - - // Store token for email outside transaction - await sendEmail( - WelcomeQuickStart({ - username: email, - link: completeSignUpLink, - fallbackLink: `${config.getRawConfig().app.dashboard_url}/auth/reset-password?quickstart=true&email=${email}`, - resourceMethod: method, - resourceHostname: ip, - resourcePort: port, - resourceUrl: `https://${fullDomain}`, - cliCommand: `newt --id ${newtId} --secret ${secret}` - }), - { - to: email, - from: config.getNoReplyEmail(), - subject: `Access your Pangolin dashboard and resources` - } - ); - }); - - return response(res, { - data: { - newtId: newtId!, - newtSecret: secret!, - resourceUrl: `https://${fullDomain!}`, - completeSignUpLink: completeSignUpLink! - }, - success: true, - error: false, - message: "Quick start completed successfully", - status: HttpCode.OK - }); - } catch (e) { - if (e instanceof SqliteError && e.code === "SQLITE_CONSTRAINT_UNIQUE") { - if (config.getRawConfig().app.log_failed_attempts) { - logger.info( - `Account already exists with that email. Email: ${email}. IP: ${req.ip}.` - ); - } - return next( - createHttpError( - HttpCode.BAD_REQUEST, - "A user with that email address already exists" - ) - ); - } else { - logger.error(e); - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to do quick start" - ) - ); - } - } -} - -const BACKEND_SECRET_KEY = "4f9b6000-5d1a-11f0-9de7-ff2cc032f501"; - -/** - * Validates a token received from the frontend. - * @param {string} token The validation token from the request. - * @returns {{ isValid: boolean; message: string }} An object indicating if the token is valid. - */ -const validateTokenOnApi = ( - token: string -): { isValid: boolean; message: string } => { - if (token === DEMO_UBO_KEY) { - // Special case for demo UBO key - return { isValid: true, message: "Demo UBO key is valid." }; - } - - if (!token) { - return { isValid: false, message: "Error: No token provided." }; - } - - try { - // 1. Decode the base64 string - const decodedB64 = atob(token); - - // 2. Reverse the character code manipulation - const deobfuscated = decodedB64 - .split("") - .map((char) => String.fromCharCode(char.charCodeAt(0) - 5)) // Reverse the shift - .join(""); - - // 3. Split the data to get the original secret and timestamp - const parts = deobfuscated.split("|"); - if (parts.length !== 2) { - throw new Error("Invalid token format."); - } - const receivedKey = parts[0]; - const tokenTimestamp = parseInt(parts[1], 10); - - // 4. Check if the secret key matches - if (receivedKey !== BACKEND_SECRET_KEY) { - return { isValid: false, message: "Invalid token: Key mismatch." }; - } - - // 5. Check if the timestamp is recent (e.g., within 30 seconds) to prevent replay attacks - const now = Date.now(); - const timeDifference = now - tokenTimestamp; - - if (timeDifference > 30000) { - // 30 seconds - return { isValid: false, message: "Invalid token: Expired." }; - } - - if (timeDifference < 0) { - // Timestamp is in the future - return { - isValid: false, - message: "Invalid token: Timestamp is in the future." - }; - } - - // If all checks pass, the token is valid - return { isValid: true, message: "Token is valid!" }; - } catch (error) { - // This will catch errors from atob (if not valid base64) or other issues. - return { - isValid: false, - message: `Error: ${(error as Error).message}` - }; - } -}; diff --git a/server/private/routers/billing/changeTier.ts b/server/private/routers/billing/changeTier.ts new file mode 100644 index 000000000..3c9b8e437 --- /dev/null +++ b/server/private/routers/billing/changeTier.ts @@ -0,0 +1,268 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { customers, db, subscriptions, subscriptionItems } from "@server/db"; +import { eq, and, or } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import stripe from "#private/lib/stripe"; +import { + getTier1FeaturePriceSet, + getTier3FeaturePriceSet, + getTier2FeaturePriceSet, + FeatureId, + type FeaturePriceSet +} from "@server/lib/billing"; +import { getLineItems } from "@server/lib/billing/getLineItems"; + +const changeTierSchema = z.strictObject({ + orgId: z.string() +}); + +const changeTierBodySchema = z.strictObject({ + tier: z.enum(["tier1", "tier2", "tier3"]) +}); + +export async function changeTier( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = changeTierSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const parsedBody = changeTierBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { tier } = parsedBody.data; + + // Get the customer for this org + const [customer] = await db + .select() + .from(customers) + .where(eq(customers.orgId, orgId)) + .limit(1); + + if (!customer) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No customer found for this organization" + ) + ); + } + + // Get the active subscription for this customer + const [subscription] = await db + .select() + .from(subscriptions) + .where( + and( + eq(subscriptions.customerId, customer.customerId), + eq(subscriptions.status, "active"), + or( + eq(subscriptions.type, "tier1"), + eq(subscriptions.type, "tier2"), + eq(subscriptions.type, "tier3") + ) + ) + ) + .limit(1); + + if (!subscription) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No active subscription found for this organization" + ) + ); + } + + // Get the target tier's price set + let targetPriceSet: FeaturePriceSet; + if (tier === "tier1") { + targetPriceSet = getTier1FeaturePriceSet(); + } else if (tier === "tier2") { + targetPriceSet = getTier2FeaturePriceSet(); + } else if (tier === "tier3") { + targetPriceSet = getTier3FeaturePriceSet(); + } else { + return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid tier")); + } + + // Get current subscription items from our database + const currentItems = await db + .select() + .from(subscriptionItems) + .where( + eq( + subscriptionItems.subscriptionId, + subscription.subscriptionId + ) + ); + + if (currentItems.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No subscription items found" + ) + ); + } + + // Retrieve the full subscription from Stripe to get item IDs + const stripeSubscription = await stripe!.subscriptions.retrieve( + subscription.subscriptionId + ); + + // Determine if we're switching between different products + // tier1 uses TIER1 product, tier2/tier3 use USERS product + const currentTier = subscription.type; + const switchingProducts = + (currentTier === "tier1" && + (tier === "tier2" || tier === "tier3")) || + ((currentTier === "tier2" || currentTier === "tier3") && + tier === "tier1"); + + let updatedSubscription; + + if (switchingProducts) { + // When switching between different products, we need to: + // 1. Delete old subscription items + // 2. Add new subscription items + logger.info( + `Switching products from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}` + ); + + // Build array to delete all existing items and add new ones + const itemsToUpdate: any[] = []; + + // Mark all existing items for deletion + for (const stripeItem of stripeSubscription.items.data) { + itemsToUpdate.push({ + id: stripeItem.id, + deleted: true + }); + } + + // Add new items for the target tier + const newLineItems = await getLineItems(targetPriceSet, orgId); + for (const lineItem of newLineItems) { + itemsToUpdate.push(lineItem); + } + + updatedSubscription = await stripe!.subscriptions.update( + subscription.subscriptionId, + { + items: itemsToUpdate, + proration_behavior: "create_prorations" + } + ); + } else { + // Same product, different price tier (tier2 <-> tier3) + // We can simply update the price + logger.info( + `Updating price from ${currentTier} to ${tier} for subscription ${subscription.subscriptionId}` + ); + + const itemsToUpdate = stripeSubscription.items.data.map( + (stripeItem) => { + // Find the corresponding item in our database + const dbItem = currentItems.find( + (item) => item.priceId === stripeItem.price.id + ); + + if (!dbItem) { + // Keep the existing item unchanged if we can't find it + return { + id: stripeItem.id, + price: stripeItem.price.id, + quantity: stripeItem.quantity + }; + } + + // Map to the corresponding feature in the new tier + const newPriceId = targetPriceSet[FeatureId.USERS]; + + if (newPriceId) { + return { + id: stripeItem.id, + price: newPriceId, + quantity: stripeItem.quantity + }; + } + + // If no mapping found, keep existing + return { + id: stripeItem.id, + price: stripeItem.price.id, + quantity: stripeItem.quantity + }; + } + ); + + updatedSubscription = await stripe!.subscriptions.update( + subscription.subscriptionId, + { + items: itemsToUpdate, + proration_behavior: "create_prorations" + } + ); + } + + logger.info( + `Successfully changed tier to ${tier} for org ${orgId}, subscription ${subscription.subscriptionId}` + ); + + return response<{ subscriptionId: string; newTier: string }>(res, { + data: { + subscriptionId: updatedSubscription.id, + newTier: tier + }, + success: true, + error: false, + message: "Tier change successful", + status: HttpCode.OK + }); + } catch (error) { + logger.error("Error changing tier:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred while changing tier" + ) + ); + } +} diff --git a/server/private/routers/billing/createCheckoutSession.ts b/server/private/routers/billing/createCheckoutSession.ts index a2d8080f7..b35c65329 100644 --- a/server/private/routers/billing/createCheckoutSession.ts +++ b/server/private/routers/billing/createCheckoutSession.ts @@ -22,13 +22,22 @@ import logger from "@server/logger"; import config from "@server/lib/config"; import { fromError } from "zod-validation-error"; import stripe from "#private/lib/stripe"; -import { getLineItems, getStandardFeaturePriceSet } from "@server/lib/billing"; -import { getTierPriceSet, TierId } from "@server/lib/billing/tiers"; +import { + getTier1FeaturePriceSet, + getTier3FeaturePriceSet, + getTier2FeaturePriceSet +} from "@server/lib/billing"; +import { getLineItems } from "@server/lib/billing/getLineItems"; +import Stripe from "stripe"; const createCheckoutSessionSchema = z.strictObject({ orgId: z.string() }); +const createCheckoutSessionBodySchema = z.strictObject({ + tier: z.enum(["tier1", "tier2", "tier3"]) +}); + export async function createCheckoutSession( req: Request, res: Response, @@ -47,6 +56,18 @@ export async function createCheckoutSession( const { orgId } = parsedParams.data; + const parsedBody = createCheckoutSessionBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { tier } = parsedBody.data; + // check if we already have a customer for this org const [customer] = await db .select() @@ -65,20 +86,26 @@ export async function createCheckoutSession( ); } - const standardTierPrice = getTierPriceSet()[TierId.STANDARD]; + let lineItems: Stripe.Checkout.SessionCreateParams.LineItem[]; + if (tier === "tier1") { + lineItems = await getLineItems(getTier1FeaturePriceSet(), orgId); + } else if (tier === "tier2") { + lineItems = await getLineItems(getTier2FeaturePriceSet(), orgId); + } else if (tier === "tier3") { + lineItems = await getLineItems(getTier3FeaturePriceSet(), orgId); + } else { + return next(createHttpError(HttpCode.BAD_REQUEST, "Invalid plan")); + } + + logger.debug(`Line items: ${JSON.stringify(lineItems)}`); const session = await stripe!.checkout.sessions.create({ client_reference_id: orgId, // So we can look it up the org later on the webhook billing_address_collection: "required", - line_items: [ - { - price: standardTierPrice, // Use the standard tier - quantity: 1 - }, - ...getLineItems(getStandardFeaturePriceSet()) - ], // Start with the standard feature set that matches the free limits + line_items: lineItems, customer: customer.customerId, mode: "subscription", + allow_promotion_codes: true, success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?success=true&session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/billing?canceled=true` }); @@ -87,7 +114,7 @@ export async function createCheckoutSession( data: session.url, success: true, error: false, - message: "Organization created successfully", + message: "Checkout session created successfully", status: HttpCode.CREATED }); } catch (error) { diff --git a/server/private/routers/billing/featureLifecycle.ts b/server/private/routers/billing/featureLifecycle.ts new file mode 100644 index 000000000..d86e23cf0 --- /dev/null +++ b/server/private/routers/billing/featureLifecycle.ts @@ -0,0 +1,542 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { SubscriptionType } from "./hooks/getSubType"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; +import { Tier } from "@server/types/Tiers"; +import logger from "@server/logger"; +import { + db, + idp, + idpOrg, + loginPage, + loginPageBranding, + loginPageBrandingOrg, + loginPageOrg, + orgs, + resources, + roles, + siteResources, + userOrgRoles, + siteProvisioningKeyOrg, + siteProvisioningKeys, +} from "@server/db"; +import { and, eq } from "drizzle-orm"; + +/** + * Get the maximum allowed retention days for a given tier + * Returns null for enterprise tier (unlimited) + */ +function getMaxRetentionDaysForTier(tier: Tier | null): number | null { + if (!tier) { + return 3; // Free tier + } + + switch (tier) { + case "tier1": + return 7; + case "tier2": + return 30; + case "tier3": + return 90; + case "enterprise": + return null; // No limit + default: + return 3; // Default to free tier limit + } +} + +/** + * Cap retention days to the maximum allowed for the given tier + */ +async function capRetentionDays( + orgId: string, + tier: Tier | null +): Promise { + const maxRetentionDays = getMaxRetentionDaysForTier(tier); + + // If there's no limit (enterprise tier), no capping needed + if (maxRetentionDays === null) { + logger.debug( + `No retention day limit for org ${orgId} on tier ${tier || "free"}` + ); + return; + } + + // Get current org settings + const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); + + if (!org) { + logger.warn(`Org ${orgId} not found when capping retention days`); + return; + } + + const updates: Partial = {}; + let needsUpdate = false; + + // Cap request log retention if it exceeds the limit + if ( + org.settingsLogRetentionDaysRequest !== null && + org.settingsLogRetentionDaysRequest > maxRetentionDays + ) { + updates.settingsLogRetentionDaysRequest = maxRetentionDays; + needsUpdate = true; + logger.info( + `Capping request log retention from ${org.settingsLogRetentionDaysRequest} to ${maxRetentionDays} days for org ${orgId}` + ); + } + + // Cap access log retention if it exceeds the limit + if ( + org.settingsLogRetentionDaysAccess !== null && + org.settingsLogRetentionDaysAccess > maxRetentionDays + ) { + updates.settingsLogRetentionDaysAccess = maxRetentionDays; + needsUpdate = true; + logger.info( + `Capping access log retention from ${org.settingsLogRetentionDaysAccess} to ${maxRetentionDays} days for org ${orgId}` + ); + } + + // Cap action log retention if it exceeds the limit + if ( + org.settingsLogRetentionDaysAction !== null && + org.settingsLogRetentionDaysAction > maxRetentionDays + ) { + updates.settingsLogRetentionDaysAction = maxRetentionDays; + needsUpdate = true; + logger.info( + `Capping action log retention from ${org.settingsLogRetentionDaysAction} to ${maxRetentionDays} days for org ${orgId}` + ); + } + + // Cap action log retention if it exceeds the limit + if ( + org.settingsLogRetentionDaysConnection !== null && + org.settingsLogRetentionDaysConnection > maxRetentionDays + ) { + updates.settingsLogRetentionDaysConnection = maxRetentionDays; + needsUpdate = true; + logger.info( + `Capping connection log retention from ${org.settingsLogRetentionDaysConnection} to ${maxRetentionDays} days for org ${orgId}` + ); + } + + // Apply updates if needed + if (needsUpdate) { + await db.update(orgs).set(updates).where(eq(orgs.orgId, orgId)); + + logger.info( + `Successfully capped retention days for org ${orgId} to max ${maxRetentionDays} days` + ); + } else { + logger.debug(`No retention day capping needed for org ${orgId}`); + } +} + +export async function handleTierChange( + orgId: string, + newTier: SubscriptionType | null, + previousTier?: SubscriptionType | null +): Promise { + logger.info( + `Handling tier change for org ${orgId}: ${previousTier || "none"} -> ${newTier || "free"}` + ); + + // Get all orgs that have this orgId as their billingOrgId + const associatedOrgs = await db + .select() + .from(orgs) + .where(eq(orgs.billingOrgId, orgId)); + + logger.info( + `Found ${associatedOrgs.length} org(s) associated with billing org ${orgId}` + ); + + // Loop over all associated orgs and apply tier changes + for (const org of associatedOrgs) { + await handleTierChangeForOrg(org.orgId, newTier, previousTier); + } + + logger.info( + `Completed tier change handling for all orgs associated with billing org ${orgId}` + ); +} + +async function handleTierChangeForOrg( + orgId: string, + newTier: SubscriptionType | null, + previousTier?: SubscriptionType | null +): Promise { + logger.info( + `Handling tier change for org ${orgId}: ${previousTier || "none"} -> ${newTier || "free"}` + ); + + // License subscriptions are handled separately and don't use the tier matrix + if (newTier === "license") { + logger.debug( + `New tier is license for org ${orgId}, no feature lifecycle handling needed` + ); + return; + } + + // If newTier is null, treat as free tier - disable all features + if (newTier === null) { + logger.info( + `Org ${orgId} is reverting to free tier, disabling all paid features` + ); + // Cap retention days to free tier limits + await capRetentionDays(orgId, null); + + // Disable all features in the tier matrix + for (const [featureKey] of Object.entries(tierMatrix)) { + const feature = featureKey as TierFeature; + logger.info( + `Feature ${feature} is not available in free tier for org ${orgId}. Disabling...` + ); + await disableFeature(orgId, feature); + } + logger.info( + `Completed free tier feature lifecycle handling for org ${orgId}` + ); + return; + } + + // Get the tier (cast as Tier since we've ruled out "license" and null) + const tier = newTier as Tier; + + // Cap retention days to the new tier's limits + await capRetentionDays(orgId, tier); + + // Check each feature in the tier matrix + for (const [featureKey, allowedTiers] of Object.entries(tierMatrix)) { + const feature = featureKey as TierFeature; + const isFeatureAvailable = allowedTiers.includes(tier); + + if (!isFeatureAvailable) { + logger.info( + `Feature ${feature} is not available in tier ${tier} for org ${orgId}. Disabling...` + ); + await disableFeature(orgId, feature); + } else { + logger.debug( + `Feature ${feature} is available in tier ${tier} for org ${orgId}` + ); + } + } + + logger.info( + `Completed tier change feature lifecycle handling for org ${orgId}` + ); +} + +async function disableFeature( + orgId: string, + feature: TierFeature +): Promise { + try { + switch (feature) { + case TierFeature.OrgOidc: + await disableOrgOidc(orgId); + break; + + case TierFeature.LoginPageDomain: + await disableLoginPageDomain(orgId); + break; + + case TierFeature.DeviceApprovals: + await disableDeviceApprovals(orgId); + break; + + case TierFeature.LoginPageBranding: + await disableLoginPageBranding(orgId); + break; + + case TierFeature.LogExport: + await disableLogExport(orgId); + break; + + case TierFeature.AccessLogs: + await disableAccessLogs(orgId); + break; + + case TierFeature.ActionLogs: + await disableActionLogs(orgId); + break; + + case TierFeature.ConnectionLogs: + await disableConnectionLogs(orgId); + break; + + case TierFeature.RotateCredentials: + await disableRotateCredentials(orgId); + break; + + case TierFeature.MaintencePage: + await disableMaintencePage(orgId); + break; + + case TierFeature.DevicePosture: + await disableDevicePosture(orgId); + break; + + case TierFeature.TwoFactorEnforcement: + await disableTwoFactorEnforcement(orgId); + break; + + case TierFeature.SessionDurationPolicies: + await disableSessionDurationPolicies(orgId); + break; + + case TierFeature.PasswordExpirationPolicies: + await disablePasswordExpirationPolicies(orgId); + break; + + case TierFeature.AutoProvisioning: + await disableAutoProvisioning(orgId); + break; + + case TierFeature.SshPam: + await disableSshPam(orgId); + break; + + case TierFeature.FullRbac: + await disableFullRbac(orgId); + break; + + case TierFeature.SiteProvisioningKeys: + await disableSiteProvisioningKeys(orgId); + break; + + default: + logger.warn( + `Unknown feature ${feature} for org ${orgId}, skipping` + ); + } + + logger.info( + `Successfully disabled feature ${feature} for org ${orgId}` + ); + } catch (error) { + logger.error( + `Error disabling feature ${feature} for org ${orgId}:`, + error + ); + throw error; + } +} + +async function disableOrgOidc(orgId: string): Promise {} + +async function disableDeviceApprovals(orgId: string): Promise { + await db + .update(roles) + .set({ requireDeviceApproval: false }) + .where(eq(roles.orgId, orgId)); + + logger.info(`Disabled device approvals on all roles for org ${orgId}`); +} + +async function disableSshPam(orgId: string): Promise { + logger.info( + `Disabled SSH PAM options on all roles and site resources for org ${orgId}` + ); +} + +async function disableFullRbac(orgId: string): Promise { + logger.info(`Disabled full RBAC for org ${orgId}`); +} + +async function disableSiteProvisioningKeys(orgId: string): Promise { + const rows = await db + .select({ + siteProvisioningKeyId: + siteProvisioningKeyOrg.siteProvisioningKeyId + }) + .from(siteProvisioningKeyOrg) + .where(eq(siteProvisioningKeyOrg.orgId, orgId)); + + for (const { siteProvisioningKeyId } of rows) { + await db.transaction(async (trx) => { + await trx + .delete(siteProvisioningKeyOrg) + .where( + and( + eq( + siteProvisioningKeyOrg.siteProvisioningKeyId, + siteProvisioningKeyId + ), + eq(siteProvisioningKeyOrg.orgId, orgId) + ) + ); + + const remaining = await trx + .select() + .from(siteProvisioningKeyOrg) + .where( + eq( + siteProvisioningKeyOrg.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ); + + if (remaining.length === 0) { + await trx + .delete(siteProvisioningKeys) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ); + } + }); + } + + logger.info( + `Removed site provisioning keys for org ${orgId} after tier downgrade` + ); +} + +async function disableLoginPageBranding(orgId: string): Promise { + const [existingBranding] = await db + .select() + .from(loginPageBrandingOrg) + .where(eq(loginPageBrandingOrg.orgId, orgId)); + + if (existingBranding) { + await db + .delete(loginPageBranding) + .where( + eq( + loginPageBranding.loginPageBrandingId, + existingBranding.loginPageBrandingId + ) + ); + + logger.info(`Disabled login page branding for org ${orgId}`); + } +} + +async function disableLoginPageDomain(orgId: string): Promise { + const [existingLoginPage] = await db + .select() + .from(loginPageOrg) + .where(eq(loginPageOrg.orgId, orgId)) + .innerJoin( + loginPage, + eq(loginPage.loginPageId, loginPageOrg.loginPageId) + ); + + if (existingLoginPage) { + await db.delete(loginPageOrg).where(eq(loginPageOrg.orgId, orgId)); + + await db + .delete(loginPage) + .where( + eq( + loginPage.loginPageId, + existingLoginPage.loginPageOrg.loginPageId + ) + ); + + logger.info(`Disabled login page domain for org ${orgId}`); + } +} + +async function disableLogExport(orgId: string): Promise {} + +async function disableAccessLogs(orgId: string): Promise { + await db + .update(orgs) + .set({ settingsLogRetentionDaysAccess: 0 }) + .where(eq(orgs.orgId, orgId)); + + logger.info(`Disabled access logs for org ${orgId}`); +} + +async function disableActionLogs(orgId: string): Promise { + await db + .update(orgs) + .set({ settingsLogRetentionDaysAction: 0 }) + .where(eq(orgs.orgId, orgId)); + + logger.info(`Disabled action logs for org ${orgId}`); +} + +async function disableConnectionLogs(orgId: string): Promise { + await db + .update(orgs) + .set({ settingsLogRetentionDaysConnection: 0 }) + .where(eq(orgs.orgId, orgId)); + + logger.info(`Disabled connection logs for org ${orgId}`); +} + +async function disableRotateCredentials(orgId: string): Promise {} + +async function disableMaintencePage(orgId: string): Promise { + await db + .update(resources) + .set({ + maintenanceModeEnabled: false + }) + .where(eq(resources.orgId, orgId)); + + logger.info(`Disabled maintenance page on all resources for org ${orgId}`); +} + +async function disableDevicePosture(orgId: string): Promise {} + +async function disableTwoFactorEnforcement(orgId: string): Promise { + await db + .update(orgs) + .set({ requireTwoFactor: false }) + .where(eq(orgs.orgId, orgId)); + + logger.info(`Disabled two-factor enforcement for org ${orgId}`); +} + +async function disableSessionDurationPolicies(orgId: string): Promise { + await db + .update(orgs) + .set({ maxSessionLengthHours: null }) + .where(eq(orgs.orgId, orgId)); + + logger.info(`Disabled session duration policies for org ${orgId}`); +} + +async function disablePasswordExpirationPolicies(orgId: string): Promise { + await db + .update(orgs) + .set({ passwordExpiryDays: null }) + .where(eq(orgs.orgId, orgId)); + + logger.info(`Disabled password expiration policies for org ${orgId}`); +} + +async function disableAutoProvisioning(orgId: string): Promise { + // Get all IDP IDs for this org through the idpOrg join table + const orgIdps = await db + .select({ idpId: idpOrg.idpId }) + .from(idpOrg) + .where(eq(idpOrg.orgId, orgId)); + + // Update autoProvision to false for all IDPs in this org + for (const { idpId } of orgIdps) { + await db + .update(idp) + .set({ autoProvision: false }) + .where(eq(idp.idpId, idpId)); + } +} diff --git a/server/private/routers/billing/getOrgSubscription.ts b/server/private/routers/billing/getOrgSubscriptions.ts similarity index 70% rename from server/private/routers/billing/getOrgSubscription.ts rename to server/private/routers/billing/getOrgSubscriptions.ts index e1f8316ef..718c98f46 100644 --- a/server/private/routers/billing/getOrgSubscription.ts +++ b/server/private/routers/billing/getOrgSubscriptions.ts @@ -23,6 +23,8 @@ import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { GetOrgSubscriptionResponse } from "@server/routers/billing/types"; +import { usageService } from "@server/lib/billing/usageService"; +import { build } from "@server/build"; // Import tables for billing import { @@ -37,18 +39,7 @@ const getOrgSchema = z.strictObject({ orgId: z.string() }); -registry.registerPath({ - method: "get", - path: "/org/{orgId}/billing/subscription", - description: "Get an organization", - tags: [OpenAPITags.Org], - request: { - params: getOrgSchema - }, - responses: {} -}); - -export async function getOrgSubscription( +export async function getOrgSubscriptions( req: Request, res: Response, next: NextFunction @@ -66,12 +57,9 @@ export async function getOrgSubscription( const { orgId } = parsedParams.data; - let subscriptionData = null; - let itemsData: SubscriptionItem[] = []; + let subscriptions = null; try { - const { subscription, items } = await getOrgSubscriptionData(orgId); - subscriptionData = subscription; - itemsData = items; + subscriptions = await getOrgSubscriptionsData(orgId); } catch (err) { if ((err as Error).message === "Not found") { return next( @@ -84,10 +72,19 @@ export async function getOrgSubscription( throw err; } + let limitsExceeded = false; + if (build === "saas") { + try { + limitsExceeded = await usageService.checkLimitSet(orgId); + } catch (err) { + logger.error("Error checking limits for org %s: %s", orgId, err); + } + } + return response(res, { data: { - subscription: subscriptionData, - items: itemsData + subscriptions, + ...(build === "saas" ? { limitsExceeded } : {}) }, success: true, error: false, @@ -102,9 +99,9 @@ export async function getOrgSubscription( } } -export async function getOrgSubscriptionData( +export async function getOrgSubscriptionsData( orgId: string -): Promise<{ subscription: Subscription | null; items: SubscriptionItem[] }> { +): Promise> { const org = await db .select() .from(orgs) @@ -115,28 +112,30 @@ export async function getOrgSubscriptionData( throw new Error(`Not found`); } + const billingOrgId = org[0].billingOrgId || org[0].orgId; + // Get customer for org const customer = await db .select() .from(customers) - .where(eq(customers.orgId, orgId)) + .where(eq(customers.orgId, billingOrgId)) .limit(1); - let subscription = null; - let items: SubscriptionItem[] = []; + const subscriptionsWithItems: Array<{ + subscription: Subscription; + items: SubscriptionItem[]; + }> = []; if (customer.length > 0) { - // Get subscription for customer + // Get all subscriptions for customer const subs = await db .select() .from(subscriptions) - .where(eq(subscriptions.customerId, customer[0].customerId)) - .limit(1); + .where(eq(subscriptions.customerId, customer[0].customerId)); - if (subs.length > 0) { - subscription = subs[0]; - // Get subscription items - items = await db + for (const subscription of subs) { + // Get subscription items for each subscription + const items = await db .select() .from(subscriptionItems) .where( @@ -145,8 +144,13 @@ export async function getOrgSubscriptionData( subscription.subscriptionId ) ); + + subscriptionsWithItems.push({ + subscription, + items + }); } } - return { subscription, items }; + return subscriptionsWithItems; } diff --git a/server/private/routers/billing/getOrgUsage.ts b/server/private/routers/billing/getOrgUsage.ts index 1a3437306..cc722cec8 100644 --- a/server/private/routers/billing/getOrgUsage.ts +++ b/server/private/routers/billing/getOrgUsage.ts @@ -31,16 +31,16 @@ const getOrgSchema = z.strictObject({ orgId: z.string() }); -registry.registerPath({ - method: "get", - path: "/org/{orgId}/billing/usage", - description: "Get an organization's billing usage", - tags: [OpenAPITags.Org], - request: { - params: getOrgSchema - }, - responses: {} -}); +// registry.registerPath({ +// method: "get", +// path: "/org/{orgId}/billing/usage", +// description: "Get an organization's billing usage", +// tags: [OpenAPITags.Org], +// request: { +// params: getOrgSchema +// }, +// responses: {} +// }); export async function getOrgUsage( req: Request, @@ -78,39 +78,40 @@ export async function getOrgUsage( // Get usage for org const usageData = []; - const siteUptime = await usageService.getUsage( - orgId, - FeatureId.SITE_UPTIME - ); - const users = await usageService.getUsageDaily(orgId, FeatureId.USERS); - const domains = await usageService.getUsageDaily( - orgId, - FeatureId.DOMAINS - ); - const remoteExitNodes = await usageService.getUsageDaily( + const sites = await usageService.getUsage(orgId, FeatureId.SITES); + const users = await usageService.getUsage(orgId, FeatureId.USERS); + const domains = await usageService.getUsage(orgId, FeatureId.DOMAINS); + const remoteExitNodes = await usageService.getUsage( orgId, FeatureId.REMOTE_EXIT_NODES ); - const egressData = await usageService.getUsage( + const organizations = await usageService.getUsage( orgId, - FeatureId.EGRESS_DATA_MB + FeatureId.ORGINIZATIONS ); + // const egressData = await usageService.getUsage( + // orgId, + // FeatureId.EGRESS_DATA_MB + // ); - if (siteUptime) { - usageData.push(siteUptime); + if (sites) { + usageData.push(sites); } if (users) { usageData.push(users); } - if (egressData) { - usageData.push(egressData); - } + // if (egressData) { + // usageData.push(egressData); + // } if (domains) { usageData.push(domains); } if (remoteExitNodes) { usageData.push(remoteExitNodes); } + if (organizations) { + usageData.push(organizations); + } const orgLimits = await db .select() diff --git a/server/private/routers/billing/hooks/getSubType.ts b/server/private/routers/billing/hooks/getSubType.ts new file mode 100644 index 000000000..44cfe0026 --- /dev/null +++ b/server/private/routers/billing/hooks/getSubType.ts @@ -0,0 +1,62 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { + getLicensePriceSet, +} from "@server/lib/billing/licenses"; +import { + getTier1FeaturePriceSet, + getTier2FeaturePriceSet, + getTier3FeaturePriceSet, +} from "@server/lib/billing/features"; +import Stripe from "stripe"; +import { Tier } from "@server/types/Tiers"; + +export type SubscriptionType = Tier | "license"; + +export function getSubType(fullSubscription: Stripe.Response): SubscriptionType | null { + // Determine subscription type by checking subscription items + if (!Array.isArray(fullSubscription.items?.data) || fullSubscription.items.data.length === 0) { + return null; + } + + for (const item of fullSubscription.items.data) { + const priceId = item.price.id; + + // Check if price ID matches any license price + const licensePrices = Object.values(getLicensePriceSet()); + if (licensePrices.includes(priceId)) { + return "license"; + } + + // Check if price ID matches home lab tier + const homeLabPrices = Object.values(getTier1FeaturePriceSet()); + if (homeLabPrices.includes(priceId)) { + return "tier1"; + } + + // Check if price ID matches tier2 tier + const tier2Prices = Object.values(getTier2FeaturePriceSet()); + if (tier2Prices.includes(priceId)) { + return "tier2"; + } + + // Check if price ID matches tier3 tier + const tier3Prices = Object.values(getTier3FeaturePriceSet()); + if (tier3Prices.includes(priceId)) { + return "tier3"; + } + } + + return null; +} diff --git a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts index 223a2545e..a40142526 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionCreated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionCreated.ts @@ -24,7 +24,14 @@ import { eq, and } from "drizzle-orm"; import logger from "@server/logger"; import stripe from "#private/lib/stripe"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; -import { AudienceIds, moveEmailToAudience } from "#private/lib/resend"; +import { getSubType } from "./getSubType"; +import privateConfig from "#private/lib/config"; +import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses"; +import { sendEmail } from "@server/emails"; +import EnterpriseEditionKeyGenerated from "@server/emails/templates/EnterpriseEditionKeyGenerated"; +import config from "@server/lib/config"; +import { getFeatureIdByPriceId } from "@server/lib/billing/features"; +import { handleTierChange } from "../featureLifecycle"; export async function handleSubscriptionCreated( subscription: Stripe.Subscription @@ -53,6 +60,8 @@ export async function handleSubscriptionCreated( return; } + const type = getSubType(fullSubscription); + const newSubscription = { subscriptionId: subscription.id, customerId: subscription.customer as string, @@ -60,7 +69,9 @@ export async function handleSubscriptionCreated( canceledAt: subscription.canceled_at ? subscription.canceled_at : null, - createdAt: subscription.created + createdAt: subscription.created, + type: type, + version: 1 // we are hardcoding the initial version when the subscription is created, and then we will increment it on every update }; await db.insert(subscriptions).values(newSubscription); @@ -81,10 +92,15 @@ export async function handleSubscriptionCreated( name = product.name || null; } + // Get the feature ID from the price ID + const featureId = getFeatureIdByPriceId(item.price.id); + return { + stripeSubscriptionItemId: item.id, subscriptionId: subscription.id, planId: item.plan.id, priceId: item.price.id, + featureId: featureId || null, meterId: item.plan.meter, unitAmount: item.price.unit_amount || 0, currentPeriodStart: item.current_period_start, @@ -123,24 +139,148 @@ export async function handleSubscriptionCreated( return; } - await handleSubscriptionLifesycle(customer.orgId, subscription.status); + if (type === "tier1" || type === "tier2" || type === "tier3") { + logger.debug( + `Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}` + ); + // we only need to handle the limit lifecycle for saas subscriptions not for the licenses + await handleSubscriptionLifesycle( + customer.orgId, + subscription.status, + type + ); - const [orgUserRes] = await db - .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.orgId, customer.orgId), - eq(userOrgs.isOwner, true) + // Handle initial tier setup - disable features not available in this tier + logger.info( + `Setting up initial tier features for org ${customer.orgId} with type ${type}` + ); + await handleTierChange(customer.orgId, type); + + const [orgUserRes] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.orgId, customer.orgId), + eq(userOrgs.isOwner, true) + ) ) - ) - .innerJoin(users, eq(userOrgs.userId, users.userId)); + .innerJoin(users, eq(userOrgs.userId, users.userId)); - if (orgUserRes) { - const email = orgUserRes.user.email; + if (orgUserRes) { + const email = orgUserRes.user.email; - if (email) { - moveEmailToAudience(email, AudienceIds.Subscribed); + if (email) { + // TODO: update user in Sendy + } + } + } else if (type === "license") { + logger.debug( + `License subscription created for org ${customer.orgId}, no lifecycle handling needed.` + ); + + // Retrieve the client_reference_id from the checkout session + let licenseId: string | null = null; + + try { + const sessions = await stripe!.checkout.sessions.list({ + subscription: subscription.id, + limit: 1 + }); + if (sessions.data.length > 0) { + licenseId = sessions.data[0].client_reference_id || null; + } + + if (!licenseId) { + logger.error( + `No client_reference_id found for subscription ${subscription.id}` + ); + return; + } + + logger.debug( + `Retrieved licenseId ${licenseId} from checkout session for subscription ${subscription.id}` + ); + + // Determine users and sites based on license type + const priceSet = getLicensePriceSet(); + const subscriptionPriceId = + fullSubscription.items.data[0]?.price.id; + + let numUsers: number; + let numSites: number; + + if (subscriptionPriceId === priceSet[LicenseId.SMALL_LICENSE]) { + numUsers = 25; + numSites = 25; + } else if ( + subscriptionPriceId === priceSet[LicenseId.BIG_LICENSE] + ) { + numUsers = 50; + numSites = 50; + } else { + logger.error( + `Unknown price ID ${subscriptionPriceId} for subscription ${subscription.id}` + ); + return; + } + + logger.debug( + `License type determined: ${numUsers} users, ${numSites} sites for subscription ${subscription.id}` + ); + + const response = await fetch( + `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/paid-for`, + { + method: "POST", + headers: { + "api-key": + privateConfig.getRawPrivateConfig().server + .fossorial_api_key!, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + licenseId: parseInt(licenseId), + paidFor: true, + users: numUsers, + sites: numSites + }) + } + ); + + const data = await response.json(); + + logger.debug(`Fossorial API response: ${JSON.stringify(data)}`); + + if (customer.email) { + logger.debug( + `Sending license key email to ${customer.email} for subscription ${subscription.id}` + ); + await sendEmail( + EnterpriseEditionKeyGenerated({ + keyValue: data.data.licenseKey, + personalUseOnly: false, + users: numUsers, + sites: numSites, + modifySubscriptionLink: `${config.getRawConfig().app.dashboard_url}/${customer.orgId}/settings/billing` + }), + { + to: customer.email, + from: config.getNoReplyEmail(), + subject: + "Your Enterprise Edition license key is ready" + } + ); + } else { + logger.error( + `No email found for customer ${customer.customerId} to send license key.` + ); + } + + return data; + } catch (error) { + console.error("Error creating new license:", error); + throw error; } } } catch (error) { diff --git a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts index 7a7d91492..a029fc5c3 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionDeleted.ts @@ -23,12 +23,23 @@ import { import { eq, and } from "drizzle-orm"; import logger from "@server/logger"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; -import { AudienceIds, moveEmailToAudience } from "#private/lib/resend"; +import { getSubType } from "./getSubType"; +import stripe from "#private/lib/stripe"; +import privateConfig from "#private/lib/config"; +import { handleTierChange } from "../featureLifecycle"; export async function handleSubscriptionDeleted( subscription: Stripe.Subscription ): Promise { try { + // Fetch the subscription from Stripe with expanded price.tiers + const fullSubscription = await stripe!.subscriptions.retrieve( + subscription.id, + { + expand: ["items.data.price.tiers"] + } + ); + const [existingSubscription] = await db .select() .from(subscriptions) @@ -64,24 +75,69 @@ export async function handleSubscriptionDeleted( return; } - await handleSubscriptionLifesycle(customer.orgId, subscription.status); + const type = getSubType(fullSubscription); + if (type == "tier1" || type == "tier2" || type == "tier3") { + logger.debug( + `Handling SaaS subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}` + ); - const [orgUserRes] = await db - .select() - .from(userOrgs) - .where( - and( - eq(userOrgs.orgId, customer.orgId), - eq(userOrgs.isOwner, true) + await handleSubscriptionLifesycle( + customer.orgId, + subscription.status, + type + ); + + // Handle feature lifecycle for cancellation - disable all tier-specific features + logger.info( + `Disabling tier-specific features for org ${customer.orgId} due to subscription deletion` + ); + await handleTierChange(customer.orgId, null, type); + + const [orgUserRes] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.orgId, customer.orgId), + eq(userOrgs.isOwner, true) + ) ) - ) - .innerJoin(users, eq(userOrgs.userId, users.userId)); + .innerJoin(users, eq(userOrgs.userId, users.userId)); - if (orgUserRes) { - const email = orgUserRes.user.email; + if (orgUserRes) { + const email = orgUserRes.user.email; - if (email) { - moveEmailToAudience(email, AudienceIds.Churned); + if (email) { + // TODO: update user in Sendy + } + } + } else if (type === "license") { + logger.debug( + `Handling license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}` + ); + try { + // WARNING: + // this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId + await fetch( + `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`, + { + method: "POST", + headers: { + "api-key": + privateConfig.getRawPrivateConfig().server + .fossorial_api_key!, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + orgId: customer.orgId, + }) + } + ); + } catch (error) { + logger.error( + `Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`, + error + ); } } } catch (error) { diff --git a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts index 010860542..0305e7f1b 100644 --- a/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts +++ b/server/private/routers/billing/hooks/handleSubscriptionUpdated.ts @@ -23,9 +23,12 @@ import { } from "@server/db"; import { eq, and } from "drizzle-orm"; import logger from "@server/logger"; -import { getFeatureIdByMetricId } from "@server/lib/billing/features"; +import { getFeatureIdByMetricId, getFeatureIdByPriceId } from "@server/lib/billing/features"; import stripe from "#private/lib/stripe"; import { handleSubscriptionLifesycle } from "../subscriptionLifecycle"; +import { getSubType, SubscriptionType } from "./getSubType"; +import privateConfig from "#private/lib/config"; +import { handleTierChange } from "../featureLifecycle"; export async function handleSubscriptionUpdated( subscription: Stripe.Subscription, @@ -56,12 +59,15 @@ export async function handleSubscriptionUpdated( } // get the customer - const [existingCustomer] = await db + const [customer] = await db .select() .from(customers) .where(eq(customers.customerId, subscription.customer as string)) .limit(1); + const type = getSubType(fullSubscription); + const previousType = existingSubscription.type as SubscriptionType | null; + await db .update(subscriptions) .set({ @@ -70,30 +76,55 @@ export async function handleSubscriptionUpdated( ? subscription.canceled_at : null, updatedAt: Math.floor(Date.now() / 1000), - billingCycleAnchor: subscription.billing_cycle_anchor + billingCycleAnchor: subscription.billing_cycle_anchor, + type: type }) .where(eq(subscriptions.subscriptionId, subscription.id)); - await handleSubscriptionLifesycle( - existingCustomer.orgId, - subscription.status - ); + // Handle tier change if the subscription type changed + if (type && type !== previousType) { + logger.info( + `Tier change detected for org ${customer.orgId}: ${previousType} -> ${type}` + ); + await handleTierChange(customer.orgId, type, previousType ?? undefined); + } // Upsert subscription items if (Array.isArray(fullSubscription.items?.data)) { - const itemsToUpsert = fullSubscription.items.data.map((item) => ({ - subscriptionId: subscription.id, - planId: item.plan.id, - priceId: item.price.id, - meterId: item.plan.meter, - unitAmount: item.price.unit_amount || 0, - currentPeriodStart: item.current_period_start, - currentPeriodEnd: item.current_period_end, - tiers: item.price.tiers - ? JSON.stringify(item.price.tiers) - : null, - interval: item.plan.interval - })); + // First, get existing items to preserve featureId when there's no match + const existingItems = await db + .select() + .from(subscriptionItems) + .where(eq(subscriptionItems.subscriptionId, subscription.id)); + + const itemsToUpsert = fullSubscription.items.data.map((item) => { + // Try to get featureId from price + let featureId: string | null = getFeatureIdByPriceId(item.price.id) || null; + + // If no match, try to preserve existing featureId + if (!featureId) { + const existingItem = existingItems.find( + (ei) => ei.stripeSubscriptionItemId === item.id + ); + featureId = existingItem?.featureId || null; + } + + return { + stripeSubscriptionItemId: item.id, + subscriptionId: subscription.id, + planId: item.plan.id, + priceId: item.price.id, + featureId: featureId, + meterId: item.plan.meter, + unitAmount: item.price.unit_amount || 0, + currentPeriodStart: item.current_period_start, + currentPeriodEnd: item.current_period_end, + tiers: item.price.tiers + ? JSON.stringify(item.price.tiers) + : null, + interval: item.plan.interval + }; + }); if (itemsToUpsert.length > 0) { await db.transaction(async (trx) => { await trx @@ -141,23 +172,23 @@ export async function handleSubscriptionUpdated( // This item has cycled const meterId = item.plan.meter; if (!meterId) { - logger.warn( + logger.debug( `No meterId found for subscription item ${item.id}. Skipping usage reset.` ); continue; } const featureId = getFeatureIdByMetricId(meterId); if (!featureId) { - logger.warn( + logger.debug( `No featureId found for meterId ${meterId}. Skipping usage reset.` ); continue; } - const orgId = existingCustomer.orgId; + const orgId = customer.orgId; if (!orgId) { - logger.warn( + logger.debug( `No orgId found in subscription metadata for subscription ${subscription.id}. Skipping usage reset.` ); continue; @@ -236,6 +267,57 @@ export async function handleSubscriptionUpdated( } } // --- end usage update --- + + if (type === "tier1" || type === "tier2" || type === "tier3") { + logger.debug( + `Handling SAAS subscription lifecycle for org ${customer.orgId} with type ${type}` + ); + // we only need to handle the limit lifecycle for saas subscriptions not for the licenses + await handleSubscriptionLifesycle( + customer.orgId, + subscription.status, + type + ); + + // Handle feature lifecycle when subscription is canceled or becomes unpaid + if ( + subscription.status === "canceled" || + subscription.status === "unpaid" || + subscription.status === "incomplete_expired" + ) { + logger.info( + `Subscription ${subscription.id} for org ${customer.orgId} is ${subscription.status}, disabling paid features` + ); + await handleTierChange(customer.orgId, null, previousType ?? undefined); + } + } else if (type === "license") { + if (subscription.status === "canceled" || subscription.status == "unpaid" || subscription.status == "incomplete_expired") { + try { + // WARNING: + // this invalidates ALL OF THE ENTERPRISE LICENSES for this orgId + await fetch( + `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/invalidate`, + { + method: "POST", + headers: { + "api-key": + privateConfig.getRawPrivateConfig() + .server.fossorial_api_key!, + "Content-Type": "application/json" + }, + body: JSON.stringify({ + orgId: customer.orgId + }) + } + ); + } catch (error) { + logger.error( + `Error notifying Fossorial API of license subscription deletion for orgId ${customer.orgId} and subscription ID ${subscription.id}:`, + error + ); + } + } + } } } catch (error) { logger.error( diff --git a/server/private/routers/billing/index.ts b/server/private/routers/billing/index.ts index 59fce8d62..6555f5499 100644 --- a/server/private/routers/billing/index.ts +++ b/server/private/routers/billing/index.ts @@ -13,6 +13,7 @@ export * from "./createCheckoutSession"; export * from "./createPortalSession"; -export * from "./getOrgSubscription"; +export * from "./getOrgSubscriptions"; export * from "./getOrgUsage"; export * from "./internalGetOrgTier"; +export * from "./changeTier"; diff --git a/server/private/routers/billing/subscriptionLifecycle.ts b/server/private/routers/billing/subscriptionLifecycle.ts index 0fc75835e..a80f64c0a 100644 --- a/server/private/routers/billing/subscriptionLifecycle.ts +++ b/server/private/routers/billing/subscriptionLifecycle.ts @@ -13,38 +13,66 @@ import { freeLimitSet, + tier1LimitSet, + tier2LimitSet, + tier3LimitSet, limitsService, - subscribedLimitSet + LimitSet } from "@server/lib/billing"; import { usageService } from "@server/lib/billing/usageService"; -import logger from "@server/logger"; +import { SubscriptionType } from "./hooks/getSubType"; + +function getLimitSetForSubscriptionType( + subType: SubscriptionType | null +): LimitSet { + switch (subType) { + case "tier1": + return tier1LimitSet; + case "tier2": + return tier2LimitSet; + case "tier3": + return tier3LimitSet; + case "license": + // License subscriptions use tier2 limits by default + // This can be adjusted based on your business logic + return tier2LimitSet; + default: + return freeLimitSet; + } +} export async function handleSubscriptionLifesycle( orgId: string, - status: string + status: string, + subType: SubscriptionType | null ) { switch (status) { case "active": - await limitsService.applyLimitSetToOrg(orgId, subscribedLimitSet); - await usageService.checkLimitSet(orgId, true); + const activeLimitSet = getLimitSetForSubscriptionType(subType); + await limitsService.applyLimitSetToOrg(orgId, activeLimitSet); + await usageService.checkLimitSet(orgId); break; case "canceled": + // Subscription canceled - revert to free tier await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); - await usageService.checkLimitSet(orgId, true); + await usageService.checkLimitSet(orgId); break; case "past_due": - // Optionally handle past due status, e.g., notify customer + // Payment past due - keep current limits but notify customer + // Limits will revert to free tier if it becomes unpaid break; case "unpaid": + // Subscription unpaid - revert to free tier await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); - await usageService.checkLimitSet(orgId, true); + await usageService.checkLimitSet(orgId); break; case "incomplete": - // Optionally handle incomplete status, e.g., notify customer + // Payment incomplete - give them time to complete payment break; case "incomplete_expired": + // Payment never completed - revert to free tier await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); - await usageService.checkLimitSet(orgId, true); + await usageService.checkLimitSet(orgId); break; default: break; diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 568f2b350..412895a41 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -24,13 +24,22 @@ import * as generateLicense from "./generatedLicense"; import * as logs from "#private/routers/auditLogs"; import * as misc from "#private/routers/misc"; import * as reKey from "#private/routers/re-key"; +import * as approval from "#private/routers/approvals"; +import * as ssh from "#private/routers/ssh"; +import * as user from "#private/routers/user"; +import * as siteProvisioning from "#private/routers/siteProvisioning"; import { verifyOrgAccess, verifyUserHasAction, verifyUserIsServerAdmin, verifySiteAccess, - verifyClientAccess + verifyClientAccess, + verifyLimits, + verifyRoleAccess, + verifyUserAccess, + verifyUserCanSetUserOrgRoles, + verifySiteProvisioningKeyAccess } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import { @@ -51,6 +60,7 @@ import { authenticated as a, authRouter as aa } from "@server/routers/external"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; export const authenticated = a; export const unauthenticated = ua; @@ -75,7 +85,9 @@ unauthenticated.post( authenticated.put( "/org/:orgId/idp/oidc", verifyValidLicense, + verifyValidSubscription(tierMatrix.orgOidc), verifyOrgAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.createIdp), logActionAudit(ActionsEnum.createIdp), orgIdp.createOrgOidcIdp @@ -84,8 +96,10 @@ authenticated.put( authenticated.post( "/org/:orgId/idp/:idpId/oidc", verifyValidLicense, + verifyValidSubscription(tierMatrix.orgOidc), verifyOrgAccess, verifyIdpAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.updateIdp), logActionAudit(ActionsEnum.updateIdp), orgIdp.updateOrgOidcIdp @@ -134,29 +148,13 @@ authenticated.post( verifyValidLicense, verifyOrgAccess, verifyCertificateAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.restartCertificate), logActionAudit(ActionsEnum.restartCertificate), certificates.restartCertificate ); if (build === "saas") { - unauthenticated.post( - "/quick-start", - rateLimit({ - windowMs: 15 * 60 * 1000, - max: 100, - keyGenerator: (req) => req.path, - handler: (req, res, next) => { - const message = `We're too busy right now. Please try again later.`; - return next( - createHttpError(HttpCode.TOO_MANY_REQUESTS, message) - ); - }, - store: createStore() - }), - auth.quickStart - ); - authenticated.post( "/org/:orgId/billing/create-checkout-session", verifyOrgAccess, @@ -165,6 +163,14 @@ if (build === "saas") { billing.createCheckoutSession ); + authenticated.post( + "/org/:orgId/billing/change-tier", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.billing), + logActionAudit(ActionsEnum.billing), + billing.changeTier + ); + authenticated.post( "/org/:orgId/billing/create-portal-session", verifyOrgAccess, @@ -174,10 +180,10 @@ if (build === "saas") { ); authenticated.get( - "/org/:orgId/billing/subscription", + "/org/:orgId/billing/subscriptions", verifyOrgAccess, verifyUserHasAction(ActionsEnum.billing), - billing.getOrgSubscription + billing.getOrgSubscriptions ); authenticated.get( @@ -199,6 +205,14 @@ if (build === "saas") { generateLicense.generateNewLicense ); + authenticated.put( + "/org/:orgId/license/enterprise", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.billing), + logActionAudit(ActionsEnum.billing), + generateLicense.generateNewEnterpriseLicense + ); + authenticated.post( "/send-support-request", rateLimit({ @@ -234,6 +248,7 @@ authenticated.put( "/org/:orgId/remote-exit-node", verifyValidLicense, verifyOrgAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.createRemoteExitNode), logActionAudit(ActionsEnum.createRemoteExitNode), remoteExitNode.createRemoteExitNode @@ -277,7 +292,9 @@ authenticated.delete( authenticated.put( "/org/:orgId/login-page", verifyValidLicense, + verifyValidSubscription(tierMatrix.loginPageDomain), verifyOrgAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.createLoginPage), logActionAudit(ActionsEnum.createLoginPage), loginPage.createLoginPage @@ -286,8 +303,10 @@ authenticated.put( authenticated.post( "/org/:orgId/login-page/:loginPageId", verifyValidLicense, + verifyValidSubscription(tierMatrix.loginPageDomain), verifyOrgAccess, verifyLoginPageAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.updateLoginPage), logActionAudit(ActionsEnum.updateLoginPage), loginPage.updateLoginPage @@ -311,6 +330,64 @@ authenticated.get( loginPage.getLoginPage ); +authenticated.get( + "/org/:orgId/approvals", + verifyValidLicense, + verifyValidSubscription(tierMatrix.deviceApprovals), + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listApprovals), + logActionAudit(ActionsEnum.listApprovals), + approval.listApprovals +); + +authenticated.get( + "/org/:orgId/approvals/count", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listApprovals), + approval.countApprovals +); + +authenticated.put( + "/org/:orgId/approvals/:approvalId", + verifyValidLicense, + verifyValidSubscription(tierMatrix.deviceApprovals), + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.updateApprovals), + logActionAudit(ActionsEnum.updateApprovals), + approval.processPendingApproval +); + +authenticated.get( + "/org/:orgId/login-page-branding", + verifyValidLicense, + verifyValidSubscription(tierMatrix.loginPageBranding), + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.getLoginPage), + logActionAudit(ActionsEnum.getLoginPage), + loginPage.getLoginPageBranding +); + +authenticated.put( + "/org/:orgId/login-page-branding", + verifyValidLicense, + verifyValidSubscription(tierMatrix.loginPageBranding), + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.updateLoginPage), + logActionAudit(ActionsEnum.updateLoginPage), + loginPage.upsertLoginPageBranding +); + +authenticated.delete( + "/org/:orgId/login-page-branding", + verifyValidLicense, + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.deleteLoginPage), + logActionAudit(ActionsEnum.deleteLoginPage), + loginPage.deleteLoginPageBranding +); + authRouter.post( "/remoteExitNode/get-token", verifyValidLicense, @@ -372,7 +449,7 @@ authenticated.post( authenticated.get( "/org/:orgId/logs/action", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.actionLogs), verifyOrgAccess, verifyUserHasAction(ActionsEnum.exportLogs), logs.queryActionAuditLogs @@ -381,7 +458,7 @@ authenticated.get( authenticated.get( "/org/:orgId/logs/action/export", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.logExport), verifyOrgAccess, verifyUserHasAction(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs), @@ -391,7 +468,7 @@ authenticated.get( authenticated.get( "/org/:orgId/logs/access", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.accessLogs), verifyOrgAccess, verifyUserHasAction(ActionsEnum.exportLogs), logs.queryAccessAuditLogs @@ -400,27 +477,48 @@ authenticated.get( authenticated.get( "/org/:orgId/logs/access/export", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.logExport), verifyOrgAccess, verifyUserHasAction(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs), logs.exportAccessAuditLogs ); +authenticated.get( + "/org/:orgId/logs/connection", + verifyValidLicense, + verifyValidSubscription(tierMatrix.connectionLogs), + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logs.queryConnectionAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/connection/export", + verifyValidLicense, + verifyValidSubscription(tierMatrix.logExport), + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + logs.exportConnectionAuditLogs +); + authenticated.post( "/re-key/:clientId/regenerate-client-secret", + verifyClientAccess, // this is first to set the org id verifyValidLicense, - verifyValidSubscription, - verifyClientAccess, + verifyValidSubscription(tierMatrix.rotateCredentials), + verifyLimits, verifyUserHasAction(ActionsEnum.reGenerateSecret), reKey.reGenerateClientSecret ); authenticated.post( "/re-key/:siteId/regenerate-site-secret", + verifySiteAccess, // this is first to set the org id verifyValidLicense, - verifyValidSubscription, - verifySiteAccess, + verifyValidSubscription(tierMatrix.rotateCredentials), + verifyLimits, verifyUserHasAction(ActionsEnum.reGenerateSecret), reKey.reGenerateSiteSecret ); @@ -428,8 +526,92 @@ authenticated.post( authenticated.put( "/re-key/:orgId/regenerate-remote-exit-node-secret", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.rotateCredentials), verifyOrgAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.reGenerateSecret), reKey.reGenerateExitNodeSecret ); + +authenticated.post( + "/org/:orgId/ssh/sign-key", + verifyValidLicense, + verifyValidSubscription(tierMatrix.sshPam), + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.signSshKey), + // logActionAudit(ActionsEnum.signSshKey), // it is handled inside of the function below so we can include more metadata + ssh.signSshKey +); + +authenticated.post( + "/user/:userId/add-role/:roleId", + verifyRoleAccess, + verifyUserAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.addUserRole), + logActionAudit(ActionsEnum.addUserRole), + user.addUserRole +); + +authenticated.delete( + "/user/:userId/remove-role/:roleId", + verifyRoleAccess, + verifyUserAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.removeUserRole), + logActionAudit(ActionsEnum.removeUserRole), + user.removeUserRole +); + +authenticated.post( + "/user/:userId/org/:orgId/roles", + verifyOrgAccess, + verifyUserAccess, + verifyLimits, + verifyUserCanSetUserOrgRoles(), + logActionAudit(ActionsEnum.setUserOrgRoles), + user.setUserOrgRoles +); + +authenticated.put( + "/org/:orgId/site-provisioning-key", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyOrgAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.createSiteProvisioningKey), + logActionAudit(ActionsEnum.createSiteProvisioningKey), + siteProvisioning.createSiteProvisioningKey +); + +authenticated.get( + "/org/:orgId/site-provisioning-keys", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listSiteProvisioningKeys), + siteProvisioning.listSiteProvisioningKeys +); + +authenticated.delete( + "/org/:orgId/site-provisioning-key/:siteProvisioningKeyId", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyOrgAccess, + verifySiteProvisioningKeyAccess, + verifyUserHasAction(ActionsEnum.deleteSiteProvisioningKey), + logActionAudit(ActionsEnum.deleteSiteProvisioningKey), + siteProvisioning.deleteSiteProvisioningKey +); + +authenticated.patch( + "/org/:orgId/site-provisioning-key/:siteProvisioningKeyId", + verifyValidLicense, + verifyValidSubscription(tierMatrix.siteProvisioningKeys), + verifyOrgAccess, + verifySiteProvisioningKeyAccess, + verifyUserHasAction(ActionsEnum.updateSiteProvisioningKey), + logActionAudit(ActionsEnum.updateSiteProvisioningKey), + siteProvisioning.updateSiteProvisioningKey +); diff --git a/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts new file mode 100644 index 000000000..50248f1f9 --- /dev/null +++ b/server/private/routers/generatedLicense/generateNewEnterpriseLicense.ts @@ -0,0 +1,158 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib/response"; +import privateConfig from "#private/lib/config"; +import { createNewLicense } from "./generateNewLicense"; +import config from "@server/lib/config"; +import { getLicensePriceSet, LicenseId } from "@server/lib/billing/licenses"; +import stripe from "#private/lib/stripe"; +import { customers, db } from "@server/db"; +import { fromError } from "zod-validation-error"; +import z from "zod"; +import { eq } from "drizzle-orm"; +import { log } from "winston"; + +const generateNewEnterpriseLicenseParamsSchema = z.strictObject({ + orgId: z.string() +}); + +export async function generateNewEnterpriseLicense( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = generateNewEnterpriseLicenseParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + if (!orgId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization ID is required" + ) + ); + } + + logger.debug(`Generating new license for orgId: ${orgId}`); + + const licenseData = req.body; + + if ( + licenseData.tier != "big_license" && + licenseData.tier != "small_license" + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid tier specified. Must be either 'big_license' or 'small_license'." + ) + ); + } + + const apiResponse = await createNewLicense(orgId, licenseData); + + // Check if the API call was successful + if (!apiResponse.success || apiResponse.error) { + return next( + createHttpError( + apiResponse.status || HttpCode.BAD_REQUEST, + apiResponse.message || + "Failed to create license from Fossorial API" + ) + ); + } + + const keyId = apiResponse?.data?.licenseKey?.id; + if (!keyId) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Fossorial API did not return a valid license key ID" + ) + ); + } + + // check if we already have a customer for this org + const [customer] = await db + .select() + .from(customers) + .where(eq(customers.orgId, orgId)) + .limit(1); + + // If we don't have a customer, create one + if (!customer) { + // error + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "No customer found for this organization" + ) + ); + } + + const tier = + licenseData.tier === "big_license" + ? LicenseId.BIG_LICENSE + : LicenseId.SMALL_LICENSE; + const tierPrice = getLicensePriceSet()[tier]; + + const session = await stripe!.checkout.sessions.create({ + client_reference_id: keyId.toString(), + billing_address_collection: "required", + line_items: [ + { + price: tierPrice, // Use the standard tier + quantity: 1 + } + ], // Start with the standard feature set that matches the free limits + customer: customer.customerId, + mode: "subscription", + allow_promotion_codes: true, + success_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?success=true&session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${config.getRawConfig().app.dashboard_url}/${orgId}/settings/license?canceled=true` + }); + + return sendResponse(res, { + data: session.url, + success: true, + error: false, + message: "License and checkout session created successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred while generating new license." + ) + ); + } +} diff --git a/server/private/routers/generatedLicense/generateNewLicense.ts b/server/private/routers/generatedLicense/generateNewLicense.ts index 2c0c4420a..9835f40a4 100644 --- a/server/private/routers/generatedLicense/generateNewLicense.ts +++ b/server/private/routers/generatedLicense/generateNewLicense.ts @@ -19,10 +19,40 @@ import { response as sendResponse } from "@server/lib/response"; import privateConfig from "#private/lib/config"; import { GenerateNewLicenseResponse } from "@server/routers/generatedLicense/types"; -async function createNewLicense(orgId: string, licenseData: any): Promise { +export interface CreateNewLicenseResponse { + data: Data + success: boolean + error: boolean + message: string + status: number +} + +export interface Data { + licenseKey: LicenseKey +} + +export interface LicenseKey { + id: number + instanceName: any + instanceId: string + licenseKey: string + tier: string + type: string + quantity: number + quantity_2: number + isValid: boolean + updatedAt: string + createdAt: string + expiresAt: string + paidFor: boolean + orgId: string + metadata: string +} + +export async function createNewLicense(orgId: string, licenseData: any): Promise { try { const response = await fetch( - `https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/create`, + `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/create`, // this says enterprise but it does both { method: "PUT", headers: { @@ -35,9 +65,8 @@ async function createNewLicense(orgId: string, licenseData: any): Promise { } ); - const data = await response.json(); + const data: CreateNewLicenseResponse = await response.json(); - logger.debug("Fossorial API response:", { data }); return data; } catch (error) { console.error("Error creating new license:", error); diff --git a/server/private/routers/generatedLicense/index.ts b/server/private/routers/generatedLicense/index.ts index 83d886340..70b9b001c 100644 --- a/server/private/routers/generatedLicense/index.ts +++ b/server/private/routers/generatedLicense/index.ts @@ -13,3 +13,4 @@ export * from "./listGeneratedLicenses"; export * from "./generateNewLicense"; +export * from "./generateNewEnterpriseLicense"; diff --git a/server/private/routers/generatedLicense/listGeneratedLicenses.ts b/server/private/routers/generatedLicense/listGeneratedLicenses.ts index fb54c763c..cb9308824 100644 --- a/server/private/routers/generatedLicense/listGeneratedLicenses.ts +++ b/server/private/routers/generatedLicense/listGeneratedLicenses.ts @@ -25,7 +25,7 @@ import { async function fetchLicenseKeys(orgId: string): Promise { try { const response = await fetch( - `https://api.fossorial.io/api/v1/license-internal/enterprise/${orgId}/list`, + `${privateConfig.getRawPrivateConfig().server.fossorial_api}/api/v1/license-internal/enterprise/${orgId}/list`, { method: "GET", headers: { diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index 751a1a0cb..13a6f70e0 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -15,6 +15,7 @@ import { verifySessionRemoteExitNodeMiddleware } from "#private/middlewares/veri import { Router } from "express"; import { db, + logsDb, exitNodes, Resource, ResourcePassword, @@ -36,8 +37,11 @@ import { LoginPage, resourceHeaderAuth, ResourceHeaderAuth, + resourceHeaderAuthExtendedCompatibility, + ResourceHeaderAuthExtendedCompatibility, orgs, - requestAuditLog + requestAuditLog, + Org } from "@server/db"; import { resources, @@ -48,7 +52,9 @@ import { userOrgs, roleResources, userResources, - resourceRules + resourceRules, + userOrgRoles, + roles } from "@server/db"; import { eq, and, inArray, isNotNull, ne } from "drizzle-orm"; import { response } from "@server/lib/response"; @@ -77,6 +83,8 @@ import { maxmindLookup } from "@server/db/maxmind"; import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToken"; import semver from "semver"; import { maxmindAsnLookup } from "@server/db/maxmindAsn"; +import { checkOrgAccessPolicy } from "@server/lib/checkOrgAccessPolicy"; +import { sanitizeString } from "@server/lib/sanitize"; // Zod schemas for request validation const getResourceByDomainParamsSchema = z.strictObject({ @@ -92,6 +100,19 @@ const getUserOrgRoleParamsSchema = z.strictObject({ orgId: z.string().min(1, "Organization ID is required") }); +const getUserOrgSessionVerifySchema = z.strictObject({ + userId: z.string().min(1, "User ID is required"), + orgId: z.string().min(1, "Organization ID is required"), + sessionId: z.string().min(1, "Session ID is required") +}); + +const getRoleNameParamsSchema = z.strictObject({ + roleId: z + .string() + .transform(Number) + .pipe(z.int().positive("Role ID must be a positive integer")) +}); + const getRoleResourceAccessParamsSchema = z.strictObject({ roleId: z .string() @@ -103,6 +124,23 @@ const getRoleResourceAccessParamsSchema = z.strictObject({ .pipe(z.int().positive("Resource ID must be a positive integer")) }); +const getResourceAccessParamsSchema = z.strictObject({ + resourceId: z + .string() + .transform(Number) + .pipe(z.int().positive("Resource ID must be a positive integer")) +}); + +const getResourceAccessQuerySchema = z.strictObject({ + roleIds: z + .union([z.array(z.string()), z.string()]) + .transform((val) => + (Array.isArray(val) ? val : [val]) + .map(Number) + .filter((n) => !isNaN(n)) + ) +}); + const getUserResourceAccessParamsSchema = z.strictObject({ userId: z.string().min(1, "User ID is required"), resourceId: z @@ -175,6 +213,8 @@ export type ResourceWithAuth = { pincode: ResourcePincode | null; password: ResourcePassword | null; headerAuth: ResourceHeaderAuth | null; + headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; + org: Org; }; export type UserSessionWithUser = { @@ -235,7 +275,8 @@ hybridRouter.get( ["newt", "local", "wireguard"], // Allow them to use all the site types true, // But don't allow domain namespace resources false, // Dont include login pages, - true // allow raw resources + true, // allow raw resources + false // dont generate maintenance page ); return response(res, { @@ -257,7 +298,6 @@ hybridRouter.get( } ); -let encryptionKeyPath = ""; let encryptionKeyHex = ""; let encryptionKey: Buffer; function loadEncryptData() { @@ -265,16 +305,8 @@ function loadEncryptData() { return; // already loaded } - encryptionKeyPath = - privateConfig.getRawPrivateConfig().server.encryption_key_path; - - if (!fs.existsSync(encryptionKeyPath)) { - throw new Error( - "Encryption key file not found. Please generate one first." - ); - } - - encryptionKeyHex = fs.readFileSync(encryptionKeyPath, "utf8").trim(); + encryptionKeyHex = + privateConfig.getRawPrivateConfig().server.encryption_key; encryptionKey = Buffer.from(encryptionKeyHex, "hex"); } @@ -498,6 +530,14 @@ hybridRouter.get( resourceHeaderAuth, eq(resourceHeaderAuth.resourceId, resources.resourceId) ) + .leftJoin( + resourceHeaderAuthExtendedCompatibility, + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + resources.resourceId + ) + ) + .innerJoin(orgs, eq(orgs.orgId, resources.orgId)) .where(eq(resources.fullDomain, domain)) .limit(1); @@ -530,7 +570,10 @@ hybridRouter.get( resource: result.resources, pincode: result.resourcePincode, password: result.resourcePassword, - headerAuth: result.resourceHeaderAuth + headerAuth: result.resourceHeaderAuth, + headerAuthExtendedCompatibility: + result.resourceHeaderAuthExtendedCompatibility, + org: result.orgs }; return response(res, { @@ -594,6 +637,16 @@ hybridRouter.get( ) .limit(1); + if (!result) { + return response(res, { + data: null, + success: true, + error: false, + message: "Login page not found", + status: HttpCode.OK + }); + } + if ( await checkExitNodeOrg( remoteExitNode.exitNodeId, @@ -609,16 +662,6 @@ hybridRouter.get( ); } - if (!result) { - return response(res, { - data: null, - success: true, - error: false, - message: "Login page not found", - status: HttpCode.OK - }); - } - return response(res, { data: result.loginPage, success: true, @@ -743,7 +786,7 @@ hybridRouter.get( // Get user organization role hybridRouter.get( - "/user/:userId/org/:orgId/role", + "/user/:userId/org/:orgId/roles", async (req: Request, res: Response, next: NextFunction) => { try { const parsedParams = getUserOrgRoleParamsSchema.safeParse( @@ -779,23 +822,27 @@ hybridRouter.get( ); } - const userOrgRole = await db - .select() - .from(userOrgs) + const userOrgRoleRows = await db + .select({ roleId: userOrgRoles.roleId, roleName: roles.name }) + .from(userOrgRoles) + .innerJoin(roles, eq(roles.roleId, userOrgRoles.roleId)) .where( - and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) - ) - .limit(1); + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); - const result = userOrgRole.length > 0 ? userOrgRole[0] : null; + logger.debug(`User ${userId} has roles in org ${orgId}:`, userOrgRoleRows); - return response(res, { - data: result, + return response<{ roleId: number, roleName: string }[]>(res, { + data: userOrgRoleRows, success: true, error: false, - message: result - ? "User org role retrieved successfully" - : "User org role not found", + message: + userOrgRoleRows.length > 0 + ? "User org roles retrieved successfully" + : "User has no roles in this organization", status: HttpCode.OK }); } catch (error) { @@ -810,6 +857,225 @@ hybridRouter.get( } ); +// DEPRICATED Get user organization role +// used for backward compatibility with old remote nodes +hybridRouter.get( + "/user/:userId/org/:orgId/role", // <- note the missing s + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getUserOrgRoleParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId, orgId } = parsedParams.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "User is not authorized to access this organization" + ) + ); + } + + // get the roles on the user + + const userOrgRoleRows = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + + const roleIds = userOrgRoleRows.map((r) => r.roleId); + + let roleId: number | null = null; + + if (userOrgRoleRows.length === 0) { + // User has no roles in this organization + roleId = null; + } else if (userOrgRoleRows.length === 1) { + // User has exactly one role, return it + roleId = userOrgRoleRows[0].roleId; + } else { + // User has multiple roles + // Check if any of these roles are also assigned to a resource + // If we find a match, prefer that role; otherwise return the first role + // Get all resources that have any of these roles assigned + const roleResourceMatches = await db + .select({ roleId: roleResources.roleId }) + .from(roleResources) + .where(inArray(roleResources.roleId, roleIds)) + .limit(1); + if (roleResourceMatches.length > 0) { + // Return the first role that's also on a resource + roleId = roleResourceMatches[0].roleId; + } else { + // No resource match found, return the first role + roleId = userOrgRoleRows[0].roleId; + } + } + + return response<{ roleId: number | null }>(res, { + data: { roleId }, + success: true, + error: false, + message: + roleIds.length > 0 + ? "User org roles retrieved successfully" + : "User has no roles in this organization", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get user org role" + ) + ); + } + } +); + +// Get user organization role +hybridRouter.get( + "/user/:userId/org/:orgId/session/:sessionId/verify", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getUserOrgSessionVerifySchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId, orgId, sessionId } = parsedParams.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode || !remoteExitNode.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + if (await checkExitNodeOrg(remoteExitNode.exitNodeId, orgId)) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "User is not authorized to access this organization" + ) + ); + } + + const accessPolicy = await checkOrgAccessPolicy({ + orgId, + userId, + sessionId + }); + + return response(res, { + data: accessPolicy, + success: true, + error: false, + message: "User org access policy retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get user org role" + ) + ); + } + } +); + +// Get role name by ID +hybridRouter.get( + "/role/:roleId/name", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getRoleNameParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { roleId } = parsedParams.data; + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode?.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [role] = await db + .select({ name: roles.name }) + .from(roles) + .where(eq(roles.roleId, roleId)) + .limit(1); + + return response(res, { + data: role?.name ?? null, + success: true, + error: false, + message: role + ? "Role name retrieved successfully" + : "Role not found", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get role name" + ) + ); + } + } +); + // Check if role has access to resource hybridRouter.get( "/role/:roleId/resource/:resourceId/access", @@ -895,6 +1161,101 @@ hybridRouter.get( } ); +// Check if role has access to resource +hybridRouter.get( + "/resource/:resourceId/access", + async (req: Request, res: Response, next: NextFunction) => { + try { + const parsedParams = getResourceAccessParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { resourceId } = parsedParams.data; + const parsedQuery = getResourceAccessQuerySchema.safeParse( + req.query + ); + const roleIds = parsedQuery.success ? parsedQuery.data.roleIds : []; + + const remoteExitNode = req.remoteExitNode; + + if (!remoteExitNode?.exitNodeId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Remote exit node not found" + ) + ); + } + + const [resource] = await db + .select() + .from(resources) + .where(eq(resources.resourceId, resourceId)) + .limit(1); + + if ( + await checkExitNodeOrg( + remoteExitNode.exitNodeId, + resource.orgId + ) + ) { + // If the exit node is not allowed for the org, return an error + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Exit node not allowed for this organization" + ) + ); + } + + const roleResourceAccess = await db + .select({ + resourceId: roleResources.resourceId, + roleId: roleResources.roleId + }) + .from(roleResources) + .where( + and( + eq(roleResources.resourceId, resourceId), + inArray(roleResources.roleId, roleIds) + ) + ); + + const result = + roleResourceAccess.length > 0 ? roleResourceAccess : null; + + return response<{ resourceId: number; roleId: number }[] | null>( + res, + { + data: result, + success: true, + error: false, + message: result + ? "Role resource access retrieved successfully" + : "Role resource access not found", + status: HttpCode.OK + } + ); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to get role resource access" + ) + ); + } + } +); + // Check if user has direct access to resource hybridRouter.get( "/user/:userId/resource/:resourceId/access", @@ -1781,24 +2142,25 @@ hybridRouter.post( }) .map((logEntry) => ({ timestamp: logEntry.timestamp, - orgId: logEntry.orgId, - actorType: logEntry.actorType, - actor: logEntry.actor, - actorId: logEntry.actorId, - metadata: logEntry.metadata, + orgId: sanitizeString(logEntry.orgId), + actorType: sanitizeString(logEntry.actorType), + actor: sanitizeString(logEntry.actor), + actorId: sanitizeString(logEntry.actorId), + metadata: sanitizeString(logEntry.metadata), action: logEntry.action, resourceId: logEntry.resourceId, reason: logEntry.reason, - location: logEntry.location, + location: sanitizeString(logEntry.location), // userAgent: data.userAgent, // TODO: add this // headers: data.body.headers, // query: data.body.query, - originalRequestURL: logEntry.originalRequestURL, - scheme: logEntry.scheme, - host: logEntry.host, - path: logEntry.path, - method: logEntry.method, - ip: logEntry.ip, + originalRequestURL: + sanitizeString(logEntry.originalRequestURL) ?? "", + scheme: sanitizeString(logEntry.scheme) ?? "", + host: sanitizeString(logEntry.host) ?? "", + path: sanitizeString(logEntry.path) ?? "", + method: sanitizeString(logEntry.method) ?? "", + ip: sanitizeString(logEntry.ip), tls: logEntry.tls })); @@ -1806,7 +2168,7 @@ hybridRouter.post( const batchSize = 100; for (let i = 0; i < logEntries.length; i += batchSize) { const batch = logEntries.slice(i, i + batchSize); - await db.insert(requestAuditLog).values(batch); + await logsDb.insert(requestAuditLog).values(batch); } return response(res, { diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 9eefff8f8..40bb2b56c 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -18,19 +18,24 @@ import * as logs from "#private/routers/auditLogs"; import { verifyApiKeyHasAction, verifyApiKeyIsRoot, - verifyApiKeyOrgAccess + verifyApiKeyOrgAccess, + verifyApiKeyIdpAccess, + verifyApiKeyRoleAccess, + verifyApiKeyUserAccess, + verifyLimits } from "@server/middlewares"; +import * as user from "#private/routers/user"; import { verifyValidSubscription, verifyValidLicense } from "#private/middlewares"; import { ActionsEnum } from "@server/auth/actions"; - import { unauthenticated as ua, authenticated as a } from "@server/routers/integration"; import { logActionAudit } from "#private/middlewares"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; export const unauthenticated = ua; export const authenticated = a; @@ -54,7 +59,7 @@ authenticated.delete( authenticated.get( "/org/:orgId/logs/action", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.actionLogs), verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.exportLogs), logs.queryActionAuditLogs @@ -63,7 +68,7 @@ authenticated.get( authenticated.get( "/org/:orgId/logs/action/export", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.logExport), verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs), @@ -73,7 +78,7 @@ authenticated.get( authenticated.get( "/org/:orgId/logs/access", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.accessLogs), verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.exportLogs), logs.queryAccessAuditLogs @@ -82,9 +87,98 @@ authenticated.get( authenticated.get( "/org/:orgId/logs/access/export", verifyValidLicense, - verifyValidSubscription, + verifyValidSubscription(tierMatrix.logExport), verifyApiKeyOrgAccess, verifyApiKeyHasAction(ActionsEnum.exportLogs), logActionAudit(ActionsEnum.exportLogs), logs.exportAccessAuditLogs ); + +authenticated.get( + "/org/:orgId/logs/connection", + verifyValidLicense, + verifyValidSubscription(tierMatrix.connectionLogs), + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.exportLogs), + logs.queryConnectionAuditLogs +); + +authenticated.get( + "/org/:orgId/logs/connection/export", + verifyValidLicense, + verifyValidSubscription(tierMatrix.logExport), + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.exportLogs), + logActionAudit(ActionsEnum.exportLogs), + logs.exportConnectionAuditLogs +); + +authenticated.put( + "/org/:orgId/idp/oidc", + verifyValidLicense, + verifyValidSubscription(tierMatrix.orgOidc), + verifyApiKeyOrgAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.createIdp), + logActionAudit(ActionsEnum.createIdp), + orgIdp.createOrgOidcIdp +); + +authenticated.post( + "/org/:orgId/idp/:idpId/oidc", + verifyValidLicense, + verifyValidSubscription(tierMatrix.orgOidc), + verifyApiKeyOrgAccess, + verifyApiKeyIdpAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.updateIdp), + logActionAudit(ActionsEnum.updateIdp), + orgIdp.updateOrgOidcIdp +); + +authenticated.delete( + "/org/:orgId/idp/:idpId", + verifyValidLicense, + verifyApiKeyOrgAccess, + verifyApiKeyIdpAccess, + verifyApiKeyHasAction(ActionsEnum.deleteIdp), + logActionAudit(ActionsEnum.deleteIdp), + orgIdp.deleteOrgIdp +); + +authenticated.get( + "/org/:orgId/idp/:idpId", + verifyValidLicense, + verifyApiKeyOrgAccess, + verifyApiKeyIdpAccess, + verifyApiKeyHasAction(ActionsEnum.getIdp), + orgIdp.getOrgIdp +); + +authenticated.get( + "/org/:orgId/idp", + verifyValidLicense, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listIdps), + orgIdp.listOrgIdps +); + +authenticated.post( + "/user/:userId/add-role/:roleId", + verifyApiKeyRoleAccess, + verifyApiKeyUserAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.addUserRole), + logActionAudit(ActionsEnum.addUserRole), + user.addUserRole +); + +authenticated.delete( + "/user/:userId/remove-role/:roleId", + verifyApiKeyRoleAccess, + verifyApiKeyUserAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.removeUserRole), + logActionAudit(ActionsEnum.removeUserRole), + user.removeUserRole +); diff --git a/server/private/routers/internal.ts b/server/private/routers/internal.ts index b393b8843..b599d6627 100644 --- a/server/private/routers/internal.ts +++ b/server/private/routers/internal.ts @@ -16,6 +16,7 @@ import * as auth from "#private/routers/auth"; import * as orgIdp from "#private/routers/orgIdp"; import * as billing from "#private/routers/billing"; import * as license from "#private/routers/license"; +import * as resource from "#private/routers/resource"; import { verifySessionUserMiddleware } from "@server/middlewares"; @@ -28,6 +29,7 @@ internalRouter.get("/org/:orgId/idp", orgIdp.listOrgIdps); internalRouter.get("/org/:orgId/billing/tier", billing.getOrgTier); internalRouter.get("/login-page", loginPage.loadLoginPage); +internalRouter.get("/login-page-branding", loginPage.loadLoginPageBranding); internalRouter.post( "/get-session-transfer-token", @@ -36,3 +38,5 @@ internalRouter.post( ); internalRouter.get(`/license/status`, license.getLicenseStatus); + +internalRouter.get("/maintenance/info", resource.getMaintenanceInfo); diff --git a/server/private/routers/loginPage/createLoginPage.ts b/server/private/routers/loginPage/createLoginPage.ts index b5e8ccff9..72b8a28f2 100644 --- a/server/private/routers/loginPage/createLoginPage.ts +++ b/server/private/routers/loginPage/createLoginPage.ts @@ -30,9 +30,7 @@ import { fromError } from "zod-validation-error"; import { eq, and } from "drizzle-orm"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { createCertificate } from "#private/routers/certificates/createCertificate"; -import { getOrgTierData } from "#private/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; -import { build } from "@server/build"; + import { CreateLoginPageResponse } from "@server/routers/loginPage/types"; const paramsSchema = z.strictObject({ @@ -76,19 +74,6 @@ export async function createLoginPage( const { orgId } = parsedParams.data; - if (build === "saas") { - const { tier } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; - if (!subscribed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "This organization's current plan does not support this feature." - ) - ); - } - } - const [existing] = await db .select() .from(loginPageOrg) diff --git a/server/private/routers/loginPage/deleteLoginPageBranding.ts b/server/private/routers/loginPage/deleteLoginPageBranding.ts new file mode 100644 index 000000000..0a59ce4e6 --- /dev/null +++ b/server/private/routers/loginPage/deleteLoginPageBranding.ts @@ -0,0 +1,99 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + LoginPageBranding, + loginPageBranding, + loginPageBrandingOrg +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq } from "drizzle-orm"; + + +const paramsSchema = z + .object({ + orgId: z.string() + }) + .strict(); + +export async function deleteLoginPageBranding( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + + const [existingLoginPageBranding] = await db + .select() + .from(loginPageBranding) + .innerJoin( + loginPageBrandingOrg, + eq( + loginPageBrandingOrg.loginPageBrandingId, + loginPageBranding.loginPageBrandingId + ) + ) + .where(eq(loginPageBrandingOrg.orgId, orgId)); + + if (!existingLoginPageBranding) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Login page branding not found" + ) + ); + } + + await db + .delete(loginPageBranding) + .where( + eq( + loginPageBranding.loginPageBrandingId, + existingLoginPageBranding.loginPageBranding + .loginPageBrandingId + ) + ); + + return response(res, { + data: existingLoginPageBranding.loginPageBranding, + success: true, + error: false, + message: "Login page branding deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/loginPage/getLoginPageBranding.ts b/server/private/routers/loginPage/getLoginPageBranding.ts new file mode 100644 index 000000000..ce133c7cd --- /dev/null +++ b/server/private/routers/loginPage/getLoginPageBranding.ts @@ -0,0 +1,86 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + LoginPageBranding, + loginPageBranding, + loginPageBrandingOrg +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq } from "drizzle-orm"; + + +const paramsSchema = z.strictObject({ + orgId: z.string() +}); + +export async function getLoginPageBranding( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const [existingLoginPageBranding] = await db + .select() + .from(loginPageBranding) + .innerJoin( + loginPageBrandingOrg, + eq( + loginPageBrandingOrg.loginPageBrandingId, + loginPageBranding.loginPageBrandingId + ) + ) + .where(eq(loginPageBrandingOrg.orgId, orgId)); + + if (!existingLoginPageBranding) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Login page branding not found" + ) + ); + } + + return response(res, { + data: existingLoginPageBranding.loginPageBranding, + success: true, + error: false, + message: "Login page branding retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/loginPage/index.ts b/server/private/routers/loginPage/index.ts index 2372ddfa9..1bfe6e16c 100644 --- a/server/private/routers/loginPage/index.ts +++ b/server/private/routers/loginPage/index.ts @@ -17,3 +17,7 @@ export * from "./getLoginPage"; export * from "./loadLoginPage"; export * from "./updateLoginPage"; export * from "./deleteLoginPage"; +export * from "./upsertLoginPageBranding"; +export * from "./deleteLoginPageBranding"; +export * from "./getLoginPageBranding"; +export * from "./loadLoginPageBranding"; diff --git a/server/private/routers/loginPage/loadLoginPage.ts b/server/private/routers/loginPage/loadLoginPage.ts index 1b10e2058..7a631c8a6 100644 --- a/server/private/routers/loginPage/loadLoginPage.ts +++ b/server/private/routers/loginPage/loadLoginPage.ts @@ -40,6 +40,11 @@ async function query(orgId: string | undefined, fullDomain: string) { eq(loginPage.loginPageId, loginPageOrg.loginPageId) ) .limit(1); + + if (!res) { + return null; + } + return { ...res.loginPage, orgId: res.loginPageOrg.orgId @@ -65,6 +70,11 @@ async function query(orgId: string | undefined, fullDomain: string) { ) ) .limit(1); + + if (!res) { + return null; + } + return { ...res, orgId: orgLink.orgId diff --git a/server/private/routers/loginPage/loadLoginPageBranding.ts b/server/private/routers/loginPage/loadLoginPageBranding.ts new file mode 100644 index 000000000..1197bb10d --- /dev/null +++ b/server/private/routers/loginPage/loadLoginPageBranding.ts @@ -0,0 +1,105 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, loginPageBranding, loginPageBrandingOrg, orgs } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import type { LoadLoginPageBrandingResponse } from "@server/routers/loginPage/types"; + +const querySchema = z.object({ + orgId: z.string().min(1) +}); + +async function query(orgId: string) { + const [orgLink] = await db + .select() + .from(loginPageBrandingOrg) + .where(eq(loginPageBrandingOrg.orgId, orgId)) + .innerJoin(orgs, eq(loginPageBrandingOrg.orgId, orgs.orgId)); + if (!orgLink) { + return null; + } + + const [res] = await db + .select() + .from(loginPageBranding) + .where( + and( + eq( + loginPageBranding.loginPageBrandingId, + orgLink.loginPageBrandingOrg.loginPageBrandingId + ) + ) + ) + .limit(1); + + if (!res) { + return null; + } + + return { + ...res, + orgId: orgLink.orgs.orgId, + orgName: orgLink.orgs.name + }; +} + +export async function loadLoginPageBranding( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { orgId } = parsedQuery.data; + + const branding = await query(orgId); + + if (!branding) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Branding for Login page not found" + ) + ); + } + + return response(res, { + data: branding, + success: true, + error: false, + message: "Login page branding retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/loginPage/updateLoginPage.ts b/server/private/routers/loginPage/updateLoginPage.ts index bda614d37..6226dda2d 100644 --- a/server/private/routers/loginPage/updateLoginPage.ts +++ b/server/private/routers/loginPage/updateLoginPage.ts @@ -23,9 +23,7 @@ import { eq, and } from "drizzle-orm"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { subdomainSchema } from "@server/lib/schemas"; import { createCertificate } from "#private/routers/certificates/createCertificate"; -import { getOrgTierData } from "#private/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; -import { build } from "@server/build"; + import { UpdateLoginPageResponse } from "@server/routers/loginPage/types"; const paramsSchema = z @@ -87,18 +85,6 @@ export async function updateLoginPage( const { loginPageId, orgId } = parsedParams.data; - if (build === "saas") { - const { tier } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; - if (!subscribed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "This organization's current plan does not support this feature." - ) - ); - } - } const [existingLoginPage] = await db .select() diff --git a/server/private/routers/loginPage/upsertLoginPageBranding.ts b/server/private/routers/loginPage/upsertLoginPageBranding.ts new file mode 100644 index 000000000..232636543 --- /dev/null +++ b/server/private/routers/loginPage/upsertLoginPageBranding.ts @@ -0,0 +1,226 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + LoginPageBranding, + loginPageBranding, + loginPageBrandingOrg +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, InferInsertModel } from "drizzle-orm"; +import { build } from "@server/build"; +import { validateLocalPath } from "@app/lib/validateLocalPath"; +import config from "#private/lib/config"; + +const paramsSchema = z.strictObject({ + orgId: z.string() +}); + +const bodySchema = z.strictObject({ + logoUrl: z + .union([ + z.literal(""), + z + .string() + .superRefine(async (urlOrPath, ctx) => { + const parseResult = z.url().safeParse(urlOrPath); + if (!parseResult.success) { + if (build !== "enterprise") { + ctx.addIssue({ + code: "custom", + message: "Must be a valid URL" + }); + return; + } else { + try { + validateLocalPath(urlOrPath); + } catch (error) { + ctx.addIssue({ + code: "custom", + message: "Must be either a valid image URL or a valid pathname starting with `/` and not containing query parameters, `..` or `*`" + }); + } finally { + return; + } + } + } + + try { + const response = await fetch(urlOrPath, { + method: "HEAD" + }).catch(() => { + // If HEAD fails (CORS or method not allowed), try GET + return fetch(urlOrPath, { method: "GET" }); + }); + + if (response.status !== 200) { + ctx.addIssue({ + code: "custom", + message: `Failed to load image. Please check that the URL is accessible.` + }); + return; + } + + const contentType = + response.headers.get("content-type") ?? ""; + if (!contentType.startsWith("image/")) { + ctx.addIssue({ + code: "custom", + message: `URL does not point to an image. Please provide a URL to an image file (e.g., .png, .jpg, .svg).` + }); + return; + } + } catch (error) { + let errorMessage = + "Unable to verify image URL. Please check that the URL is accessible and points to an image file."; + + if (error instanceof TypeError && error.message.includes("fetch")) { + errorMessage = + "Network error: Unable to reach the URL. Please check your internet connection and verify the URL is correct."; + } else if (error instanceof Error) { + errorMessage = `Error verifying URL: ${error.message}`; + } + + ctx.addIssue({ + code: "custom", + message: errorMessage + }); + } + }) + ]) + .transform((val) => (val === "" ? null : val)) + .nullish(), + logoWidth: z.coerce.number().min(1), + logoHeight: z.coerce.number().min(1), + resourceTitle: z.string(), + resourceSubtitle: z.string().optional(), + orgTitle: z.string().optional(), + orgSubtitle: z.string().optional(), + primaryColor: z + .string() + .regex(/^#([0-9a-f]{6}|[0-9a-f]{3})$/i) + .optional() +}); + +export type UpdateLoginPageBrandingBody = z.infer; + +export async function upsertLoginPageBranding( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = await bodySchema.safeParseAsync(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + let updateData = parsedBody.data satisfies InferInsertModel< + typeof loginPageBranding + >; + + // Empty strings are transformed to null by the schema, which will clear the logo URL in the database + // We keep it as null (not undefined) because undefined fields are omitted from Drizzle updates + + if ( + build !== "saas" && + !config.getRawPrivateConfig().flags.use_org_only_idp + ) { + const { orgTitle, orgSubtitle, ...rest } = updateData; + updateData = rest; + } + + const [existingLoginPageBranding] = await db + .select() + .from(loginPageBranding) + .innerJoin( + loginPageBrandingOrg, + eq( + loginPageBrandingOrg.loginPageBrandingId, + loginPageBranding.loginPageBrandingId + ) + ) + .where(eq(loginPageBrandingOrg.orgId, orgId)); + + let updatedLoginPageBranding: LoginPageBranding; + + if (existingLoginPageBranding) { + updatedLoginPageBranding = await db.transaction(async (tx) => { + const [branding] = await tx + .update(loginPageBranding) + .set({ ...updateData }) + .where( + eq( + loginPageBranding.loginPageBrandingId, + existingLoginPageBranding.loginPageBranding + .loginPageBrandingId + ) + ) + .returning(); + return branding; + }); + } else { + updatedLoginPageBranding = await db.transaction(async (tx) => { + const [branding] = await tx + .insert(loginPageBranding) + .values({ ...updateData }) + .returning(); + + await tx.insert(loginPageBrandingOrg).values({ + loginPageBrandingId: branding.loginPageBrandingId, + orgId: orgId + }); + return branding; + }); + } + + return response(res, { + data: updatedLoginPageBranding, + success: true, + error: false, + message: existingLoginPageBranding + ? "Login page branding updated successfully" + : "Login page branding created successfully", + status: existingLoginPageBranding ? HttpCode.OK : HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/misc/sendSupportEmail.ts b/server/private/routers/misc/sendSupportEmail.ts index 404a25014..cd37560d9 100644 --- a/server/private/routers/misc/sendSupportEmail.ts +++ b/server/private/routers/misc/sendSupportEmail.ts @@ -66,6 +66,7 @@ export async function sendSupportEmail( { name: req.user?.email || "Support User", to: "support@pangolin.net", + replyTo: req.user?.email || undefined, from: config.getNoReplyEmail(), subject: `Support Request: ${subject}` } diff --git a/server/private/routers/newt/handleConnectionLogMessage.ts b/server/private/routers/newt/handleConnectionLogMessage.ts new file mode 100644 index 000000000..2ac7153b5 --- /dev/null +++ b/server/private/routers/newt/handleConnectionLogMessage.ts @@ -0,0 +1,394 @@ +import { db, logsDb } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { connectionAuditLog, sites, Newt, clients, orgs } from "@server/db"; +import { and, eq, lt, inArray } from "drizzle-orm"; +import logger from "@server/logger"; +import { inflate } from "zlib"; +import { promisify } from "util"; +import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; + +const zlibInflate = promisify(inflate); + +// Retry configuration for deadlock handling +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 50; + +// How often to flush accumulated connection log data to the database +const FLUSH_INTERVAL_MS = 30_000; // 30 seconds + +// Maximum number of records to buffer before forcing a flush +const MAX_BUFFERED_RECORDS = 500; + +// Maximum number of records to insert in a single batch +const INSERT_BATCH_SIZE = 100; + +interface ConnectionSessionData { + sessionId: string; + resourceId: number; + sourceAddr: string; + destAddr: string; + protocol: string; + startedAt: string; // ISO 8601 timestamp + endedAt?: string; // ISO 8601 timestamp + bytesTx?: number; + bytesRx?: number; +} + +interface ConnectionLogRecord { + sessionId: string; + siteResourceId: number; + orgId: string; + siteId: number; + clientId: number | null; + userId: string | null; + sourceAddr: string; + destAddr: string; + protocol: string; + startedAt: number; // epoch seconds + endedAt: number | null; + bytesTx: number | null; + bytesRx: number | null; +} + +// In-memory buffer of records waiting to be flushed +let buffer: ConnectionLogRecord[] = []; + +/** + * Check if an error is a deadlock error + */ +function isDeadlockError(error: any): boolean { + return ( + error?.code === "40P01" || + error?.cause?.code === "40P01" || + (error?.message && error.message.includes("deadlock")) + ); +} + +/** + * Execute a function with retry logic for deadlock handling + */ +async function withDeadlockRetry( + operation: () => Promise, + context: string +): Promise { + let attempt = 0; + while (true) { + try { + return await operation(); + } catch (error: any) { + if (isDeadlockError(error) && attempt < MAX_RETRIES) { + attempt++; + const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS; + const jitter = Math.random() * baseDelay; + const delay = baseDelay + jitter; + logger.warn( + `Deadlock detected in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms` + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + throw error; + } + } +} + +/** + * Decompress a base64-encoded zlib-compressed string into parsed JSON. + */ +async function decompressConnectionLog( + compressed: string +): Promise { + const compressedBuffer = Buffer.from(compressed, "base64"); + const decompressed = await zlibInflate(compressedBuffer); + const jsonString = decompressed.toString("utf-8"); + const parsed = JSON.parse(jsonString); + + if (!Array.isArray(parsed)) { + throw new Error("Decompressed connection log data is not an array"); + } + + return parsed; +} + +/** + * Convert an ISO 8601 timestamp string to epoch seconds. + * Returns null if the input is falsy. + */ +function toEpochSeconds(isoString: string | undefined | null): number | null { + if (!isoString) { + return null; + } + const ms = new Date(isoString).getTime(); + if (isNaN(ms)) { + return null; + } + return Math.floor(ms / 1000); +} + +/** + * Flush all buffered connection log records to the database. + * + * Swaps out the buffer before writing so that any records added during the + * flush are captured in the new buffer rather than being lost. Entries that + * fail to write are re-queued back into the buffer so they will be retried + * on the next flush. + * + * This function is exported so that the application's graceful-shutdown + * cleanup handler can call it before the process exits. + */ +export async function flushConnectionLogToDb(): Promise { + if (buffer.length === 0) { + return; + } + + // Atomically swap out the buffer so new data keeps flowing in + const snapshot = buffer; + buffer = []; + + logger.debug( + `Flushing ${snapshot.length} connection log record(s) to the database` + ); + + // Insert in batches to avoid overly large SQL statements + for (let i = 0; i < snapshot.length; i += INSERT_BATCH_SIZE) { + const batch = snapshot.slice(i, i + INSERT_BATCH_SIZE); + + try { + await withDeadlockRetry(async () => { + await logsDb.insert(connectionAuditLog).values(batch); + }, `flush connection log batch (${batch.length} records)`); + } catch (error) { + logger.error( + `Failed to flush connection log batch of ${batch.length} records:`, + error + ); + + // Re-queue the failed batch so it is retried on the next flush + buffer = [...batch, ...buffer]; + + // Cap buffer to prevent unbounded growth if DB is unreachable + if (buffer.length > MAX_BUFFERED_RECORDS * 5) { + const dropped = buffer.length - MAX_BUFFERED_RECORDS * 5; + buffer = buffer.slice(0, MAX_BUFFERED_RECORDS * 5); + logger.warn( + `Connection log buffer overflow, dropped ${dropped} oldest records` + ); + } + + // Stop trying further batches from this snapshot — they'll be + // picked up by the next flush via the re-queued records above + const remaining = snapshot.slice(i + INSERT_BATCH_SIZE); + if (remaining.length > 0) { + buffer = [...remaining, ...buffer]; + } + break; + } + } +} + +const flushTimer = setInterval(async () => { + try { + await flushConnectionLogToDb(); + } catch (error) { + logger.error( + "Unexpected error during periodic connection log flush:", + error + ); + } +}, FLUSH_INTERVAL_MS); + +// Calling unref() means this timer will not keep the Node.js event loop alive +// on its own — the process can still exit normally when there is no other work +// left. The graceful-shutdown path will call flushConnectionLogToDb() explicitly +// before process.exit(), so no data is lost. +flushTimer.unref(); + +export async function cleanUpOldLogs(orgId: string, retentionDays: number) { + const cutoffTimestamp = calculateCutoffTimestamp(retentionDays); + + try { + await logsDb + .delete(connectionAuditLog) + .where( + and( + lt(connectionAuditLog.startedAt, cutoffTimestamp), + eq(connectionAuditLog.orgId, orgId) + ) + ); + + // logger.debug( + // `Cleaned up connection audit logs older than ${retentionDays} days` + // ); + } catch (error) { + logger.error("Error cleaning up old connection audit logs:", error); + } +} + +export const handleConnectionLogMessage: MessageHandler = async (context) => { + const { message, client } = context; + const newt = client as Newt; + + if (!newt) { + logger.warn("Connection log received but no newt client in context"); + return; + } + + if (!newt.siteId) { + logger.warn("Connection log received but newt has no siteId"); + return; + } + + if (!message.data?.compressed) { + logger.warn("Connection log message missing compressed data"); + return; + } + + // Look up the org for this site + const [site] = await db + .select({ orgId: sites.orgId, orgSubnet: orgs.subnet }) + .from(sites) + .innerJoin(orgs, eq(sites.orgId, orgs.orgId)) + .where(eq(sites.siteId, newt.siteId)); + + if (!site) { + logger.warn( + `Connection log received but site ${newt.siteId} not found in database` + ); + return; + } + + const orgId = site.orgId; + + // Extract the CIDR suffix (e.g. "/16") from the org subnet so we can + // reconstruct the exact subnet string stored on each client record. + const cidrSuffix = site.orgSubnet?.includes("/") + ? site.orgSubnet.substring(site.orgSubnet.indexOf("/")) + : null; + + let sessions: ConnectionSessionData[]; + try { + sessions = await decompressConnectionLog(message.data.compressed); + } catch (error) { + logger.error("Failed to decompress connection log data:", error); + return; + } + + if (sessions.length === 0) { + return; + } + + logger.debug(`Sessions: ${JSON.stringify(sessions)}`) + + // Build a map from sourceAddr → { clientId, userId } by querying clients + // whose subnet field matches exactly. Client subnets are stored with the + // org's CIDR suffix (e.g. "100.90.128.5/16"), so we reconstruct that from + // each unique sourceAddr + the org's CIDR suffix and do a targeted IN query. + const ipToClient = new Map(); + + if (cidrSuffix) { + // Collect unique source addresses so we only query for what we need + const uniqueSourceAddrs = new Set(); + for (const session of sessions) { + if (session.sourceAddr) { + uniqueSourceAddrs.add(session.sourceAddr); + } + } + + if (uniqueSourceAddrs.size > 0) { + // Construct the exact subnet strings as stored in the DB + const subnetQueries = Array.from(uniqueSourceAddrs).map( + (addr) => { + // Strip port if present (e.g. "100.90.128.1:38004" → "100.90.128.1") + const ip = addr.includes(":") ? addr.split(":")[0] : addr; + return `${ip}${cidrSuffix}`; + } + ); + + logger.debug(`Subnet queries: ${JSON.stringify(subnetQueries)}`); + + const matchedClients = await db + .select({ + clientId: clients.clientId, + userId: clients.userId, + subnet: clients.subnet + }) + .from(clients) + .where( + and( + eq(clients.orgId, orgId), + inArray(clients.subnet, subnetQueries) + ) + ); + + for (const c of matchedClients) { + const ip = c.subnet.split("/")[0]; + logger.debug(`Client ${c.clientId} subnet ${c.subnet} matches ${ip}`); + ipToClient.set(ip, { clientId: c.clientId, userId: c.userId }); + } + } + } + + // Convert to DB records and add to the buffer + for (const session of sessions) { + // Validate required fields + if ( + !session.sessionId || + !session.resourceId || + !session.sourceAddr || + !session.destAddr || + !session.protocol + ) { + logger.debug( + `Skipping connection log session with missing required fields: ${JSON.stringify(session)}` + ); + continue; + } + + const startedAt = toEpochSeconds(session.startedAt); + if (startedAt === null) { + logger.debug( + `Skipping connection log session with invalid startedAt: ${session.startedAt}` + ); + continue; + } + + // Match the source address to a client. The sourceAddr is the + // client's IP on the WireGuard network, which corresponds to the IP + // portion of the client's subnet CIDR (e.g. "100.90.128.5/24"). + // Strip port if present (e.g. "100.90.128.1:38004" → "100.90.128.1") + const sourceIp = session.sourceAddr.includes(":") ? session.sourceAddr.split(":")[0] : session.sourceAddr; + const clientInfo = ipToClient.get(sourceIp) ?? null; + + + buffer.push({ + sessionId: session.sessionId, + siteResourceId: session.resourceId, + orgId, + siteId: newt.siteId, + clientId: clientInfo?.clientId ?? null, + userId: clientInfo?.userId ?? null, + sourceAddr: session.sourceAddr, + destAddr: session.destAddr, + protocol: session.protocol, + startedAt, + endedAt: toEpochSeconds(session.endedAt), + bytesTx: session.bytesTx ?? null, + bytesRx: session.bytesRx ?? null + }); + } + + logger.debug( + `Buffered ${sessions.length} connection log session(s) from newt ${newt.newtId} (site ${newt.siteId})` + ); + + // If the buffer has grown large enough, trigger an immediate flush + if (buffer.length >= MAX_BUFFERED_RECORDS) { + // Fire and forget — errors are handled inside flushConnectionLogToDb + flushConnectionLogToDb().catch((error) => { + logger.error( + "Unexpected error during size-triggered connection log flush:", + error + ); + }); + } +}; diff --git a/server/private/routers/newt/index.ts b/server/private/routers/newt/index.ts new file mode 100644 index 000000000..cc182cf7d --- /dev/null +++ b/server/private/routers/newt/index.ts @@ -0,0 +1 @@ +export * from "./handleConnectionLogMessage"; diff --git a/server/private/routers/org/sendUsageNotifications.ts b/server/private/routers/org/sendUsageNotifications.ts index 4aa421520..72fc00d4c 100644 --- a/server/private/routers/org/sendUsageNotifications.ts +++ b/server/private/routers/org/sendUsageNotifications.ts @@ -14,7 +14,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { userOrgs, users, roles, orgs } from "@server/db"; +import { userOrgs, userOrgRoles, users, roles, orgs } from "@server/db"; import { eq, and, or } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -95,7 +95,14 @@ async function getOrgAdmins(orgId: string) { }) .from(userOrgs) .innerJoin(users, eq(userOrgs.userId, users.userId)) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) + .leftJoin( + userOrgRoles, + and( + eq(userOrgs.userId, userOrgRoles.userId), + eq(userOrgs.orgId, userOrgRoles.orgId) + ) + ) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) .where( and( eq(userOrgs.orgId, orgId), @@ -103,8 +110,11 @@ async function getOrgAdmins(orgId: string) { ) ); - // Filter to only include users with verified emails - const orgAdmins = admins.filter( + // Dedupe by userId (user may have multiple roles) + const byUserId = new Map( + admins.map((a) => [a.userId, a]) + ); + const orgAdmins = Array.from(byUserId.values()).filter( (admin) => admin.email && admin.email.length > 0 ); diff --git a/server/private/routers/orgIdp/createOrgOidcIdp.ts b/server/private/routers/orgIdp/createOrgOidcIdp.ts index 709f6167b..cc17d7cfc 100644 --- a/server/private/routers/orgIdp/createOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/createOrgOidcIdp.ts @@ -24,10 +24,11 @@ import { idp, idpOidcConfig, idpOrg, orgs } from "@server/db"; import { generateOidcRedirectUrl } from "@server/lib/idp/generateRedirectUrl"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; -import { build } from "@server/build"; -import { getOrgTierData } from "#private/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; import { CreateOrgIdpResponse } from "@server/routers/orgIdp/types"; +import { isSubscribed } from "#private/lib/isSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import privateConfig from "#private/lib/config"; +import { build } from "@server/build"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); @@ -43,25 +44,27 @@ const bodySchema = z.strictObject({ scopes: z.string().nonempty(), autoProvision: z.boolean().optional(), variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc"), - roleMapping: z.string().optional() + roleMapping: z.string().optional(), + tags: z.string().optional() }); -// registry.registerPath({ -// method: "put", -// path: "/idp/oidc", -// description: "Create an OIDC IdP.", -// tags: [OpenAPITags.Idp], -// request: { -// body: { -// content: { -// "application/json": { -// schema: bodySchema -// } -// } -// } -// }, -// responses: {} -// }); +registry.registerPath({ + method: "put", + path: "/org/{orgId}/idp/oidc", + description: "Create an OIDC IdP for a specific organization.", + tags: [OpenAPITags.OrgIdp], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); export async function createOrgOidcIdp( req: Request, @@ -91,6 +94,18 @@ export async function createOrgOidcIdp( ); } + if ( + privateConfig.getRawPrivateConfig().app.identity_provider_mode !== + "org" + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." + ) + ); + } + const { clientId, clientSecret, @@ -101,21 +116,20 @@ export async function createOrgOidcIdp( emailPath, namePath, name, - autoProvision, variant, - roleMapping + roleMapping, + tags } = parsedBody.data; - if (build === "saas") { - const { tier, active } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; + let { autoProvision } = parsedBody.data; + + if (build == "saas") { // this is not paywalled with a ee license because this whole endpoint is restricted + const subscribed = await isSubscribed( + orgId, + tierMatrix.deviceApprovals + ); if (!subscribed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "This organization's current plan does not support this feature." - ) - ); + autoProvision = false; } } @@ -131,7 +145,8 @@ export async function createOrgOidcIdp( .values({ name, autoProvision, - type: "oidc" + type: "oidc", + tags }) .returning(); diff --git a/server/private/routers/orgIdp/deleteOrgIdp.ts b/server/private/routers/orgIdp/deleteOrgIdp.ts index 721b91cba..7d201dd17 100644 --- a/server/private/routers/orgIdp/deleteOrgIdp.ts +++ b/server/private/routers/orgIdp/deleteOrgIdp.ts @@ -22,6 +22,7 @@ import { fromError } from "zod-validation-error"; import { idp, idpOidcConfig, idpOrg } from "@server/db"; import { eq } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; +import privateConfig from "#private/lib/config"; const paramsSchema = z .object({ @@ -32,9 +33,9 @@ const paramsSchema = z registry.registerPath({ method: "delete", - path: "/idp/{idpId}", - description: "Delete IDP.", - tags: [OpenAPITags.Idp], + path: "/org/{orgId}/idp/{idpId}", + description: "Delete IDP for a specific organization.", + tags: [OpenAPITags.OrgIdp], request: { params: paramsSchema }, @@ -59,6 +60,18 @@ export async function deleteOrgIdp( const { idpId } = parsedParams.data; + if ( + privateConfig.getRawPrivateConfig().app.identity_provider_mode !== + "org" + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." + ) + ); + } + // Check if IDP exists const [existingIdp] = await db .select() diff --git a/server/private/routers/orgIdp/getOrgIdp.ts b/server/private/routers/orgIdp/getOrgIdp.ts index 01ddc0f7e..6941fc0fc 100644 --- a/server/private/routers/orgIdp/getOrgIdp.ts +++ b/server/private/routers/orgIdp/getOrgIdp.ts @@ -48,16 +48,16 @@ async function query(idpId: number, orgId: string) { return res; } -// registry.registerPath({ -// method: "get", -// path: "/idp/{idpId}", -// description: "Get an IDP by its IDP ID.", -// tags: [OpenAPITags.Idp], -// request: { -// params: paramsSchema -// }, -// responses: {} -// }); +registry.registerPath({ + method: "get", + path: "/org/{orgId}/idp/{idpId}", + description: "Get an IDP by its IDP ID for a specific organization.", + tags: [OpenAPITags.OrgIdp], + request: { + params: paramsSchema + }, + responses: {} +}); export async function getOrgIdp( req: Request, diff --git a/server/private/routers/orgIdp/listOrgIdps.ts b/server/private/routers/orgIdp/listOrgIdps.ts index 36cbc6279..fed8a0aab 100644 --- a/server/private/routers/orgIdp/listOrgIdps.ts +++ b/server/private/routers/orgIdp/listOrgIdps.ts @@ -50,7 +50,8 @@ async function query(orgId: string, limit: number, offset: number) { orgId: idpOrg.orgId, name: idp.name, type: idp.type, - variant: idpOidcConfig.variant + variant: idpOidcConfig.variant, + tags: idp.tags }) .from(idpOrg) .where(eq(idpOrg.orgId, orgId)) @@ -62,16 +63,17 @@ async function query(orgId: string, limit: number, offset: number) { return res; } -// registry.registerPath({ -// method: "get", -// path: "/idp", -// description: "List all IDP in the system.", -// tags: [OpenAPITags.Idp], -// request: { -// query: querySchema -// }, -// responses: {} -// }); +registry.registerPath({ + method: "get", + path: "/org/{orgId}/idp", + description: "List all IDP for a specific organization.", + tags: [OpenAPITags.OrgIdp], + request: { + query: querySchema, + params: paramsSchema + }, + responses: {} +}); export async function listOrgIdps( req: Request, diff --git a/server/private/routers/orgIdp/updateOrgOidcIdp.ts b/server/private/routers/orgIdp/updateOrgOidcIdp.ts index f29e4fc21..191f49068 100644 --- a/server/private/routers/orgIdp/updateOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/updateOrgOidcIdp.ts @@ -24,9 +24,10 @@ import { idp, idpOidcConfig } from "@server/db"; import { eq, and } from "drizzle-orm"; import { encrypt } from "@server/lib/crypto"; import config from "@server/lib/config"; +import { isSubscribed } from "#private/lib/isSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import privateConfig from "#private/lib/config"; import { build } from "@server/build"; -import { getOrgTierData } from "#private/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; const paramsSchema = z .object({ @@ -46,30 +47,31 @@ const bodySchema = z.strictObject({ namePath: z.string().optional(), scopes: z.string().optional(), autoProvision: z.boolean().optional(), - roleMapping: z.string().optional() + roleMapping: z.string().optional(), + tags: z.string().optional() }); export type UpdateOrgIdpResponse = { idpId: number; }; -// registry.registerPath({ -// method: "post", -// path: "/idp/{idpId}/oidc", -// description: "Update an OIDC IdP.", -// tags: [OpenAPITags.Idp], -// request: { -// params: paramsSchema, -// body: { -// content: { -// "application/json": { -// schema: bodySchema -// } -// } -// } -// }, -// responses: {} -// }); +registry.registerPath({ + method: "post", + path: "/org/{orgId}/idp/{idpId}/oidc", + description: "Update an OIDC IdP for a specific organization.", + tags: [OpenAPITags.OrgIdp], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); export async function updateOrgOidcIdp( req: Request, @@ -97,6 +99,18 @@ export async function updateOrgOidcIdp( ); } + if ( + privateConfig.getRawPrivateConfig().app.identity_provider_mode !== + "org" + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization-specific IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'org' in the private configuration to enable this feature." + ) + ); + } + const { idpId, orgId } = parsedParams.data; const { clientId, @@ -108,20 +122,20 @@ export async function updateOrgOidcIdp( emailPath, namePath, name, - autoProvision, - roleMapping + roleMapping, + tags } = parsedBody.data; - if (build === "saas") { - const { tier, active } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; + let { autoProvision } = parsedBody.data; + + if (build == "saas") { + // this is not paywalled with a ee license because this whole endpoint is restricted + const subscribed = await isSubscribed( + orgId, + tierMatrix.deviceApprovals + ); if (!subscribed) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "This organization's current plan does not support this feature." - ) - ); + autoProvision = false; } } @@ -167,7 +181,8 @@ export async function updateOrgOidcIdp( await db.transaction(async (trx) => { const idpData = { name, - autoProvision + autoProvision, + tags }; // only update if at least one key is not undefined diff --git a/server/private/routers/re-key/reGenerateClientSecret.ts b/server/private/routers/re-key/reGenerateClientSecret.ts index 5478c690c..b2f9e1511 100644 --- a/server/private/routers/re-key/reGenerateClientSecret.ts +++ b/server/private/routers/re-key/reGenerateClientSecret.ts @@ -24,6 +24,8 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { hashPassword } from "@server/auth/password"; import { disconnectClient, sendToClient } from "#private/routers/ws"; +import { OlmErrorCodes, sendOlmError } from "@server/routers/olm/error"; +import { sendTerminateClient } from "@server/routers/client/terminate"; const reGenerateSecretParamsSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) @@ -117,12 +119,12 @@ export async function reGenerateClientSecret( // Only disconnect if explicitly requested if (disconnect) { - const payload = { - type: `olm/terminate`, - data: {} - }; // Don't await this to prevent blocking the response - sendToClient(existingOlms[0].olmId, payload).catch((error) => { + sendTerminateClient( + clientId, + OlmErrorCodes.TERMINATED_REKEYED, + existingOlms[0].olmId + ).catch((error) => { logger.error( "Failed to send termination message to olm:", error diff --git a/server/private/routers/remoteExitNode/createRemoteExitNode.ts b/server/private/routers/remoteExitNode/createRemoteExitNode.ts index f734813e9..f24afdde1 100644 --- a/server/private/routers/remoteExitNode/createRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/createRemoteExitNode.ts @@ -12,7 +12,14 @@ */ import { NextFunction, Request, Response } from "express"; -import { db, exitNodes, exitNodeOrgs, ExitNode, ExitNodeOrg } from "@server/db"; +import { + db, + exitNodes, + exitNodeOrgs, + ExitNode, + ExitNodeOrg, + orgs +} from "@server/db"; import HttpCode from "@server/types/HttpCode"; import { z } from "zod"; import { remoteExitNodes } from "@server/db"; @@ -25,7 +32,7 @@ import { createRemoteExitNodeSession } from "#private/auth/sessions/remoteExitNo import { fromError } from "zod-validation-error"; import { hashPassword, verifyPassword } from "@server/auth/password"; import logger from "@server/logger"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray, ne } from "drizzle-orm"; import { getNextAvailableSubnet } from "@server/lib/exitNodes"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; @@ -72,7 +79,7 @@ export async function createRemoteExitNode( const { remoteExitNodeId, secret } = parsedBody.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -85,7 +92,7 @@ export async function createRemoteExitNode( if (usage) { const rejectRemoteExitNodes = await usageService.checkLimitSet( orgId, - false, + FeatureId.REMOTE_EXIT_NODES, { ...usage, @@ -97,7 +104,7 @@ export async function createRemoteExitNode( return next( createHttpError( HttpCode.FORBIDDEN, - "Remote exit node limit exceeded. Please upgrade your plan or contact us at support@pangolin.net" + "Remote node limit exceeded. Please upgrade your plan." ) ); } @@ -169,7 +176,17 @@ export async function createRemoteExitNode( ); } - let numExitNodeOrgs: ExitNodeOrg[] | undefined; + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Organization not found") + ); + } await db.transaction(async (trx) => { if (!existingExitNode) { @@ -217,19 +234,43 @@ export async function createRemoteExitNode( }); } - numExitNodeOrgs = await trx - .select() - .from(exitNodeOrgs) - .where(eq(exitNodeOrgs.orgId, orgId)); - }); + // calculate if the node is in any other of the orgs before we count it as an add to the billing org + if (org.billingOrgId) { + const otherBillingOrgs = await trx + .select() + .from(orgs) + .where( + and( + eq(orgs.billingOrgId, org.billingOrgId), + ne(orgs.orgId, orgId) + ) + ); - if (numExitNodeOrgs) { - await usageService.updateDaily( - orgId, - FeatureId.REMOTE_EXIT_NODES, - numExitNodeOrgs.length - ); - } + const billingOrgIds = otherBillingOrgs.map((o) => o.orgId); + + const orgsInBillingDomainThatTheNodeIsStillIn = await trx + .select() + .from(exitNodeOrgs) + .where( + and( + eq( + exitNodeOrgs.exitNodeId, + existingExitNode.exitNodeId + ), + inArray(exitNodeOrgs.orgId, billingOrgIds) + ) + ); + + if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) { + await usageService.add( + orgId, + FeatureId.REMOTE_EXIT_NODES, + 1, + trx + ); + } + } + }); const token = generateSessionToken(); await createRemoteExitNodeSession(token, remoteExitNodeId); diff --git a/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts b/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts index a23363fc8..6ff6841ce 100644 --- a/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts +++ b/server/private/routers/remoteExitNode/deleteRemoteExitNode.ts @@ -13,9 +13,9 @@ import { NextFunction, Request, Response } from "express"; import { z } from "zod"; -import { db, ExitNodeOrg, exitNodeOrgs, exitNodes } from "@server/db"; +import { db, ExitNodeOrg, exitNodeOrgs, exitNodes, orgs } from "@server/db"; import { remoteExitNodes } from "@server/db"; -import { and, count, eq } from "drizzle-orm"; +import { and, count, eq, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -50,7 +50,8 @@ export async function deleteRemoteExitNode( const [remoteExitNode] = await db .select() .from(remoteExitNodes) - .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)); + .where(eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId)) + .limit(1); if (!remoteExitNode) { return next( @@ -70,7 +71,17 @@ export async function deleteRemoteExitNode( ); } - let numExitNodeOrgs: ExitNodeOrg[] | undefined; + const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); + + if (!org) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Org with ID ${orgId} not found` + ) + ); + } + await db.transaction(async (trx) => { await trx .delete(exitNodeOrgs) @@ -81,38 +92,39 @@ export async function deleteRemoteExitNode( ) ); - const [remainingExitNodeOrgs] = await trx - .select({ count: count() }) - .from(exitNodeOrgs) - .where(eq(exitNodeOrgs.exitNodeId, remoteExitNode.exitNodeId!)); + // calculate if the user is in any other of the orgs before we count it as an remove to the billing org + if (org.billingOrgId) { + const otherBillingOrgs = await trx + .select() + .from(orgs) + .where(eq(orgs.billingOrgId, org.billingOrgId)); - if (remainingExitNodeOrgs.count === 0) { - await trx - .delete(remoteExitNodes) + const billingOrgIds = otherBillingOrgs.map((o) => o.orgId); + + const orgsInBillingDomainThatTheNodeIsStillIn = await trx + .select() + .from(exitNodeOrgs) .where( - eq(remoteExitNodes.remoteExitNodeId, remoteExitNodeId) + and( + eq( + exitNodeOrgs.exitNodeId, + remoteExitNode.exitNodeId! + ), + inArray(exitNodeOrgs.orgId, billingOrgIds) + ) ); - await trx - .delete(exitNodes) - .where( - eq(exitNodes.exitNodeId, remoteExitNode.exitNodeId!) + + if (orgsInBillingDomainThatTheNodeIsStillIn.length === 0) { + await usageService.add( + orgId, + FeatureId.REMOTE_EXIT_NODES, + -1, + trx ); + } } - - numExitNodeOrgs = await trx - .select() - .from(exitNodeOrgs) - .where(eq(exitNodeOrgs.orgId, orgId)); }); - if (numExitNodeOrgs) { - await usageService.updateDaily( - orgId, - FeatureId.REMOTE_EXIT_NODES, - numExitNodeOrgs.length - ); - } - return response(res, { data: null, success: true, diff --git a/server/private/routers/remoteExitNode/getRemoteExitNodeToken.ts b/server/private/routers/remoteExitNode/getRemoteExitNodeToken.ts index 24f0de159..025e2d34e 100644 --- a/server/private/routers/remoteExitNode/getRemoteExitNodeToken.ts +++ b/server/private/routers/remoteExitNode/getRemoteExitNodeToken.ts @@ -23,8 +23,10 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { createRemoteExitNodeSession, - validateRemoteExitNodeSessionToken + validateRemoteExitNodeSessionToken, + EXPIRES } from "#private/auth/sessions/remoteExitNode"; +import { getOrCreateCachedToken } from "@server/private/lib/tokenCache"; import { verifyPassword } from "@server/auth/password"; import logger from "@server/logger"; import config from "@server/lib/config"; @@ -103,14 +105,23 @@ export async function getRemoteExitNodeToken( ); } - const resToken = generateSessionToken(); - await createRemoteExitNodeSession( - resToken, - existingRemoteExitNode.remoteExitNodeId + // Return a cached token if one exists to prevent thundering herd on + // simultaneous restarts; falls back to creating a fresh session when + // Redis is unavailable or the cache has expired. + const resToken = await getOrCreateCachedToken( + `remote_exit_node:token_cache:${existingRemoteExitNode.remoteExitNodeId}`, + config.getRawConfig().server.secret!, + Math.floor(EXPIRES / 1000), + async () => { + const token = generateSessionToken(); + await createRemoteExitNodeSession( + token, + existingRemoteExitNode.remoteExitNodeId + ); + return token; + } ); - // logger.debug(`Created RemoteExitNode token response: ${JSON.stringify(resToken)}`); - return response<{ token: string }>(res, { data: { token: resToken diff --git a/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts b/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts index dafc14121..9c2889a99 100644 --- a/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts +++ b/server/private/routers/remoteExitNode/handleRemoteExitNodePingMessage.ts @@ -38,7 +38,7 @@ export const startRemoteExitNodeOfflineChecker = (): void => { ); // Find clients that haven't pinged in the last 2 minutes and mark them as offline - const newlyOfflineNodes = await db + const offlineNodes = await db .update(exitNodes) .set({ online: false }) .where( @@ -53,32 +53,15 @@ export const startRemoteExitNodeOfflineChecker = (): void => { ) .returning(); - // Update the sites to offline if they have not pinged either - const exitNodeIds = newlyOfflineNodes.map( - (node) => node.exitNodeId - ); - - const sitesOnNode = await db - .select() - .from(sites) - .where( - and( - eq(sites.online, true), - inArray(sites.exitNodeId, exitNodeIds) - ) + if (offlineNodes.length > 0) { + logger.info( + `checkRemoteExitNodeOffline: Marked ${offlineNodes.length} remoteExitNode client(s) offline due to inactivity` ); - // loop through the sites and process their lastBandwidthUpdate as an iso string and if its more than 1 minute old then mark the site offline - for (const site of sitesOnNode) { - if (!site.lastBandwidthUpdate) { - continue; - } - const lastBandwidthUpdate = new Date(site.lastBandwidthUpdate); - if (Date.now() - lastBandwidthUpdate.getTime() > 60 * 1000) { - await db - .update(sites) - .set({ online: false }) - .where(eq(sites.siteId, site.siteId)); + for (const offlineClient of offlineNodes) { + logger.debug( + `checkRemoteExitNodeOffline: Client ${offlineClient.exitNodeId} marked offline (lastPing: ${offlineClient.lastPing})` + ); } } } catch (error) { diff --git a/server/private/routers/resource/getMaintenanceInfo.ts b/server/private/routers/resource/getMaintenanceInfo.ts new file mode 100644 index 000000000..e3e739c6e --- /dev/null +++ b/server/private/routers/resource/getMaintenanceInfo.ts @@ -0,0 +1,113 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { resources } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import { GetMaintenanceInfoResponse } from "@server/routers/resource/types"; + +const getMaintenanceInfoSchema = z + .object({ + fullDomain: z.string().min(1, "Domain is required") + }) + .strict(); + +async function query(fullDomain: string) { + const [res] = await db + .select({ + resourceId: resources.resourceId, + name: resources.name, + fullDomain: resources.fullDomain, + maintenanceModeEnabled: resources.maintenanceModeEnabled, + maintenanceModeType: resources.maintenanceModeType, + maintenanceTitle: resources.maintenanceTitle, + maintenanceMessage: resources.maintenanceMessage, + maintenanceEstimatedTime: resources.maintenanceEstimatedTime + }) + .from(resources) + .where(eq(resources.fullDomain, fullDomain)) + .limit(1); + return res; +} + +registry.registerPath({ + method: "get", + path: "/maintenance/info", + description: "Get maintenance information for a resource by domain.", + tags: [OpenAPITags.PublicResource], + request: { + query: z.object({ + fullDomain: z.string() + }) + }, + responses: { + 200: { + description: "Maintenance information retrieved successfully" + }, + 404: { + description: "Resource not found" + } + } +}); + +export async function getMaintenanceInfo( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = getMaintenanceInfoSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { fullDomain } = parsedQuery.data; + + const maintenanceInfo = await query(fullDomain); + + if (!maintenanceInfo) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Resource not found") + ); + } + + return response(res, { + data: maintenanceInfo, + success: true, + error: false, + message: "Maintenance information retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred while retrieving maintenance information" + ) + ); + } +} diff --git a/server/private/routers/resource/index.ts b/server/private/routers/resource/index.ts new file mode 100644 index 000000000..f82b55524 --- /dev/null +++ b/server/private/routers/resource/index.ts @@ -0,0 +1,14 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./getMaintenanceInfo"; diff --git a/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts new file mode 100644 index 000000000..abed27550 --- /dev/null +++ b/server/private/routers/siteProvisioning/createSiteProvisioningKey.ts @@ -0,0 +1,146 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { NextFunction, Request, Response } from "express"; +import { db, siteProvisioningKeyOrg, siteProvisioningKeys } from "@server/db"; +import HttpCode from "@server/types/HttpCode"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import moment from "moment"; +import { + generateId, + generateIdFromEntropySize +} from "@server/auth/sessions/app"; +import logger from "@server/logger"; +import { hashPassword } from "@server/auth/password"; +import type { CreateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; + +const paramsSchema = z.object({ + orgId: z.string().nonempty() +}); + +const bodySchema = z + .strictObject({ + name: z.string().min(1).max(255), + maxBatchSize: z.union([ + z.null(), + z.coerce.number().int().positive().max(1_000_000) + ]), + validUntil: z.string().max(255).optional() + }) + .superRefine((data, ctx) => { + const v = data.validUntil; + if (v == null || v.trim() === "") { + return; + } + if (Number.isNaN(Date.parse(v))) { + ctx.addIssue({ + code: "custom", + message: "Invalid validUntil", + path: ["validUntil"] + }); + } + }); + +export type CreateSiteProvisioningKeyBody = z.infer; + +export async function createSiteProvisioningKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { name, maxBatchSize } = parsedBody.data; + const vuRaw = parsedBody.data.validUntil; + const validUntil = + vuRaw == null || vuRaw.trim() === "" + ? null + : new Date(Date.parse(vuRaw)).toISOString(); + + const siteProvisioningKeyId = `spk-${generateId(15)}`; + const siteProvisioningKey = generateIdFromEntropySize(25); + const siteProvisioningKeyHash = await hashPassword(siteProvisioningKey); + const lastChars = siteProvisioningKey.slice(-4); + const createdAt = moment().toISOString(); + const provisioningKey = `${siteProvisioningKeyId}.${siteProvisioningKey}`; + + await db.transaction(async (trx) => { + await trx.insert(siteProvisioningKeys).values({ + siteProvisioningKeyId, + name, + siteProvisioningKeyHash, + createdAt, + lastChars, + lastUsed: null, + maxBatchSize, + numUsed: 0, + validUntil + }); + + await trx.insert(siteProvisioningKeyOrg).values({ + siteProvisioningKeyId, + orgId + }); + }); + + try { + return response(res, { + data: { + siteProvisioningKeyId, + orgId, + name, + siteProvisioningKey: provisioningKey, + lastChars, + createdAt, + lastUsed: null, + maxBatchSize, + numUsed: 0, + validUntil + }, + success: true, + error: false, + message: "Site provisioning key created", + status: HttpCode.CREATED + }); + } catch (e) { + logger.error(e); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create site provisioning key" + ) + ); + } +} diff --git a/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts new file mode 100644 index 000000000..fc8b05e60 --- /dev/null +++ b/server/private/routers/siteProvisioning/deleteSiteProvisioningKey.ts @@ -0,0 +1,129 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + siteProvisioningKeyOrg, + siteProvisioningKeys +} from "@server/db"; +import { and, eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; + +const paramsSchema = z.object({ + siteProvisioningKeyId: z.string().nonempty(), + orgId: z.string().nonempty() +}); + +export async function deleteSiteProvisioningKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { siteProvisioningKeyId, orgId } = parsedParams.data; + + const [row] = await db + .select() + .from(siteProvisioningKeys) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ) + .innerJoin( + siteProvisioningKeyOrg, + and( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyOrg.siteProvisioningKeyId + ), + eq(siteProvisioningKeyOrg.orgId, orgId) + ) + ) + .limit(1); + + if (!row) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site provisioning key with ID ${siteProvisioningKeyId} not found` + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(siteProvisioningKeyOrg) + .where( + and( + eq( + siteProvisioningKeyOrg.siteProvisioningKeyId, + siteProvisioningKeyId + ), + eq(siteProvisioningKeyOrg.orgId, orgId) + ) + ); + + const siteProvisioningKeyOrgs = await trx + .select() + .from(siteProvisioningKeyOrg) + .where( + eq( + siteProvisioningKeyOrg.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ); + + if (siteProvisioningKeyOrgs.length === 0) { + await trx + .delete(siteProvisioningKeys) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ); + } + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Site provisioning key deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/siteProvisioning/index.ts b/server/private/routers/siteProvisioning/index.ts new file mode 100644 index 000000000..d143274f6 --- /dev/null +++ b/server/private/routers/siteProvisioning/index.ts @@ -0,0 +1,17 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./createSiteProvisioningKey"; +export * from "./listSiteProvisioningKeys"; +export * from "./deleteSiteProvisioningKey"; +export * from "./updateSiteProvisioningKey"; diff --git a/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts new file mode 100644 index 000000000..5f7531a2c --- /dev/null +++ b/server/private/routers/siteProvisioning/listSiteProvisioningKeys.ts @@ -0,0 +1,126 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { + db, + siteProvisioningKeyOrg, + siteProvisioningKeys +} from "@server/db"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import response from "@server/lib/response"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { eq } from "drizzle-orm"; +import type { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types"; + +const paramsSchema = z.object({ + orgId: z.string().nonempty() +}); + +const querySchema = z.object({ + limit: z + .string() + .optional() + .default("1000") + .transform(Number) + .pipe(z.int().positive()), + offset: z + .string() + .optional() + .default("0") + .transform(Number) + .pipe(z.int().nonnegative()) +}); + +function querySiteProvisioningKeys(orgId: string) { + return db + .select({ + siteProvisioningKeyId: + siteProvisioningKeys.siteProvisioningKeyId, + orgId: siteProvisioningKeyOrg.orgId, + lastChars: siteProvisioningKeys.lastChars, + createdAt: siteProvisioningKeys.createdAt, + name: siteProvisioningKeys.name, + lastUsed: siteProvisioningKeys.lastUsed, + maxBatchSize: siteProvisioningKeys.maxBatchSize, + numUsed: siteProvisioningKeys.numUsed, + validUntil: siteProvisioningKeys.validUntil + }) + .from(siteProvisioningKeyOrg) + .innerJoin( + siteProvisioningKeys, + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyOrg.siteProvisioningKeyId + ) + ) + .where(eq(siteProvisioningKeyOrg.orgId, orgId)); +} + +export async function listSiteProvisioningKeys( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + + const { orgId } = parsedParams.data; + const { limit, offset } = parsedQuery.data; + + const siteProvisioningKeysList = await querySiteProvisioningKeys(orgId) + .limit(limit) + .offset(offset); + + return response(res, { + data: { + siteProvisioningKeys: siteProvisioningKeysList, + pagination: { + total: siteProvisioningKeysList.length, + limit, + offset + } + }, + success: true, + error: false, + message: "Site provisioning keys retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts new file mode 100644 index 000000000..526d8bfb8 --- /dev/null +++ b/server/private/routers/siteProvisioning/updateSiteProvisioningKey.ts @@ -0,0 +1,199 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + siteProvisioningKeyOrg, + siteProvisioningKeys +} from "@server/db"; +import { and, eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import type { UpdateSiteProvisioningKeyResponse } from "@server/routers/siteProvisioning/types"; + +const paramsSchema = z.object({ + siteProvisioningKeyId: z.string().nonempty(), + orgId: z.string().nonempty() +}); + +const bodySchema = z + .strictObject({ + maxBatchSize: z + .union([ + z.null(), + z.coerce.number().int().positive().max(1_000_000) + ]) + .optional(), + validUntil: z.string().max(255).optional() + }) + .superRefine((data, ctx) => { + if ( + data.maxBatchSize === undefined && + data.validUntil === undefined + ) { + ctx.addIssue({ + code: "custom", + message: "Provide maxBatchSize and/or validUntil", + path: ["maxBatchSize"] + }); + } + const v = data.validUntil; + if (v == null || v.trim() === "") { + return; + } + if (Number.isNaN(Date.parse(v))) { + ctx.addIssue({ + code: "custom", + message: "Invalid validUntil", + path: ["validUntil"] + }); + } + }); + +export type UpdateSiteProvisioningKeyBody = z.infer; + +export async function updateSiteProvisioningKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { siteProvisioningKeyId, orgId } = parsedParams.data; + const body = parsedBody.data; + + const [row] = await db + .select() + .from(siteProvisioningKeys) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ) + .innerJoin( + siteProvisioningKeyOrg, + and( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyOrg.siteProvisioningKeyId + ), + eq(siteProvisioningKeyOrg.orgId, orgId) + ) + ) + .limit(1); + + if (!row) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Site provisioning key with ID ${siteProvisioningKeyId} not found` + ) + ); + } + + const setValues: { + maxBatchSize?: number | null; + validUntil?: string | null; + } = {}; + if (body.maxBatchSize !== undefined) { + setValues.maxBatchSize = body.maxBatchSize; + } + if (body.validUntil !== undefined) { + setValues.validUntil = + body.validUntil.trim() === "" + ? null + : new Date(Date.parse(body.validUntil)).toISOString(); + } + + await db + .update(siteProvisioningKeys) + .set(setValues) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ); + + const [updated] = await db + .select({ + siteProvisioningKeyId: + siteProvisioningKeys.siteProvisioningKeyId, + name: siteProvisioningKeys.name, + lastChars: siteProvisioningKeys.lastChars, + createdAt: siteProvisioningKeys.createdAt, + lastUsed: siteProvisioningKeys.lastUsed, + maxBatchSize: siteProvisioningKeys.maxBatchSize, + numUsed: siteProvisioningKeys.numUsed, + validUntil: siteProvisioningKeys.validUntil + }) + .from(siteProvisioningKeys) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyId + ) + ) + .limit(1); + + if (!updated) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to load updated site provisioning key" + ) + ); + } + + return response(res, { + data: { + ...updated, + orgId + }, + success: true, + error: false, + message: "Site provisioning key updated successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/ssh/index.ts b/server/private/routers/ssh/index.ts new file mode 100644 index 000000000..a98405ba2 --- /dev/null +++ b/server/private/routers/ssh/index.ts @@ -0,0 +1,14 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./signSshKey"; \ No newline at end of file diff --git a/server/private/routers/ssh/signSshKey.ts b/server/private/routers/ssh/signSshKey.ts new file mode 100644 index 000000000..b02d2b23c --- /dev/null +++ b/server/private/routers/ssh/signSshKey.ts @@ -0,0 +1,533 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + actionAuditLog, + db, + logsDb, + newts, + roles, + roundTripMessageTracker, + siteResources, + sites, + userOrgs +} from "@server/db"; +import { logAccessAudit } from "#private/lib/logAccessAudit"; +import { isLicensedOrSubscribed } from "#private/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { and, eq, inArray, or } from "drizzle-orm"; +import { canUserAccessSiteResource } from "@server/auth/canUserAccessSiteResource"; +import { signPublicKey, getOrgCAKeys } from "@server/lib/sshCA"; +import config from "@server/lib/config"; +import { sendToClient } from "#private/routers/ws"; +import { ActionsEnum } from "@server/auth/actions"; + +const paramsSchema = z.strictObject({ + orgId: z.string().nonempty() +}); + +const bodySchema = z + .strictObject({ + publicKey: z.string().nonempty(), + resourceId: z.number().int().positive().optional(), + resource: z.string().nonempty().optional() // this is either the nice id or the alias + }) + .refine( + (data) => { + const fields = [data.resourceId, data.resource]; + const definedFields = fields.filter((field) => field !== undefined); + return definedFields.length === 1; + }, + { + message: + "Exactly one of resourceId, niceId, or alias must be provided" + } + ); + +export type SignSshKeyResponse = { + certificate: string; + messageId: number; + sshUsername: string; + sshHost: string; + resourceId: number; + siteId: number; + keyId: string; + validPrincipals: string[]; + validAfter: string; + validBefore: string; + expiresIn: number; +}; + +// registry.registerPath({ +// method: "post", +// path: "/org/{orgId}/ssh/sign-key", +// description: "Sign an SSH public key for access to a resource.", +// tags: [OpenAPITags.Org, OpenAPITags.Ssh], +// request: { +// params: paramsSchema, +// body: { +// content: { +// "application/json": { +// schema: bodySchema +// } +// } +// } +// }, +// responses: {} +// }); + +export async function signSshKey( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { + publicKey, + resourceId, + resource: resourceQueryString + } = parsedBody.data; + const userId = req.user?.userId; + const roleIds = req.userOrgRoleIds ?? []; + + if (!userId) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "User not authenticated") + ); + } + + if (roleIds.length === 0) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User has no role in organization" + ) + ); + } + + const [userOrg] = await db + .select() + .from(userOrgs) + .where(and(eq(userOrgs.orgId, orgId), eq(userOrgs.userId, userId))) + .limit(1); + + if (!userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not belong to the specified organization" + ) + ); + } + + const isLicensed = await isLicensedOrSubscribed( + orgId, + tierMatrix.sshPam + ); + if (!isLicensed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "SSH key signing requires a paid plan" + ) + ); + } + + let usernameToUse; + if (!userOrg.pamUsername) { + if (req.user?.email) { + // Extract username from email (first part before @) + usernameToUse = req.user?.email + .split("@")[0] + .replace(/[^a-zA-Z0-9_-]/g, ""); + if (!usernameToUse) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Unable to extract username from email" + ) + ); + } + } else if (req.user?.username) { + usernameToUse = req.user.username; + // We need to clean out any spaces or special characters from the username to ensure it's valid for SSH certificates + usernameToUse = usernameToUse.replace(/[^a-zA-Z0-9_-]/g, "-"); + if (!usernameToUse) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Username is not valid for SSH certificate" + ) + ); + } + } else { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User does not have a valid email or username for SSH certificate" + ) + ); + } + + // prefix with p- + usernameToUse = `p-${usernameToUse}`; + + // check if we have a existing user in this org with the same + const [existingUserWithSameName] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.orgId, orgId), + eq(userOrgs.pamUsername, usernameToUse) + ) + ) + .limit(1); + + if (existingUserWithSameName) { + let foundUniqueUsername = false; + for (let attempt = 0; attempt < 20; attempt++) { + const randomNum = Math.floor(Math.random() * 101); // 0 to 100 + const candidateUsername = `${usernameToUse}${randomNum}`; + + const [existingUser] = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.orgId, orgId), + eq(userOrgs.pamUsername, candidateUsername) + ) + ) + .limit(1); + + if (!existingUser) { + usernameToUse = candidateUsername; + foundUniqueUsername = true; + break; + } + } + + if (!foundUniqueUsername) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Unable to generate a unique username for SSH certificate" + ) + ); + } + } + + await db + .update(userOrgs) + .set({ pamUsername: usernameToUse }) + .where( + and( + eq(userOrgs.orgId, orgId), + eq(userOrgs.userId, userId) + ) + ); + } else { + usernameToUse = userOrg.pamUsername; + } + + // Get and decrypt the org's CA keys + const caKeys = await getOrgCAKeys( + orgId, + config.getRawConfig().server.secret! + ); + + if (!caKeys) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "SSH CA not configured for this organization" + ) + ); + } + + // Verify the resource exists and belongs to the org + // Build the where clause dynamically based on which field is provided + let whereClause; + if (resourceId !== undefined) { + whereClause = eq(siteResources.siteResourceId, resourceId); + } else if (resourceQueryString !== undefined) { + whereClause = or( + eq(siteResources.niceId, resourceQueryString), + eq(siteResources.alias, resourceQueryString) + ); + } else { + // This should never happen due to the schema validation, but TypeScript doesn't know that + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One of resourceId, niceId, or alias must be provided" + ) + ); + } + + const resources = await db + .select() + .from(siteResources) + .where(and(whereClause, eq(siteResources.orgId, orgId))); + + if (!resources || resources.length === 0) { + return next( + createHttpError(HttpCode.NOT_FOUND, `Resource not found`) + ); + } + + if (resources.length > 1) { + // error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Multiple resources found matching the criteria` + ) + ); + } + + const resource = resources[0]; + + if (resource.orgId !== orgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Resource does not belong to the specified organization" + ) + ); + } + + if (resource.mode == "cidr") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "SSHing is not supported for CIDR resources" + ) + ); + } + + // Check if the user has access to the resource + const hasAccess = await canUserAccessSiteResource({ + userId: userId, + resourceId: resource.siteResourceId, + roleIds + }); + + if (!hasAccess) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this resource" + ) + ); + } + + const roleRows = await db + .select() + .from(roles) + .where(inArray(roles.roleId, roleIds)); + + const parsedSudoCommands: string[] = []; + const parsedGroupsSet = new Set(); + let homedir: boolean | null = null; + const sudoModeOrder = { none: 0, commands: 1, all: 2 }; + let sudoMode: "none" | "commands" | "all" = "none"; + for (const roleRow of roleRows) { + try { + const cmds = JSON.parse(roleRow?.sshSudoCommands ?? "[]"); + if (Array.isArray(cmds)) parsedSudoCommands.push(...cmds); + } catch { + // skip + } + try { + const grps = JSON.parse(roleRow?.sshUnixGroups ?? "[]"); + if (Array.isArray(grps)) grps.forEach((g: string) => parsedGroupsSet.add(g)); + } catch { + // skip + } + if (roleRow?.sshCreateHomeDir === true) homedir = true; + const m = roleRow?.sshSudoMode ?? "none"; + if (sudoModeOrder[m as keyof typeof sudoModeOrder] > sudoModeOrder[sudoMode]) { + sudoMode = m as "none" | "commands" | "all"; + } + } + const parsedGroups = Array.from(parsedGroupsSet); + if (homedir === null && roleRows.length > 0) { + homedir = roleRows[0].sshCreateHomeDir ?? null; + } + + // get the site + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.siteId, resource.siteId)) + .limit(1); + + if (!newt) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Site associated with resource not found" + ) + ); + } + + // Sign the public key + const now = BigInt(Math.floor(Date.now() / 1000)); + // only valid for 5 minutes + const validFor = 300n; + + const cert = signPublicKey(caKeys.privateKeyPem, publicKey, { + keyId: `${usernameToUse}@${resource.niceId}`, + validPrincipals: [usernameToUse, resource.niceId], + validAfter: now - 60n, // Start 1 min ago for clock skew + validBefore: now + validFor + }); + + const [message] = await db + .insert(roundTripMessageTracker) + .values({ + wsClientId: newt.newtId, + messageType: `newt/pam/connection`, + sentAt: Math.floor(Date.now() / 1000) + }) + .returning(); + + if (!message) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create message tracker entry" + ) + ); + } + + await sendToClient(newt.newtId, { + type: `newt/pam/connection`, + data: { + messageId: message.messageId, + orgId: orgId, + agentPort: resource.authDaemonPort ?? 22123, + externalAuthDaemon: resource.authDaemonMode === "remote", + agentHost: resource.destination, + caCert: caKeys.publicKeyOpenSSH, + username: usernameToUse, + niceId: resource.niceId, + metadata: { + sudoMode: sudoMode, + sudoCommands: parsedSudoCommands, + homedir: homedir, + groups: parsedGroups + } + } + }); + + const expiresIn = Number(validFor); // seconds + + let sshHost; + if (resource.alias && resource.alias != "") { + sshHost = resource.alias; + } else { + sshHost = resource.destination; + } + + await logsDb.insert(actionAuditLog).values({ + timestamp: Math.floor(Date.now() / 1000), + orgId: orgId, + actorType: "user", + actor: req.user?.username ?? "", + actorId: req.user?.userId ?? "", + action: ActionsEnum.signSshKey, + metadata: JSON.stringify({ + resourceId: resource.siteResourceId, + resource: resource.name, + siteId: resource.siteId, + }) + }); + + await logAccessAudit({ + action: true, + type: "ssh", + orgId: orgId, + siteResourceId: resource.siteResourceId, + user: req.user + ? { username: req.user.username ?? "", userId: req.user.userId } + : undefined, + metadata: { + resourceName: resource.name, + siteId: resource.siteId, + sshUsername: usernameToUse, + sshHost: sshHost + }, + userAgent: req.headers["user-agent"], + requestIp: req.ip + }); + + return response(res, { + data: { + certificate: cert.certificate, + messageId: message.messageId, + sshUsername: usernameToUse, + sshHost: sshHost, + resourceId: resource.siteResourceId, + siteId: resource.siteId, + keyId: cert.keyId, + validPrincipals: cert.validPrincipals, + validAfter: cert.validAfter.toISOString(), + validBefore: cert.validBefore.toISOString(), + expiresIn + }, + success: true, + error: false, + message: "SSH key signed successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error("Error signing SSH key:", error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred while signing the SSH key" + ) + ); + } +} diff --git a/server/routers/user/addUserRole.ts b/server/private/routers/user/addUserRole.ts similarity index 78% rename from server/routers/user/addUserRole.ts rename to server/private/routers/user/addUserRole.ts index 32eaa19d7..a46bd1ed8 100644 --- a/server/routers/user/addUserRole.ts +++ b/server/private/routers/user/addUserRole.ts @@ -1,14 +1,27 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { clients, db, UserOrg } from "@server/db"; -import { userOrgs, roles } from "@server/db"; +import stoi from "@server/lib/stoi"; +import { clients, db } from "@server/db"; +import { userOrgRoles, userOrgs, roles } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import stoi from "@server/lib/stoi"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; @@ -17,11 +30,9 @@ const addUserRoleParamsSchema = z.strictObject({ roleId: z.string().transform(stoi).pipe(z.number()) }); -export type AddUserRoleResponse = z.infer; - registry.registerPath({ method: "post", - path: "/role/{roleId}/add/{userId}", + path: "/user/{userId}/add-role/{roleId}", description: "Add a role to a user.", tags: [OpenAPITags.Role, OpenAPITags.User], request: { @@ -111,20 +122,23 @@ export async function addUserRole( ); } - let newUserRole: UserOrg | null = null; + let newUserRole: { userId: string; orgId: string; roleId: number } | null = + null; await db.transaction(async (trx) => { - [newUserRole] = await trx - .update(userOrgs) - .set({ roleId }) - .where( - and( - eq(userOrgs.userId, userId), - eq(userOrgs.orgId, role.orgId) - ) - ) + const inserted = await trx + .insert(userOrgRoles) + .values({ + userId, + orgId: role.orgId, + roleId + }) + .onConflictDoNothing() .returning(); - // get the client associated with this user in this org + if (inserted.length > 0) { + newUserRole = inserted[0]; + } + const orgClients = await trx .select() .from(clients) @@ -133,17 +147,15 @@ export async function addUserRole( eq(clients.userId, userId), eq(clients.orgId, role.orgId) ) - ) - .limit(1); + ); for (const orgClient of orgClients) { - // we just changed the user's role, so we need to rebuild client associations and what they have access to await rebuildClientAssociationsFromClient(orgClient, trx); } }); return response(res, { - data: newUserRole, + data: newUserRole ?? { userId, orgId: role.orgId, roleId }, success: true, error: false, message: "Role added to user successfully", diff --git a/server/private/routers/user/index.ts b/server/private/routers/user/index.ts new file mode 100644 index 000000000..6317eced5 --- /dev/null +++ b/server/private/routers/user/index.ts @@ -0,0 +1,16 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +export * from "./addUserRole"; +export * from "./removeUserRole"; +export * from "./setUserOrgRoles"; diff --git a/server/private/routers/user/removeUserRole.ts b/server/private/routers/user/removeUserRole.ts new file mode 100644 index 000000000..e9c3d10c0 --- /dev/null +++ b/server/private/routers/user/removeUserRole.ts @@ -0,0 +1,171 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import stoi from "@server/lib/stoi"; +import { db } from "@server/db"; +import { userOrgRoles, userOrgs, roles, clients } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; + +const removeUserRoleParamsSchema = z.strictObject({ + userId: z.string(), + roleId: z.string().transform(stoi).pipe(z.number()) +}); + +registry.registerPath({ + method: "delete", + path: "/user/{userId}/remove-role/{roleId}", + description: + "Remove a role from a user. User must have at least one role left in the org.", + tags: [OpenAPITags.Role, OpenAPITags.User], + request: { + params: removeUserRoleParamsSchema + }, + responses: {} +}); + +export async function removeUserRole( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = removeUserRoleParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId, roleId } = parsedParams.data; + + if (req.user && !req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have access to this organization" + ) + ); + } + + const [role] = await db + .select() + .from(roles) + .where(eq(roles.roleId, roleId)) + .limit(1); + + if (!role) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID") + ); + } + + const [existingUser] = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)) + ) + .limit(1); + + if (!existingUser) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found or does not belong to the specified organization" + ) + ); + } + + if (existingUser.isOwner) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Cannot change the roles of the owner of the organization" + ) + ); + } + + const remainingRoles = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, role.orgId) + ) + ); + + if (remainingRoles.length <= 1) { + const hasThisRole = remainingRoles.some((r) => r.roleId === roleId); + if (hasThisRole) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User must have at least one role in the organization. Remove the last role is not allowed." + ) + ); + } + } + + await db.transaction(async (trx) => { + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, role.orgId), + eq(userOrgRoles.roleId, roleId) + ) + ); + + const orgClients = await trx + .select() + .from(clients) + .where( + and( + eq(clients.userId, userId), + eq(clients.orgId, role.orgId) + ) + ); + + for (const orgClient of orgClients) { + await rebuildClientAssociationsFromClient(orgClient, trx); + } + }); + + return response(res, { + data: { userId, orgId: role.orgId, roleId }, + success: true, + error: false, + message: "Role removed from user successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/user/setUserOrgRoles.ts b/server/private/routers/user/setUserOrgRoles.ts new file mode 100644 index 000000000..67563fd26 --- /dev/null +++ b/server/private/routers/user/setUserOrgRoles.ts @@ -0,0 +1,163 @@ +/* + * This file is part of a proprietary work. + * + * Copyright (c) 2025 Fossorial, Inc. + * All rights reserved. + * + * This file is licensed under the Fossorial Commercial License. + * You may not use this file except in compliance with the License. + * Unauthorized use, copying, modification, or distribution is strictly prohibited. + * + * This file is not licensed under the AGPLv3. + */ + +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { clients, db } from "@server/db"; +import { userOrgRoles, userOrgs, roles } from "@server/db"; +import { eq, and, inArray } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; + +const setUserOrgRolesParamsSchema = z.strictObject({ + orgId: z.string(), + userId: z.string() +}); + +const setUserOrgRolesBodySchema = z.strictObject({ + roleIds: z.array(z.int().positive()).min(1) +}); + +export async function setUserOrgRoles( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = setUserOrgRolesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = setUserOrgRolesBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { orgId, userId } = parsedParams.data; + const { roleIds } = parsedBody.data; + + if (req.user && !req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have access to this organization" + ) + ); + } + + const uniqueRoleIds = [...new Set(roleIds)]; + + const [existingUser] = await db + .select() + .from(userOrgs) + .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .limit(1); + + if (!existingUser) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found in this organization" + ) + ); + } + + if (existingUser.isOwner) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Cannot change the roles of the owner of the organization" + ) + ); + } + + const orgRoles = await db + .select({ roleId: roles.roleId }) + .from(roles) + .where( + and( + eq(roles.orgId, orgId), + inArray(roles.roleId, uniqueRoleIds) + ) + ); + + if (orgRoles.length !== uniqueRoleIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "One or more role IDs are invalid for this organization" + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + + if (uniqueRoleIds.length > 0) { + await trx.insert(userOrgRoles).values( + uniqueRoleIds.map((roleId) => ({ + userId, + orgId, + roleId + })) + ); + } + + const orgClients = await trx + .select() + .from(clients) + .where( + and(eq(clients.userId, userId), eq(clients.orgId, orgId)) + ); + + for (const orgClient of orgClients) { + await rebuildClientAssociationsFromClient(orgClient, trx); + } + }); + + return response(res, { + data: { userId, orgId, roleIds: uniqueRoleIds }, + success: true, + error: false, + message: "User roles set successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/private/routers/ws/messageHandlers.ts b/server/private/routers/ws/messageHandlers.ts index 5a6c85cff..a3c9c5bdb 100644 --- a/server/private/routers/ws/messageHandlers.ts +++ b/server/private/routers/ws/messageHandlers.ts @@ -17,10 +17,15 @@ import { startRemoteExitNodeOfflineChecker } from "#private/routers/remoteExitNode"; import { MessageHandler } from "@server/routers/ws"; +import { build } from "@server/build"; +import { handleConnectionLogMessage } from "#dynamic/routers/newt"; export const messageHandlers: Record = { "remoteExitNode/register": handleRemoteExitNodeRegisterMessage, - "remoteExitNode/ping": handleRemoteExitNodePingMessage + "remoteExitNode/ping": handleRemoteExitNodePingMessage, + "newt/access-log": handleConnectionLogMessage, }; -startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes +if (build != "saas") { + startRemoteExitNodeOfflineChecker(); // this is to handle the offline check for remote exit nodes +} diff --git a/server/private/routers/ws/ws.ts b/server/private/routers/ws/ws.ts index 784c3d515..21f4fad37 100644 --- a/server/private/routers/ws/ws.ts +++ b/server/private/routers/ws/ws.ts @@ -12,22 +12,21 @@ */ import { Router, Request, Response } from "express"; +import zlib from "zlib"; import { Server as HttpServer } from "http"; import { WebSocket, WebSocketServer } from "ws"; import { Socket } from "net"; import { Newt, newts, - NewtSession, - olms, Olm, - OlmSession, + olms, RemoteExitNode, - RemoteExitNodeSession, - remoteExitNodes + remoteExitNodes, } from "@server/db"; import { eq } from "drizzle-orm"; import { db } from "@server/db"; +import { recordPing } from "@server/routers/newt/pingAccumulator"; import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateOlmSessionToken } from "@server/auth/sessions/olm"; import logger from "@server/logger"; @@ -43,7 +42,8 @@ import { WSMessage, TokenPayload, WebSocketRequest, - RedisMessage + RedisMessage, + SendMessageOptions } from "@server/routers/ws"; import { validateSessionToken } from "@server/auth/sessions/app"; @@ -56,11 +56,13 @@ const MAX_PENDING_MESSAGES = 50; // Maximum messages to queue during connection const processMessage = async ( ws: AuthenticatedWebSocket, data: Buffer, + isBinary: boolean, clientId: string, clientType: ClientType ): Promise => { try { - const message: WSMessage = JSON.parse(data.toString()); + const messageBuffer = isBinary ? zlib.gunzipSync(data) : data; + const message: WSMessage = JSON.parse(messageBuffer.toString()); // logger.debug( // `Processing message from ${clientType.toUpperCase()} ID: ${clientId}, type: ${message.type}` @@ -75,7 +77,7 @@ const processMessage = async ( clientId, message.type, // Pass message type for granular limiting 100, // max requests per window - 20, // max requests per message type per window + 100, // max requests per message type per window 60 * 1000 // window in milliseconds ); if (rateLimitResult.isLimited) { @@ -118,12 +120,21 @@ const processMessage = async ( if (response.broadcast) { await broadcastToAllExcept( response.message, - response.excludeSender ? clientId : undefined + response.excludeSender ? clientId : undefined, + response.options ); } else if (response.targetClientId) { - await sendToClient(response.targetClientId, response.message); + await sendToClient( + response.targetClientId, + response.message, + response.options + ); } else { - ws.send(JSON.stringify(response.message)); + await sendToClient( + clientId, + response.message, + response.options + ); } } } catch (error) { @@ -153,8 +164,16 @@ const processPendingMessages = async ( ); const jobs = []; - for (const messageData of ws.pendingMessages) { - jobs.push(processMessage(ws, messageData, clientId, clientType)); + for (const pending of ws.pendingMessages) { + jobs.push( + processMessage( + ws, + pending.data, + pending.isBinary, + clientId, + clientType + ) + ); } await Promise.all(jobs); @@ -172,6 +191,11 @@ const REDIS_CHANNEL = "websocket_messages"; // Client tracking map (local to this node) const connectedClients: Map = new Map(); +// Config version tracking map (local to this node, resets on server restart) +const clientConfigVersions: Map = new Map(); + + + // Recovery tracking let isRedisRecoveryInProgress = false; @@ -182,6 +206,8 @@ const getClientMapKey = (clientId: string) => clientId; const getConnectionsKey = (clientId: string) => `ws:connections:${clientId}`; const getNodeConnectionsKey = (nodeId: string, clientId: string) => `ws:node:${nodeId}:${clientId}`; +const getConfigVersionKey = (clientId: string) => + `ws:configVersion:${clientId}`; // Initialize Redis subscription for cross-node messaging const initializeRedisSubscription = async (): Promise => { @@ -304,6 +330,50 @@ const addClient = async ( existingClients.push(ws); connectedClients.set(mapKey, existingClients); + // Get or initialize config version + let configVersion = 0; + + // Check Redis first if enabled + if (redisManager.isRedisEnabled()) { + try { + const redisVersion = await redisManager.get( + getConfigVersionKey(clientId) + ); + if (redisVersion !== null) { + configVersion = parseInt(redisVersion, 10); + // Sync to local cache + clientConfigVersions.set(clientId, configVersion); + } else if (!clientConfigVersions.has(clientId)) { + // No version in Redis or local cache, initialize to 0 + await redisManager.set(getConfigVersionKey(clientId), "0"); + clientConfigVersions.set(clientId, 0); + } else { + // Use local cache version and sync to Redis + configVersion = clientConfigVersions.get(clientId) || 0; + await redisManager.set( + getConfigVersionKey(clientId), + configVersion.toString() + ); + } + } catch (error) { + logger.error("Failed to get/set config version in Redis:", error); + // Fall back to local cache + if (!clientConfigVersions.has(clientId)) { + clientConfigVersions.set(clientId, 0); + } + configVersion = clientConfigVersions.get(clientId) || 0; + } + } else { + // Redis not enabled, use local cache only + if (!clientConfigVersions.has(clientId)) { + clientConfigVersions.set(clientId, 0); + } + configVersion = clientConfigVersions.get(clientId) || 0; + } + + // Set config version on websocket + ws.configVersion = configVersion; + // Add to Redis tracking if enabled if (redisManager.isRedisEnabled()) { try { @@ -322,7 +392,7 @@ const addClient = async ( } logger.info( - `Client added to tracking - ${clientType.toUpperCase()} ID: ${clientId}, Connection ID: ${connectionId}, Total connections: ${existingClients.length}` + `Client added to tracking - ${clientType.toUpperCase()} ID: ${clientId}, Connection ID: ${connectionId}, Total connections: ${existingClients.length}, Config version: ${configVersion}` ); }; @@ -377,53 +447,161 @@ const removeClient = async ( } }; +// Helper to get the current config version for a client +const getClientConfigVersion = async ( + clientId: string +): Promise => { + // Try Redis first if available + if (redisManager.isRedisEnabled()) { + try { + const redisVersion = await redisManager.get( + getConfigVersionKey(clientId) + ); + if (redisVersion !== null) { + const version = parseInt(redisVersion, 10); + // Sync local cache with Redis + clientConfigVersions.set(clientId, version); + return version; + } + } catch (error) { + logger.error("Failed to get config version from Redis:", error); + } + } + + // Fall back to local cache + return clientConfigVersions.get(clientId); +}; + +// Helper to increment and get the new config version for a client +const incrementClientConfigVersion = async ( + clientId: string +): Promise => { + let newVersion: number; + + if (redisManager.isRedisEnabled()) { + try { + // Use Redis INCR for atomic increment across nodes + newVersion = await redisManager.incr(getConfigVersionKey(clientId)); + // Sync local cache + clientConfigVersions.set(clientId, newVersion); + return newVersion; + } catch (error) { + logger.error("Failed to increment config version in Redis:", error); + // Fall through to local increment + } + } + + // Local increment + const currentVersion = clientConfigVersions.get(clientId) || 0; + newVersion = currentVersion + 1; + clientConfigVersions.set(clientId, newVersion); + return newVersion; +}; + // Local message sending (within this node) const sendToClientLocal = async ( clientId: string, - message: WSMessage + message: WSMessage, + options: SendMessageOptions = {} ): Promise => { const mapKey = getClientMapKey(clientId); const clients = connectedClients.get(mapKey); if (!clients || clients.length === 0) { return false; } - const messageString = JSON.stringify(message); - clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(messageString); - } - }); - logger.debug( - `sendToClient: Message type ${message.type} sent to clientId ${clientId}` - ); + // Handle config version + const configVersion = await getClientConfigVersion(clientId); + + // Add config version to message + const messageWithVersion = { + ...message, + configVersion + }; + + const messageString = JSON.stringify(messageWithVersion); + if (options.compress) { + logger.debug( + `Message size before compression: ${messageString.length} bytes` + ); + const compressed = zlib.gzipSync(Buffer.from(messageString, "utf8")); + logger.debug( + `Message size after compression: ${compressed.length} bytes` + ); + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(compressed); + } + }); + } else { + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(messageString); + } + }); + } return true; }; const broadcastToAllExceptLocal = async ( message: WSMessage, - excludeClientId?: string + excludeClientId?: string, + options: SendMessageOptions = {} ): Promise => { - connectedClients.forEach((clients, mapKey) => { + for (const [mapKey, clients] of connectedClients.entries()) { const [type, id] = mapKey.split(":"); - if (!(excludeClientId && id === excludeClientId)) { - clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify(message)); - } - }); + const clientId = mapKey; // mapKey is the clientId + if (!(excludeClientId && clientId === excludeClientId)) { + // Handle config version per client + let configVersion = await getClientConfigVersion(clientId); + if (options.incrementConfigVersion) { + configVersion = await incrementClientConfigVersion(clientId); + } + + // Add config version to message + const messageWithVersion = { + ...message, + configVersion + }; + + if (options.compress) { + const compressed = zlib.gzipSync( + Buffer.from(JSON.stringify(messageWithVersion), "utf8") + ); + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(compressed); + } + }); + } else { + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify(messageWithVersion)); + } + }); + } } - }); + } }; // Cross-node message sending (via Redis) const sendToClient = async ( clientId: string, - message: WSMessage + message: WSMessage, + options: SendMessageOptions = {} ): Promise => { + let configVersion = await getClientConfigVersion(clientId); + if (options.incrementConfigVersion) { + configVersion = await incrementClientConfigVersion(clientId); + } + + logger.debug( + `sendToClient: Message type ${message.type} sent to clientId ${clientId} (new configVersion: ${configVersion})` + ); + // Try to send locally first - const localSent = await sendToClientLocal(clientId, message); + const localSent = await sendToClientLocal(clientId, message, options); // Only send via Redis if the client is not connected locally and Redis is enabled if (!localSent && redisManager.isRedisEnabled()) { @@ -431,7 +609,10 @@ const sendToClient = async ( const redisMessage: RedisMessage = { type: "direct", targetClientId: clientId, - message, + message: { + ...message, + configVersion + }, fromNodeId: NODE_ID }; @@ -458,19 +639,22 @@ const sendToClient = async ( const broadcastToAllExcept = async ( message: WSMessage, - excludeClientId?: string + excludeClientId?: string, + options: SendMessageOptions = {} ): Promise => { // Broadcast locally - await broadcastToAllExceptLocal(message, excludeClientId); + await broadcastToAllExceptLocal(message, excludeClientId, options); // If Redis is enabled, also broadcast via Redis pub/sub to other nodes + // Note: For broadcasts, we include the options so remote nodes can handle versioning if (redisManager.isRedisEnabled()) { try { const redisMessage: RedisMessage = { type: "broadcast", excludeClientId, message, - fromNodeId: NODE_ID + fromNodeId: NODE_ID, + options }; await redisManager.publish( @@ -622,7 +806,7 @@ const setupConnection = async ( } // Set up message handler FIRST to prevent race condition - ws.on("message", async (data) => { + ws.on("message", async (data, isBinary) => { if (!ws.isFullyConnected) { // Queue message for later processing with limits ws.pendingMessages = ws.pendingMessages || []; @@ -637,11 +821,17 @@ const setupConnection = async ( logger.debug( `Queueing message from ${clientType.toUpperCase()} ID: ${clientId} (connection not fully established)` ); - ws.pendingMessages.push(data as Buffer); + ws.pendingMessages.push({ data: data as Buffer, isBinary }); return; } - await processMessage(ws, data as Buffer, clientId, clientType); + await processMessage( + ws, + data as Buffer, + isBinary, + clientId, + clientType + ); }); // Set up other event handlers before async operations @@ -656,6 +846,19 @@ const setupConnection = async ( ); }); + if (clientType === "newt") { + const newtClient = client as Newt; + ws.on("ping", () => { + if (!newtClient.siteId) return; + // Record the ping in the accumulator instead of writing to the + // database on every WS ping frame. The accumulator flushes all + // pending pings in a single batched UPDATE every ~10s, which + // prevents connection pool exhaustion under load (especially + // with cross-region latency to the database). + recordPing(newtClient.siteId); + }); + } + ws.on("error", (error: Error) => { logger.error( `WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`, @@ -936,5 +1139,6 @@ export { getActiveNodes, disconnectClient, NODE_ID, - cleanup + cleanup, + getClientConfigVersion }; diff --git a/server/routers/accessToken/generateAccessToken.ts b/server/routers/accessToken/generateAccessToken.ts index 35da6add3..9d0a7a7df 100644 --- a/server/routers/accessToken/generateAccessToken.ts +++ b/server/routers/accessToken/generateAccessToken.ts @@ -43,7 +43,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/access-token", description: "Generate a new access token for a resource.", - tags: [OpenAPITags.Resource, OpenAPITags.AccessToken], + tags: [OpenAPITags.PublicResource, OpenAPITags.AccessToken], request: { params: generateAccssTokenParamsSchema, body: { diff --git a/server/routers/accessToken/listAccessTokens.ts b/server/routers/accessToken/listAccessTokens.ts index 2f929fc62..55751df81 100644 --- a/server/routers/accessToken/listAccessTokens.ts +++ b/server/routers/accessToken/listAccessTokens.ts @@ -122,7 +122,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/access-tokens", description: "List all access tokens in an organization.", - tags: [OpenAPITags.Org, OpenAPITags.AccessToken], + tags: [OpenAPITags.AccessToken], request: { params: z.object({ orgId: z.string() @@ -135,8 +135,8 @@ registry.registerPath({ registry.registerPath({ method: "get", path: "/resource/{resourceId}/access-tokens", - description: "List all access tokens in an organization.", - tags: [OpenAPITags.Resource, OpenAPITags.AccessToken], + description: "List all access tokens for a resource.", + tags: [OpenAPITags.PublicResource, OpenAPITags.AccessToken], request: { params: z.object({ resourceId: z.number() @@ -208,7 +208,7 @@ export async function listAccessTokens( .where( or( eq(userResources.userId, req.user!.userId), - eq(roleResources.roleId, req.userOrgRoleId!) + inArray(roleResources.roleId, req.userOrgRoleIds!) ) ); } else { diff --git a/server/routers/apiKeys/createOrgApiKey.ts b/server/routers/apiKeys/createOrgApiKey.ts index d61a364b1..91ef72bc8 100644 --- a/server/routers/apiKeys/createOrgApiKey.ts +++ b/server/routers/apiKeys/createOrgApiKey.ts @@ -37,7 +37,7 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/api-key", description: "Create a new API key scoped to the organization.", - tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + tags: [OpenAPITags.ApiKey], request: { params: paramsSchema, body: { diff --git a/server/routers/apiKeys/deleteApiKey.ts b/server/routers/apiKeys/deleteApiKey.ts index 4b97b3530..2627fd636 100644 --- a/server/routers/apiKeys/deleteApiKey.ts +++ b/server/routers/apiKeys/deleteApiKey.ts @@ -18,7 +18,7 @@ registry.registerPath({ method: "delete", path: "/org/{orgId}/api-key/{apiKeyId}", description: "Delete an API key.", - tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + tags: [OpenAPITags.ApiKey], request: { params: paramsSchema }, diff --git a/server/routers/apiKeys/listApiKeyActions.ts b/server/routers/apiKeys/listApiKeyActions.ts index 073a75831..d816d4b38 100644 --- a/server/routers/apiKeys/listApiKeyActions.ts +++ b/server/routers/apiKeys/listApiKeyActions.ts @@ -48,7 +48,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/api-key/{apiKeyId}/actions", description: "List all actions set for an API key.", - tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + tags: [OpenAPITags.ApiKey], request: { params: paramsSchema, query: querySchema diff --git a/server/routers/apiKeys/listOrgApiKeys.ts b/server/routers/apiKeys/listOrgApiKeys.ts index 53191ba63..24370665d 100644 --- a/server/routers/apiKeys/listOrgApiKeys.ts +++ b/server/routers/apiKeys/listOrgApiKeys.ts @@ -52,7 +52,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/api-keys", description: "List all API keys for an organization", - tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + tags: [OpenAPITags.ApiKey], request: { params: paramsSchema, query: querySchema diff --git a/server/routers/apiKeys/setApiKeyActions.ts b/server/routers/apiKeys/setApiKeyActions.ts index 629673886..55b3670ac 100644 --- a/server/routers/apiKeys/setApiKeyActions.ts +++ b/server/routers/apiKeys/setApiKeyActions.ts @@ -25,7 +25,7 @@ registry.registerPath({ path: "/org/{orgId}/api-key/{apiKeyId}/actions", description: "Set actions for an API key. This will replace any existing actions.", - tags: [OpenAPITags.Org, OpenAPITags.ApiKey], + tags: [OpenAPITags.ApiKey], request: { params: paramsSchema, body: { diff --git a/server/routers/auditLogs/exportRequestAuditLog.ts b/server/routers/auditLogs/exportRequestAuditLog.ts index 8b70ec5e1..14054a5c3 100644 --- a/server/routers/auditLogs/exportRequestAuditLog.ts +++ b/server/routers/auditLogs/exportRequestAuditLog.ts @@ -20,7 +20,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/logs/request", description: "Query the request audit log for an organization", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.Logs], request: { query: queryAccessAuditLogsQuery.omit({ limit: true, diff --git a/server/routers/auditLogs/queryRequestAnalytics.ts b/server/routers/auditLogs/queryRequestAnalytics.ts index a765f1765..1e0f1f401 100644 --- a/server/routers/auditLogs/queryRequestAnalytics.ts +++ b/server/routers/auditLogs/queryRequestAnalytics.ts @@ -1,4 +1,4 @@ -import { db, requestAuditLog, driver } from "@server/db"; +import { logsDb, requestAuditLog, driver, primaryLogsDb } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; @@ -35,7 +35,7 @@ const queryAccessAuditLogsQuery = z.object({ }) .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .optional() - .prefault(new Date().toISOString()) + .prefault(() => new Date().toISOString()) .openapi({ type: "string", format: "date-time", @@ -74,12 +74,12 @@ async function query(query: Q) { ); } - const [all] = await db + const [all] = await primaryLogsDb .select({ total: count() }) .from(requestAuditLog) .where(baseConditions); - const [blocked] = await db + const [blocked] = await primaryLogsDb .select({ total: count() }) .from(requestAuditLog) .where(and(baseConditions, eq(requestAuditLog.action, false))); @@ -88,7 +88,9 @@ async function query(query: Q) { .mapWith(Number) .as("total"); - const requestsPerCountry = await db + const DISTINCT_LIMIT = 500; + + const requestsPerCountry = await primaryLogsDb .selectDistinct({ code: requestAuditLog.location, count: totalQ @@ -96,7 +98,17 @@ async function query(query: Q) { .from(requestAuditLog) .where(and(baseConditions, not(isNull(requestAuditLog.location)))) .groupBy(requestAuditLog.location) - .orderBy(desc(totalQ)); + .orderBy(desc(totalQ)) + .limit(DISTINCT_LIMIT + 1); + + if (requestsPerCountry.length > DISTINCT_LIMIT) { + // throw an error + throw createHttpError( + HttpCode.BAD_REQUEST, + // todo: is this even possible? + `Too many distinct countries. Please narrow your query.` + ); + } const groupByDayFunction = driver === "pg" @@ -106,7 +118,7 @@ async function query(query: Q) { const booleanTrue = driver === "pg" ? sql`true` : sql`1`; const booleanFalse = driver === "pg" ? sql`false` : sql`0`; - const requestsPerDay = await db + const requestsPerDay = await primaryLogsDb .select({ day: groupByDayFunction.as("day"), allowedCount: @@ -139,7 +151,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/logs/analytics", description: "Query the request audit analytics for an organization", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.Logs], request: { query: queryAccessAuditLogsQuery, params: queryRequestAuditLogsParams diff --git a/server/routers/auditLogs/queryRequestAuditLog.ts b/server/routers/auditLogs/queryRequestAuditLog.ts index 9cedec637..176a9e5d3 100644 --- a/server/routers/auditLogs/queryRequestAuditLog.ts +++ b/server/routers/auditLogs/queryRequestAuditLog.ts @@ -1,8 +1,8 @@ -import { db, requestAuditLog, resources } from "@server/db"; +import { logsDb, primaryLogsDb, requestAuditLog, resources, db, primaryDb } from "@server/db"; import { registry } from "@server/openApi"; import { NextFunction } from "express"; import { Request, Response } from "express"; -import { eq, gt, lt, and, count, desc } from "drizzle-orm"; +import { eq, gt, lt, and, count, desc, inArray } from "drizzle-orm"; import { OpenAPITags } from "@server/openApi"; import { z } from "zod"; import createHttpError from "http-errors"; @@ -35,7 +35,7 @@ export const queryAccessAuditLogsQuery = z.object({ }) .transform((val) => Math.floor(new Date(val).getTime() / 1000)) .optional() - .prefault(new Date().toISOString()) + .prefault(() => new Date().toISOString()) .openapi({ type: "string", format: "date-time", @@ -107,7 +107,7 @@ function getWhere(data: Q) { } export function queryRequest(data: Q) { - return db + return primaryLogsDb .select({ id: requestAuditLog.id, timestamp: requestAuditLog.timestamp, @@ -129,21 +129,49 @@ export function queryRequest(data: Q) { host: requestAuditLog.host, path: requestAuditLog.path, method: requestAuditLog.method, - tls: requestAuditLog.tls, - resourceName: resources.name, - resourceNiceId: resources.niceId + tls: requestAuditLog.tls }) .from(requestAuditLog) - .leftJoin( - resources, - eq(requestAuditLog.resourceId, resources.resourceId) - ) // TODO: Is this efficient? .where(getWhere(data)) .orderBy(desc(requestAuditLog.timestamp)); } +async function enrichWithResourceDetails(logs: Awaited>) { + // If logs database is the same as main database, we can do a join + // Otherwise, we need to fetch resource details separately + const resourceIds = logs + .map(log => log.resourceId) + .filter((id): id is number => id !== null && id !== undefined); + + if (resourceIds.length === 0) { + return logs.map(log => ({ ...log, resourceName: null, resourceNiceId: null })); + } + + // Fetch resource details from main database + const resourceDetails = await primaryDb + .select({ + resourceId: resources.resourceId, + name: resources.name, + niceId: resources.niceId + }) + .from(resources) + .where(inArray(resources.resourceId, resourceIds)); + + // Create a map for quick lookup + const resourceMap = new Map( + resourceDetails.map(r => [r.resourceId, { name: r.name, niceId: r.niceId }]) + ); + + // Enrich logs with resource details + return logs.map(log => ({ + ...log, + resourceName: log.resourceId ? resourceMap.get(log.resourceId)?.name ?? null : null, + resourceNiceId: log.resourceId ? resourceMap.get(log.resourceId)?.niceId ?? null : null + })); +} + export function countRequestQuery(data: Q) { - const countQuery = db + const countQuery = primaryLogsDb .select({ count: count() }) .from(requestAuditLog) .where(getWhere(data)); @@ -154,7 +182,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/logs/request", description: "Query the request audit log for an organization", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.Logs], request: { query: queryAccessAuditLogsQuery, params: queryRequestAuditLogsParams @@ -173,58 +201,86 @@ async function queryUniqueFilterAttributes( eq(requestAuditLog.orgId, orgId) ); - // Get unique actors - const uniqueActors = await db - .selectDistinct({ - actor: requestAuditLog.actor - }) - .from(requestAuditLog) - .where(baseConditions); + const DISTINCT_LIMIT = 500; - // Get unique locations - const uniqueLocations = await db - .selectDistinct({ - locations: requestAuditLog.location - }) - .from(requestAuditLog) - .where(baseConditions); + // TODO: SOMEONE PLEASE OPTIMIZE THIS!!!!! - // Get unique actors - const uniqueHosts = await db - .selectDistinct({ - hosts: requestAuditLog.host - }) - .from(requestAuditLog) - .where(baseConditions); + // Run all queries in parallel + const [ + uniqueActors, + uniqueLocations, + uniqueHosts, + uniquePaths, + uniqueResources + ] = await Promise.all([ + primaryLogsDb + .selectDistinct({ actor: requestAuditLog.actor }) + .from(requestAuditLog) + .where(baseConditions) + .limit(DISTINCT_LIMIT + 1), + primaryLogsDb + .selectDistinct({ locations: requestAuditLog.location }) + .from(requestAuditLog) + .where(baseConditions) + .limit(DISTINCT_LIMIT + 1), + primaryLogsDb + .selectDistinct({ hosts: requestAuditLog.host }) + .from(requestAuditLog) + .where(baseConditions) + .limit(DISTINCT_LIMIT + 1), + primaryLogsDb + .selectDistinct({ paths: requestAuditLog.path }) + .from(requestAuditLog) + .where(baseConditions) + .limit(DISTINCT_LIMIT + 1), + primaryLogsDb + .selectDistinct({ + id: requestAuditLog.resourceId + }) + .from(requestAuditLog) + .where(baseConditions) + .limit(DISTINCT_LIMIT + 1) + ]); - // Get unique actors - const uniquePaths = await db - .selectDistinct({ - paths: requestAuditLog.path - }) - .from(requestAuditLog) - .where(baseConditions); + // TODO: for stuff like the paths this is too restrictive so lets just show some of the paths and the user needs to + // refine the time range to see what they need to see + // if ( + // uniqueActors.length > DISTINCT_LIMIT || + // uniqueLocations.length > DISTINCT_LIMIT || + // uniqueHosts.length > DISTINCT_LIMIT || + // uniquePaths.length > DISTINCT_LIMIT || + // uniqueResources.length > DISTINCT_LIMIT + // ) { + // throw new Error("Too many distinct filter attributes to retrieve. Please refine your time range."); + // } - // Get unique resources with names - const uniqueResources = await db - .selectDistinct({ - id: requestAuditLog.resourceId, - name: resources.name - }) - .from(requestAuditLog) - .leftJoin( - resources, - eq(requestAuditLog.resourceId, resources.resourceId) - ) - .where(baseConditions); + // Fetch resource names from main database for the unique resource IDs + const resourceIds = uniqueResources + .map(row => row.id) + .filter((id): id is number => id !== null); + + let resourcesWithNames: Array<{ id: number; name: string | null }> = []; + + if (resourceIds.length > 0) { + const resourceDetails = await primaryDb + .select({ + resourceId: resources.resourceId, + name: resources.name + }) + .from(resources) + .where(inArray(resources.resourceId, resourceIds)); + + resourcesWithNames = resourceDetails.map(r => ({ + id: r.resourceId, + name: r.name + })); + } return { actors: uniqueActors .map((row) => row.actor) .filter((actor): actor is string => actor !== null), - resources: uniqueResources.filter( - (row): row is { id: number; name: string | null } => row.id !== null - ), + resources: resourcesWithNames, locations: uniqueLocations .map((row) => row.locations) .filter((location): location is string => location !== null), @@ -267,7 +323,10 @@ export async function queryRequestAuditLogs( const baseQuery = queryRequest(data); - const log = await baseQuery.limit(data.limit).offset(data.offset); + const logsRaw = await baseQuery.limit(data.limit).offset(data.offset); + + // Enrich with resource details (handles cross-database scenario) + const log = await enrichWithResourceDetails(logsRaw); const totalCountResult = await countRequestQuery(data); const totalCount = totalCountResult[0].count; @@ -295,6 +354,14 @@ export async function queryRequestAuditLogs( }); } catch (error) { logger.error(error); + // if the message is "Too many distinct filter attributes to retrieve. Please refine your time range.", return a 400 and the message + if ( + error instanceof Error && + error.message === + "Too many distinct filter attributes to retrieve. Please refine your time range." + ) { + return next(createHttpError(HttpCode.BAD_REQUEST, error.message)); + } return next( createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") ); diff --git a/server/routers/auditLogs/types.ts b/server/routers/auditLogs/types.ts index 474aa9261..4c278cba5 100644 --- a/server/routers/auditLogs/types.ts +++ b/server/routers/auditLogs/types.ts @@ -91,3 +91,50 @@ export type QueryAccessAuditLogResponse = { locations: string[]; }; }; + +export type QueryConnectionAuditLogResponse = { + log: { + sessionId: string; + siteResourceId: number | null; + orgId: string | null; + siteId: number | null; + clientId: number | null; + userId: string | null; + sourceAddr: string; + destAddr: string; + protocol: string; + startedAt: number; + endedAt: number | null; + bytesTx: number | null; + bytesRx: number | null; + resourceName: string | null; + resourceNiceId: string | null; + siteName: string | null; + siteNiceId: string | null; + clientName: string | null; + clientNiceId: string | null; + clientType: string | null; + userEmail: string | null; + }[]; + pagination: { + total: number; + limit: number; + offset: number; + }; + filterAttributes: { + protocols: string[]; + destAddrs: string[]; + clients: { + id: number; + name: string; + }[]; + resources: { + id: number; + name: string | null; + }[]; + users: { + id: string; + email: string | null; + }[]; + }; +}; diff --git a/server/routers/auth/deleteMyAccount.ts b/server/routers/auth/deleteMyAccount.ts new file mode 100644 index 000000000..248d5a181 --- /dev/null +++ b/server/routers/auth/deleteMyAccount.ts @@ -0,0 +1,242 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, orgs, userOrgs, users } from "@server/db"; +import { eq, and, inArray } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { verifySession } from "@server/auth/sessions/verifySession"; +import { + invalidateSession, + createBlankSessionTokenCookie +} from "@server/auth/sessions/app"; +import { verifyPassword } from "@server/auth/password"; +import { verifyTotpCode } from "@server/auth/totp"; +import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; +import { build } from "@server/build"; +import { getOrgTierData } from "#dynamic/lib/billing"; +import { + deleteOrgById, + sendTerminationMessages +} from "@server/lib/deleteOrg"; +import { UserType } from "@server/types/UserTypes"; + +const deleteMyAccountBody = z.strictObject({ + password: z.string().optional(), + code: z.string().optional() +}); + +export type DeleteMyAccountPreviewResponse = { + preview: true; + orgs: { orgId: string; name: string }[]; + twoFactorEnabled: boolean; +}; + +export type DeleteMyAccountCodeRequestedResponse = { + codeRequested: true; +}; + +export type DeleteMyAccountSuccessResponse = { + success: true; +}; + +export async function deleteMyAccount( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const { user, session } = await verifySession(req); + if (!user || !session) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Not authenticated") + ); + } + + if (user.serverAdmin) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Server admins cannot delete their account this way" + ) + ); + } + + if (user.type !== UserType.Internal) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Account deletion with password is only supported for internal users" + ) + ); + } + + const parsed = deleteMyAccountBody.safeParse(req.body ?? {}); + if (!parsed.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsed.error).toString() + ) + ); + } + const { password, code } = parsed.data; + + const userId = user.userId; + + const ownedOrgsRows = await db + .select({ + orgId: userOrgs.orgId, + isOwner: userOrgs.isOwner, + isBillingOrg: orgs.isBillingOrg + }) + .from(userOrgs) + .innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId)) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.isOwner, true)) + ); + + const orgIds = ownedOrgsRows.map((r) => r.orgId); + + if (build === "saas" && orgIds.length > 0) { + const primaryOrgId = ownedOrgsRows.find( + (r) => r.isBillingOrg && r.isOwner + )?.orgId; + if (primaryOrgId) { + const { tier, active } = await getOrgTierData(primaryOrgId); + if (active && tier) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "You must cancel your subscription before deleting your account" + ) + ); + } + } + } + + if (!password) { + const orgsWithNames = + orgIds.length > 0 + ? await db + .select({ + orgId: orgs.orgId, + name: orgs.name + }) + .from(orgs) + .where(inArray(orgs.orgId, orgIds)) + : []; + return response(res, { + data: { + preview: true, + orgs: orgsWithNames.map((o) => ({ + orgId: o.orgId, + name: o.name ?? "" + })), + twoFactorEnabled: user.twoFactorEnabled ?? false + }, + success: true, + error: false, + message: "Preview", + status: HttpCode.OK + }); + } + + const validPassword = await verifyPassword( + password, + user.passwordHash! + ); + if (!validPassword) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Invalid password") + ); + } + + if (user.twoFactorEnabled) { + if (!code) { + return response(res, { + data: { codeRequested: true }, + success: true, + error: false, + message: "Two-factor code required", + status: HttpCode.ACCEPTED + }); + } + const validOTP = await verifyTotpCode( + code, + user.twoFactorSecret!, + user.userId + ); + if (!validOTP) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "The two-factor code you entered is incorrect" + ) + ); + } + } + + const allDeletedNewtIds: string[] = []; + const allOlmsToTerminate: string[] = []; + + for (const row of ownedOrgsRows) { + try { + const result = await deleteOrgById(row.orgId); + allDeletedNewtIds.push(...result.deletedNewtIds); + allOlmsToTerminate.push(...result.olmsToTerminate); + } catch (err) { + logger.error( + `Failed to delete org ${row.orgId} during account deletion`, + err + ); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to delete organization" + ) + ); + } + } + + sendTerminationMessages({ + deletedNewtIds: allDeletedNewtIds, + olmsToTerminate: allOlmsToTerminate + }); + + await db.transaction(async (trx) => { + await trx.delete(users).where(eq(users.userId, userId)); + await calculateUserClientsForOrgs(userId, trx); + }); + + try { + await invalidateSession(session.sessionId); + } catch (error) { + logger.error( + "Failed to invalidate session after account deletion", + error + ); + } + + const isSecure = req.protocol === "https"; + res.setHeader("Set-Cookie", createBlankSessionTokenCookie(isSecure)); + + return response(res, { + data: { success: true }, + success: true, + error: false, + message: "Account deleted successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "An error occurred" + ) + ); + } +} diff --git a/server/routers/auth/index.ts b/server/routers/auth/index.ts index 22040614d..7a469aa13 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -17,3 +17,5 @@ export * from "./securityKey"; export * from "./startDeviceWebAuth"; export * from "./verifyDeviceWebAuth"; export * from "./pollDeviceWebAuth"; +export * from "./lookupUser"; +export * from "./deleteMyAccount"; \ No newline at end of file diff --git a/server/routers/auth/lookupUser.ts b/server/routers/auth/lookupUser.ts new file mode 100644 index 000000000..83894927a --- /dev/null +++ b/server/routers/auth/lookupUser.ts @@ -0,0 +1,224 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + users, + userOrgs, + orgs, + idpOrg, + idp, + idpOidcConfig +} from "@server/db"; +import { eq, or, sql, and, isNotNull, inArray } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { UserType } from "@server/types/UserTypes"; + +const lookupBodySchema = z.strictObject({ + identifier: z.string().min(1).toLowerCase() +}); + +export type LookupUserResponse = { + found: boolean; + identifier: string; + accounts: Array<{ + userId: string; + email: string | null; + username: string; + hasInternalAuth: boolean; + orgs: Array<{ + orgId: string; + orgName: string; + idps: Array<{ + idpId: number; + name: string; + variant: string | null; + }>; + hasInternalAuth: boolean; + }>; + }>; +}; + +// registry.registerPath({ +// method: "post", +// path: "/auth/lookup-user", +// description: "Lookup user accounts by username or email and return available authentication methods.", +// tags: [OpenAPITags.Auth], +// request: { +// body: lookupBodySchema +// }, +// responses: {} +// }); + +export async function lookupUser( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = lookupBodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { identifier } = parsedBody.data; + + // Query users matching identifier (case-insensitive) + // Match by username OR email + const matchingUsers = await db + .select({ + userId: users.userId, + email: users.email, + username: users.username, + type: users.type, + passwordHash: users.passwordHash, + idpId: users.idpId + }) + .from(users) + .where( + or( + sql`LOWER(${users.username}) = ${identifier}`, + sql`LOWER(${users.email}) = ${identifier}` + ) + ); + + if (!matchingUsers || matchingUsers.length === 0) { + return response(res, { + data: { + found: false, + identifier, + accounts: [] + }, + success: true, + error: false, + message: "No accounts found", + status: HttpCode.OK + }); + } + + // Get unique user IDs + const userIds = [...new Set(matchingUsers.map((u) => u.userId))]; + + // Get all org memberships for these users + const orgMemberships = await db + .select({ + userId: userOrgs.userId, + orgId: userOrgs.orgId, + orgName: orgs.name + }) + .from(userOrgs) + .innerJoin(orgs, eq(orgs.orgId, userOrgs.orgId)) + .where(inArray(userOrgs.userId, userIds)); + + // Get unique org IDs + const orgIds = [...new Set(orgMemberships.map((m) => m.orgId))]; + + // Get all IdPs for these orgs + const orgIdps = + orgIds.length > 0 + ? await db + .select({ + orgId: idpOrg.orgId, + idpId: idp.idpId, + idpName: idp.name, + variant: idpOidcConfig.variant + }) + .from(idpOrg) + .innerJoin(idp, eq(idp.idpId, idpOrg.idpId)) + .innerJoin( + idpOidcConfig, + eq(idpOidcConfig.idpId, idp.idpId) + ) + .where(inArray(idpOrg.orgId, orgIds)) + : []; + + // Build response structure + const accounts: LookupUserResponse["accounts"] = []; + + for (const user of matchingUsers) { + const hasInternalAuth = + user.type === UserType.Internal && user.passwordHash !== null; + + // Get orgs for this user + const userOrgMemberships = orgMemberships.filter( + (m) => m.userId === user.userId + ); + + // Deduplicate orgs (user might have multiple memberships in same org) + const uniqueOrgs = new Map(); + for (const membership of userOrgMemberships) { + if (!uniqueOrgs.has(membership.orgId)) { + uniqueOrgs.set(membership.orgId, membership); + } + } + + const orgsData = Array.from(uniqueOrgs.values()).map((membership) => { + // Get IdPs for this org where the user (with the exact identifier) is authenticated via that IdP + // Only show IdPs where the user's idpId matches + // Internal users don't have an idpId, so they won't see any IdPs + const orgIdpsList = orgIdps + .filter((idp) => { + if (idp.orgId !== membership.orgId) { + return false; + } + // Only show IdPs where the user (with exact identifier) is authenticated via that IdP + // This means user.idpId must match idp.idpId + if (user.idpId !== null && user.idpId === idp.idpId) { + return true; + } + return false; + }) + .map((idp) => ({ + idpId: idp.idpId, + name: idp.idpName, + variant: idp.variant + })); + + // Check if user has internal auth for this org + // User has internal auth if they have an internal account type + const orgHasInternalAuth = hasInternalAuth; + + return { + orgId: membership.orgId, + orgName: membership.orgName, + idps: orgIdpsList, + hasInternalAuth: orgHasInternalAuth + }; + }); + + accounts.push({ + userId: user.userId, + email: user.email, + username: user.username, + hasInternalAuth, + orgs: orgsData + }); + } + + return response(res, { + data: { + found: true, + identifier, + accounts + }, + success: true, + error: false, + message: "User lookup completed", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/auth/pollDeviceWebAuth.ts b/server/routers/auth/pollDeviceWebAuth.ts index a5c713625..30d7183e1 100644 --- a/server/routers/auth/pollDeviceWebAuth.ts +++ b/server/routers/auth/pollDeviceWebAuth.ts @@ -10,6 +10,7 @@ import { eq, and, gt } from "drizzle-orm"; import { createSession, generateSessionToken } from "@server/auth/sessions/app"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; +import { stripPortFromHost } from "@server/lib/ip"; const paramsSchema = z.object({ code: z.string().min(1, "Code is required") @@ -27,30 +28,6 @@ export type PollDeviceWebAuthResponse = { token?: string; }; -// Helper function to extract IP from request (same as in startDeviceWebAuth) -function extractIpFromRequest(req: Request): string | undefined { - const ip = req.ip || req.socket.remoteAddress; - if (!ip) { - return undefined; - } - - // Handle IPv6 format [::1] or IPv4 format - if (ip.startsWith("[") && ip.includes("]")) { - const ipv6Match = ip.match(/\[(.*?)\]/); - if (ipv6Match) { - return ipv6Match[1]; - } - } - - // Handle IPv4 with port (split at last colon) - const lastColonIndex = ip.lastIndexOf(":"); - if (lastColonIndex !== -1) { - return ip.substring(0, lastColonIndex); - } - - return ip; -} - export async function pollDeviceWebAuth( req: Request, res: Response, @@ -70,7 +47,7 @@ export async function pollDeviceWebAuth( try { const { code } = parsedParams.data; const now = Date.now(); - const requestIp = extractIpFromRequest(req); + const requestIp = req.ip ? stripPortFromHost(req.ip) : undefined; // Hash the code before querying const hashedCode = hashDeviceCode(code); diff --git a/server/routers/auth/signup.ts b/server/routers/auth/signup.ts index 2605a0267..82d8c1515 100644 --- a/server/routers/auth/signup.ts +++ b/server/routers/auth/signup.ts @@ -1,7 +1,7 @@ import { NextFunction, Request, Response } from "express"; -import { db, users } from "@server/db"; +import { bannedEmails, bannedIps, db, users } from "@server/db"; import HttpCode from "@server/types/HttpCode"; -import { z } from "zod"; +import { email, z } from "zod"; import { fromError } from "zod-validation-error"; import createHttpError from "http-errors"; import response from "@server/lib/response"; @@ -21,9 +21,7 @@ import { hashPassword } from "@server/auth/password"; import { checkValidInvite } from "@server/auth/checkValidInvite"; import { passwordSchema } from "@server/auth/passwordSchema"; import { UserType } from "@server/types/UserTypes"; -import { createUserAccountOrg } from "@server/lib/createUserAccountOrg"; import { build } from "@server/build"; -import resend, { AudienceIds, moveEmailToAudience } from "#dynamic/lib/resend"; export const signupBodySchema = z.object({ email: z.email().toLowerCase(), @@ -31,7 +29,8 @@ export const signupBodySchema = z.object({ inviteToken: z.string().optional(), inviteId: z.string().optional(), termsAcceptedTimestamp: z.string().nullable().optional(), - marketingEmailConsent: z.boolean().optional() + marketingEmailConsent: z.boolean().optional(), + skipVerificationEmail: z.boolean().optional() }); export type SignUpBody = z.infer; @@ -62,9 +61,34 @@ export async function signup( inviteToken, inviteId, termsAcceptedTimestamp, - marketingEmailConsent + marketingEmailConsent, + skipVerificationEmail } = parsedBody.data; + const [bannedEmail] = await db + .select() + .from(bannedEmails) + .where(eq(bannedEmails.email, email)) + .limit(1); + if (bannedEmail) { + return next( + createHttpError(HttpCode.FORBIDDEN, "Signup blocked. Do not attempt to continue to use this service.") + ); + } + + if (req.ip) { + const [bannedIp] = await db + .select() + .from(bannedIps) + .where(eq(bannedIps.ip, req.ip)) + .limit(1); + if (bannedIp) { + return next( + createHttpError(HttpCode.FORBIDDEN, "Signup blocked. Do not attempt to continue to use this service.") + ); + } + } + const passwordHash = await hashPassword(password); const userId = generateId(15); @@ -188,6 +212,7 @@ export async function signup( dateCreated: moment().toISOString(), termsAcceptedTimestamp: termsAcceptedTimestamp || null, termsVersion: "1", + marketingEmailConsent: marketingEmailConsent ?? false, lastPasswordChange: new Date().getTime() }); @@ -198,26 +223,6 @@ export async function signup( // orgId: null, // }); - if (build == "saas") { - const { success, error, org } = await createUserAccountOrg( - userId, - email - ); - if (!success) { - if (error) { - return next( - createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error) - ); - } - return next( - createHttpError( - HttpCode.INTERNAL_SERVER_ERROR, - "Failed to create user account and organization" - ) - ); - } - } - const token = generateSessionToken(); const sess = await createSession(token, userId); const isSecure = req.protocol === "https"; @@ -231,11 +236,17 @@ export async function signup( logger.debug( `User ${email} opted in to marketing emails during signup.` ); - moveEmailToAudience(email, AudienceIds.SignUps); + // TODO: update user in Sendy } if (config.getRawConfig().flags?.require_email_verification) { - sendEmailVerificationCode(email, userId); + if (!skipVerificationEmail) { + sendEmailVerificationCode(email, userId); + } else { + logger.debug( + `User ${email} opted out of verification email during signup.` + ); + } return response(res, { data: { @@ -243,7 +254,9 @@ export async function signup( }, success: true, error: false, - message: `User created successfully. We sent an email to ${email} with a verification code.`, + message: skipVerificationEmail + ? "User created successfully. Please verify your email." + : `User created successfully. We sent an email to ${email} with a verification code.`, status: HttpCode.OK }); } diff --git a/server/routers/auth/startDeviceWebAuth.ts b/server/routers/auth/startDeviceWebAuth.ts index 85fb52622..e28e750cc 100644 --- a/server/routers/auth/startDeviceWebAuth.ts +++ b/server/routers/auth/startDeviceWebAuth.ts @@ -12,6 +12,7 @@ import { TimeSpan } from "oslo"; import { maxmindLookup } from "@server/db/maxmind"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; +import { stripPortFromHost } from "@server/lib/ip"; const bodySchema = z .object({ @@ -39,30 +40,6 @@ function hashDeviceCode(code: string): string { return encodeHexLowerCase(sha256(new TextEncoder().encode(code))); } -// Helper function to extract IP from request -function extractIpFromRequest(req: Request): string | undefined { - const ip = req.ip; - if (!ip) { - return undefined; - } - - // Handle IPv6 format [::1] or IPv4 format - if (ip.startsWith("[") && ip.includes("]")) { - const ipv6Match = ip.match(/\[(.*?)\]/); - if (ipv6Match) { - return ipv6Match[1]; - } - } - - // Handle IPv4 with port (split at last colon) - const lastColonIndex = ip.lastIndexOf(":"); - if (lastColonIndex !== -1) { - return ip.substring(0, lastColonIndex); - } - - return ip; -} - // Helper function to get city from IP (if available) async function getCityFromIp(ip: string): Promise { try { @@ -112,7 +89,7 @@ export async function startDeviceWebAuth( const hashedCode = hashDeviceCode(code); // Extract IP from request - const ip = extractIpFromRequest(req); + const ip = req.ip ? stripPortFromHost(req.ip) : undefined; // Get city (optional, may return undefined) const city = ip ? await getCityFromIp(ip) : undefined; diff --git a/server/routers/auth/verifyDeviceWebAuth.ts b/server/routers/auth/verifyDeviceWebAuth.ts index be0e0ff29..756749f5a 100644 --- a/server/routers/auth/verifyDeviceWebAuth.ts +++ b/server/routers/auth/verifyDeviceWebAuth.ts @@ -10,6 +10,7 @@ import { eq, and, gt } from "drizzle-orm"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; import { unauthorized } from "@server/auth/unauthorizedResponse"; +import { getIosDeviceName, getMacDeviceName } from "@server/db/names"; const bodySchema = z .object({ @@ -120,6 +121,11 @@ export async function verifyDeviceWebAuth( ); } + const deviceName = + getMacDeviceName(deviceCode.deviceName) || + getIosDeviceName(deviceCode.deviceName) || + deviceCode.deviceName; + // If verify is false, just return metadata without verifying if (!verify) { return response(res, { @@ -129,7 +135,7 @@ export async function verifyDeviceWebAuth( metadata: { ip: deviceCode.ip, city: deviceCode.city, - deviceName: deviceCode.deviceName, + deviceName: deviceName, applicationName: deviceCode.applicationName, createdAt: deviceCode.createdAt } diff --git a/server/routers/badger/exchangeSession.ts b/server/routers/badger/exchangeSession.ts index b8d01c119..bde5518b8 100644 --- a/server/routers/badger/exchangeSession.ts +++ b/server/routers/badger/exchangeSession.ts @@ -19,6 +19,7 @@ import { import { SESSION_COOKIE_EXPIRES as RESOURCE_SESSION_COOKIE_EXPIRES } from "@server/auth/sessions/resource"; import config from "@server/lib/config"; import { response } from "@server/lib/response"; +import { stripPortFromHost } from "@server/lib/ip"; const exchangeSessionBodySchema = z.object({ requestToken: z.string(), @@ -62,7 +63,7 @@ export async function exchangeSession( cleanHost = cleanHost.slice(0, -1 * matched.length); } - const clientIp = requestIp?.split(":")[0]; + const clientIp = requestIp ? stripPortFromHost(requestIp) : undefined; const [resource] = await db .select() diff --git a/server/routers/badger/logRequestAudit.ts b/server/routers/badger/logRequestAudit.ts index 1343bdaac..92d01332e 100644 --- a/server/routers/badger/logRequestAudit.ts +++ b/server/routers/badger/logRequestAudit.ts @@ -1,8 +1,11 @@ -import { db, orgs, requestAuditLog } from "@server/db"; +import { logsDb, primaryLogsDb, db, orgs, requestAuditLog } from "@server/db"; import logger from "@server/logger"; -import { and, eq, lt } from "drizzle-orm"; -import cache from "@server/lib/cache"; +import { and, eq, lt, sql } from "drizzle-orm"; +import cache from "#dynamic/lib/cache"; import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; +import { stripPortFromHost } from "@server/lib/ip"; + +import { sanitizeString } from "@server/lib/sanitize"; /** @@ -10,7 +13,7 @@ Reasons: 100 - Allowed by Rule 101 - Allowed No Auth 102 - Valid Access Token -103 - Valid header auth +103 - Valid Header Auth (HTTP Basic Auth) 104 - Valid Pincode 105 - Valid Password 106 - Valid email @@ -48,27 +51,53 @@ const auditLogBuffer: Array<{ const BATCH_SIZE = 100; // Write to DB every 100 logs const BATCH_INTERVAL_MS = 5000; // Or every 5 seconds, whichever comes first +const MAX_BUFFER_SIZE = 10000; // Prevent unbounded memory growth let flushTimer: NodeJS.Timeout | null = null; +let isFlushInProgress = false; /** * Flush buffered logs to database */ async function flushAuditLogs() { - if (auditLogBuffer.length === 0) { + if (auditLogBuffer.length === 0 || isFlushInProgress) { return; } + isFlushInProgress = true; + // Take all current logs and clear buffer const logsToWrite = auditLogBuffer.splice(0, auditLogBuffer.length); try { - // Batch insert all logs at once - await db.insert(requestAuditLog).values(logsToWrite); + // Use a transaction to ensure all inserts succeed or fail together + // This prevents index corruption from partial writes + await logsDb.transaction(async (tx) => { + // Batch insert logs in groups of 25 to avoid overwhelming the database + const BATCH_DB_SIZE = 25; + for (let i = 0; i < logsToWrite.length; i += BATCH_DB_SIZE) { + const batch = logsToWrite.slice(i, i + BATCH_DB_SIZE); + await tx.insert(requestAuditLog).values(batch); + } + }); logger.debug(`Flushed ${logsToWrite.length} audit logs to database`); } catch (error) { logger.error("Error flushing audit logs:", error); - // On error, we lose these logs - consider a fallback strategy if needed - // (e.g., write to file, or put back in buffer with retry limit) + // On transaction error, put logs back at the front of the buffer to retry + // but only if buffer isn't too large + if (auditLogBuffer.length < MAX_BUFFER_SIZE - logsToWrite.length) { + auditLogBuffer.unshift(...logsToWrite); + logger.info(`Re-queued ${logsToWrite.length} audit logs for retry`); + } else { + logger.error(`Buffer full, dropped ${logsToWrite.length} audit logs`); + } + } finally { + isFlushInProgress = false; + // If buffer filled up while we were flushing, flush again + if (auditLogBuffer.length >= BATCH_SIZE) { + flushAuditLogs().catch((err) => + logger.error("Error in follow-up flush:", err) + ); + } } } @@ -94,12 +123,16 @@ export async function shutdownAuditLogger() { clearTimeout(flushTimer); flushTimer = null; } + // Force flush even if one is in progress by waiting and retrying + while (isFlushInProgress) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } await flushAuditLogs(); } async function getRetentionDays(orgId: string): Promise { // check cache first - const cached = cache.get(`org_${orgId}_retentionDays`); + const cached = await cache.get(`org_${orgId}_retentionDays`); if (cached !== undefined) { return cached; } @@ -118,7 +151,7 @@ async function getRetentionDays(orgId: string): Promise { } // store the result in cache - cache.set( + await cache.set( `org_${orgId}_retentionDays`, org.settingsLogRetentionDaysRequest, 300 @@ -131,7 +164,7 @@ export async function cleanUpOldLogs(orgId: string, retentionDays: number) { const cutoffTimestamp = calculateCutoffTimestamp(retentionDays); try { - await db + await logsDb .delete(requestAuditLog) .where( and( @@ -208,49 +241,37 @@ export async function logRequestAudit( } const clientIp = body.requestIp - ? (() => { - if ( - body.requestIp.startsWith("[") && - body.requestIp.includes("]") - ) { - // if brackets are found, extract the IPv6 address from between the brackets - const ipv6Match = body.requestIp.match(/\[(.*?)\]/); - if (ipv6Match) { - return ipv6Match[1]; - } - } - - // ivp4 - // split at last colon - const lastColonIndex = body.requestIp.lastIndexOf(":"); - if (lastColonIndex !== -1) { - return body.requestIp.substring(0, lastColonIndex); - } - return body.requestIp; - })() + ? stripPortFromHost(body.requestIp) : undefined; + // Prevent unbounded buffer growth - drop oldest entries if buffer is too large + if (auditLogBuffer.length >= MAX_BUFFER_SIZE) { + const dropped = auditLogBuffer.splice(0, BATCH_SIZE); + logger.warn( + `Audit log buffer exceeded max size (${MAX_BUFFER_SIZE}), dropped ${dropped.length} oldest entries` + ); + } + // Add to buffer instead of writing directly to DB auditLogBuffer.push({ timestamp, - orgId: data.orgId, - actorType, - actor, - actorId, - metadata, + orgId: sanitizeString(data.orgId), + actorType: sanitizeString(actorType), + actor: sanitizeString(actor), + actorId: sanitizeString(actorId), + metadata: sanitizeString(metadata), action: data.action, resourceId: data.resourceId, reason: data.reason, - location: data.location, - originalRequestURL: body.originalRequestURL, - scheme: body.scheme, - host: body.host, - path: body.path, - method: body.method, - ip: clientIp, + location: sanitizeString(data.location), + originalRequestURL: sanitizeString(body.originalRequestURL) ?? "", + scheme: sanitizeString(body.scheme) ?? "", + host: sanitizeString(body.host) ?? "", + path: sanitizeString(body.path) ?? "", + method: sanitizeString(body.method) ?? "", + ip: sanitizeString(clientIp), tls: body.tls }); - // Flush immediately if buffer is full, otherwise schedule a flush if (auditLogBuffer.length >= BATCH_SIZE) { // Fire and forget - don't block the caller diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 0bc9bde88..e2e5f6766 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -4,23 +4,23 @@ import { getResourceByDomain, getResourceRules, getRoleResourceAccess, - getUserOrgRole, getUserResourceAccess, getOrgLoginPage, getUserSessionWithUser } from "@server/db/queries/verifySessionQueries"; +import { getUserOrgRoles } from "@server/lib/userOrgRoles"; import { LoginPage, Org, Resource, ResourceHeaderAuth, + ResourceHeaderAuthExtendedCompatibility, ResourcePassword, ResourcePincode, - ResourceRule, - resourceSessions + ResourceRule } from "@server/db"; import config from "@server/lib/config"; -import { isIpInCidr } from "@server/lib/ip"; +import { isIpInCidr, stripPortFromHost } from "@server/lib/ip"; import { response } from "@server/lib/response"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; @@ -30,16 +30,17 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { getCountryCodeForIp } from "@server/lib/geoip"; import { getAsnForIp } from "@server/lib/asn"; -import { getOrgTierData } from "#dynamic/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; import { verifyPassword } from "@server/auth/password"; import { checkOrgAccessPolicy, enforceResourceSessionLength } from "#dynamic/lib/checkOrgAccessPolicy"; import { logRequestAudit } from "./logRequestAudit"; -import cache from "@server/lib/cache"; import { REGIONS } from "@server/db/regions"; +import { localCache } from "#dynamic/lib/cache"; +import { APP_VERSION } from "@server/lib/consts"; +import { isSubscribed } from "#dynamic/lib/isSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const verifyResourceSessionSchema = z.object({ sessions: z.record(z.string(), z.string()).optional(), @@ -51,7 +52,8 @@ const verifyResourceSessionSchema = z.object({ path: z.string(), method: z.string(), tls: z.boolean(), - requestIp: z.string().optional() + requestIp: z.string().optional(), + badgerVersion: z.string().optional() }); export type VerifyResourceSessionSchema = z.infer< @@ -67,8 +69,10 @@ type BasicUserData = { export type VerifyUserResponse = { valid: boolean; + headerAuthChallenged?: boolean; redirectUrl?: string; userData?: BasicUserData; + pangolinVersion?: string; }; export async function verifyResourceSession( @@ -97,31 +101,15 @@ export async function verifyResourceSession( requestIp, path, headers, - query + query, + badgerVersion } = parsedBody.data; // Extract HTTP Basic Auth credentials if present const clientHeaderAuth = extractBasicAuth(headers); const clientIp = requestIp - ? (() => { - logger.debug("Request IP:", { requestIp }); - if (requestIp.startsWith("[") && requestIp.includes("]")) { - // if brackets are found, extract the IPv6 address from between the brackets - const ipv6Match = requestIp.match(/\[(.*?)\]/); - if (ipv6Match) { - return ipv6Match[1]; - } - } - - // ivp4 - // split at last colon - const lastColonIndex = requestIp.lastIndexOf(":"); - if (lastColonIndex !== -1) { - return requestIp.substring(0, lastColonIndex); - } - return requestIp; - })() + ? stripPortFromHost(requestIp, badgerVersion) : undefined; logger.debug("Client IP:", { clientIp }); @@ -130,9 +118,7 @@ export async function verifyResourceSession( ? await getCountryCodeFromIp(clientIp) : undefined; - const ipAsn = clientIp - ? await getAsnFromIp(clientIp) - : undefined; + const ipAsn = clientIp ? await getAsnFromIp(clientIp) : undefined; let cleanHost = host; // if the host ends with :port, strip it @@ -148,9 +134,10 @@ export async function verifyResourceSession( pincode: ResourcePincode | null; password: ResourcePassword | null; headerAuth: ResourceHeaderAuth | null; + headerAuthExtendedCompatibility: ResourceHeaderAuthExtendedCompatibility | null; org: Org; } - | undefined = cache.get(resourceCacheKey); + | undefined = localCache.get(resourceCacheKey); if (!resourceData) { const result = await getResourceByDomain(cleanHost); @@ -174,10 +161,16 @@ export async function verifyResourceSession( } resourceData = result; - cache.set(resourceCacheKey, resourceData, 5); + localCache.set(resourceCacheKey, resourceData, 5); } - const { resource, pincode, password, headerAuth } = resourceData; + const { + resource, + pincode, + password, + headerAuth, + headerAuthExtendedCompatibility + } = resourceData; if (!resource) { logger.debug(`Resource not found ${cleanHost}`); @@ -412,7 +405,7 @@ export async function verifyResourceSession( // check for HTTP Basic Auth header const clientHeaderAuthKey = `headerAuth:${clientHeaderAuth}`; if (headerAuth && clientHeaderAuth) { - if (cache.get(clientHeaderAuthKey)) { + if (localCache.get(clientHeaderAuthKey)) { logger.debug( "Resource allowed because header auth is valid (cached)" ); @@ -435,7 +428,7 @@ export async function verifyResourceSession( headerAuth.headerAuthHash ) ) { - cache.set(clientHeaderAuthKey, clientHeaderAuth, 5); + localCache.set(clientHeaderAuthKey, clientHeaderAuth, 5); logger.debug("Resource allowed because header auth is valid"); logRequestAudit( @@ -457,7 +450,8 @@ export async function verifyResourceSession( !sso && !pincode && !password && - !resource.emailWhitelistEnabled + !resource.emailWhitelistEnabled && + !headerAuthExtendedCompatibility?.extendedCompatibilityIsActivated ) { logRequestAudit( { @@ -478,7 +472,8 @@ export async function verifyResourceSession( !sso && !pincode && !password && - !resource.emailWhitelistEnabled + !resource.emailWhitelistEnabled && + !headerAuthExtendedCompatibility?.extendedCompatibilityIsActivated ) { logRequestAudit( { @@ -525,7 +520,7 @@ export async function verifyResourceSession( if (resourceSessionToken) { const sessionCacheKey = `session:${resourceSessionToken}`; - let resourceSession: any = cache.get(sessionCacheKey); + let resourceSession: any = localCache.get(sessionCacheKey); if (!resourceSession) { const result = await validateResourceSessionToken( @@ -534,7 +529,7 @@ export async function verifyResourceSession( ); resourceSession = result?.resourceSession; - cache.set(sessionCacheKey, resourceSession, 5); + localCache.set(sessionCacheKey, resourceSession, 5); } if (resourceSession?.isRequestToken) { @@ -564,7 +559,7 @@ export async function verifyResourceSession( } if (resourceSession) { - // only run this check if not SSO sesion; SSO session length is checked later + // only run this check if not SSO session; SSO session length is checked later const accessPolicy = await enforceResourceSessionLength( resourceSession, resourceData.org @@ -667,7 +662,7 @@ export async function verifyResourceSession( }:${resource.resourceId}`; let allowedUserData: BasicUserData | null | undefined = - cache.get(userAccessCacheKey); + localCache.get(userAccessCacheKey); if (allowedUserData === undefined) { allowedUserData = await isUserAllowedToAccessResource( @@ -676,7 +671,7 @@ export async function verifyResourceSession( resourceData.org ); - cache.set(userAccessCacheKey, allowedUserData, 5); + localCache.set(userAccessCacheKey, allowedUserData, 5); } if ( @@ -708,6 +703,15 @@ export async function verifyResourceSession( } } + // If headerAuthExtendedCompatibility is activated but no clientHeaderAuth provided, force client to challenge + if ( + headerAuthExtendedCompatibility && + headerAuthExtendedCompatibility.extendedCompatibilityIsActivated && + !clientHeaderAuth + ) { + return headerAuthChallenged(res, redirectPath, resource.orgId); + } + logger.debug("No more auth to check, resource not allowed"); if (config.getRawConfig().app.log_failed_attempts) { @@ -793,8 +797,12 @@ async function notAllowed( ) { let loginPage: LoginPage | null = null; if (orgId) { - const { tier } = await getOrgTierData(orgId); // returns null in oss - if (tier === TierId.STANDARD) { + const subscribed = await isSubscribed( + // this is fine because the org login page is only a saas feature + orgId, + tierMatrix.loginPageDomain + ); + if (subscribed) { loginPage = await getOrgLoginPage(orgId); } } @@ -816,7 +824,7 @@ async function notAllowed( } const data = { - data: { valid: false, redirectUrl }, + data: { valid: false, redirectUrl, pangolinVersion: APP_VERSION }, success: true, error: false, message: "Access denied", @@ -830,8 +838,8 @@ function allowed(res: Response, userData?: BasicUserData) { const data = { data: userData !== undefined && userData !== null - ? { valid: true, ...userData } - : { valid: true }, + ? { valid: true, ...userData, pangolinVersion: APP_VERSION } + : { valid: true, pangolinVersion: APP_VERSION }, success: true, error: false, message: "Access allowed", @@ -840,6 +848,54 @@ function allowed(res: Response, userData?: BasicUserData) { return response(res, data); } +async function headerAuthChallenged( + res: Response, + redirectPath?: string, + orgId?: string +) { + let loginPage: LoginPage | null = null; + if (orgId) { + const subscribed = await isSubscribed( + orgId, + tierMatrix.loginPageDomain + ); // this is fine because the org login page is only a saas feature + if (subscribed) { + loginPage = await getOrgLoginPage(orgId); + } + } + + let redirectUrl: string | undefined = undefined; + if (redirectPath) { + let endpoint: string; + + if (loginPage && loginPage.domainId && loginPage.fullDomain) { + const secure = config + .getRawConfig() + .app.dashboard_url?.startsWith("https"); + const method = secure ? "https" : "http"; + endpoint = `${method}://${loginPage.fullDomain}`; + } else { + endpoint = config.getRawConfig().app.dashboard_url!; + } + redirectUrl = `${endpoint}${redirectPath}`; + } + + const data = { + data: { + headerAuthChallenged: true, + valid: false, + redirectUrl, + pangolinVersion: APP_VERSION + }, + success: true, + error: false, + message: "Access denied", + status: HttpCode.OK + }; + logger.debug(JSON.stringify(data)); + return response(res, data); +} + async function isUserAllowedToAccessResource( userSessionId: string, resource: Resource, @@ -864,9 +920,9 @@ async function isUserAllowedToAccessResource( return null; } - const userOrgRole = await getUserOrgRole(user.userId, resource.orgId); + const userOrgRoles = await getUserOrgRoles(user.userId, resource.orgId); - if (!userOrgRole) { + if (!userOrgRoles.length) { return null; } @@ -884,15 +940,14 @@ async function isUserAllowedToAccessResource( const roleResourceAccess = await getRoleResourceAccess( resource.resourceId, - userOrgRole.roleId + userOrgRoles.map((r) => r.roleId) ); - - if (roleResourceAccess) { + if (roleResourceAccess && roleResourceAccess.length > 0) { return { username: user.username, email: user.email, name: user.name, - role: user.role + role: userOrgRoles.map((r) => r.roleName).join(", ") }; } @@ -906,7 +961,7 @@ async function isUserAllowedToAccessResource( username: user.username, email: user.email, name: user.name, - role: user.role + role: userOrgRoles.map((r) => r.roleName).join(", ") }; } @@ -922,11 +977,11 @@ async function checkRules( ): Promise<"ACCEPT" | "DROP" | "PASS" | undefined> { const ruleCacheKey = `rules:${resourceId}`; - let rules: ResourceRule[] | undefined = cache.get(ruleCacheKey); + let rules: ResourceRule[] | undefined = localCache.get(ruleCacheKey); if (!rules) { rules = await getResourceRules(resourceId); - cache.set(ruleCacheKey, rules, 5); + localCache.set(ruleCacheKey, rules, 5); } if (rules.length === 0) { @@ -991,14 +1046,29 @@ export function isPathAllowed(pattern: string, path: string): boolean { logger.debug(`Normalized pattern parts: [${patternParts.join(", ")}]`); logger.debug(`Normalized path parts: [${pathParts.join(", ")}]`); + // Maximum recursion depth to prevent stack overflow and memory issues + const MAX_RECURSION_DEPTH = 100; + // Recursive function to try different wildcard matches - function matchSegments(patternIndex: number, pathIndex: number): boolean { - const indent = " ".repeat(pathIndex); // Indent based on recursion depth + function matchSegments( + patternIndex: number, + pathIndex: number, + depth: number = 0 + ): boolean { + // Check recursion depth limit + if (depth > MAX_RECURSION_DEPTH) { + logger.warn( + `Path matching exceeded maximum recursion depth (${MAX_RECURSION_DEPTH}) for pattern "${pattern}" and path "${path}"` + ); + return false; + } + + const indent = " ".repeat(depth); // Indent based on recursion depth const currentPatternPart = patternParts[patternIndex]; const currentPathPart = pathParts[pathIndex]; logger.debug( - `${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"})` + `${indent}Checking patternIndex=${patternIndex} (${currentPatternPart || "END"}) vs pathIndex=${pathIndex} (${currentPathPart || "END"}) [depth=${depth}]` ); // If we've consumed all pattern parts, we should have consumed all path parts @@ -1031,7 +1101,7 @@ export function isPathAllowed(pattern: string, path: string): boolean { logger.debug( `${indent}Trying to skip wildcard (consume 0 segments)` ); - if (matchSegments(patternIndex + 1, pathIndex)) { + if (matchSegments(patternIndex + 1, pathIndex, depth + 1)) { logger.debug( `${indent}Successfully matched by skipping wildcard` ); @@ -1042,7 +1112,7 @@ export function isPathAllowed(pattern: string, path: string): boolean { logger.debug( `${indent}Trying to consume segment "${currentPathPart}" for wildcard` ); - if (matchSegments(patternIndex, pathIndex + 1)) { + if (matchSegments(patternIndex, pathIndex + 1, depth + 1)) { logger.debug( `${indent}Successfully matched by consuming segment for wildcard` ); @@ -1070,7 +1140,11 @@ export function isPathAllowed(pattern: string, path: string): boolean { logger.debug( `${indent}Segment with wildcard matches: "${currentPatternPart}" matches "${currentPathPart}"` ); - return matchSegments(patternIndex + 1, pathIndex + 1); + return matchSegments( + patternIndex + 1, + pathIndex + 1, + depth + 1 + ); } logger.debug( @@ -1091,10 +1165,10 @@ export function isPathAllowed(pattern: string, path: string): boolean { `${indent}Segments match: "${currentPatternPart}" = "${currentPathPart}"` ); // Move to next segments in both pattern and path - return matchSegments(patternIndex + 1, pathIndex + 1); + return matchSegments(patternIndex + 1, pathIndex + 1, depth + 1); } - const result = matchSegments(0, 0); + const result = matchSegments(0, 0, 0); logger.debug(`Final result: ${result}`); return result; } @@ -1182,13 +1256,13 @@ export async function isIpInRegion( async function getAsnFromIp(ip: string): Promise { const asnCacheKey = `asn:${ip}`; - let cachedAsn: number | undefined = cache.get(asnCacheKey); + let cachedAsn: number | undefined = localCache.get(asnCacheKey); if (!cachedAsn) { cachedAsn = await getAsnForIp(ip); // do it locally // Cache for longer since IP ASN doesn't change frequently if (cachedAsn) { - cache.set(asnCacheKey, cachedAsn, 300); // 5 minutes + localCache.set(asnCacheKey, cachedAsn, 300); // 5 minutes } } @@ -1198,12 +1272,15 @@ async function getAsnFromIp(ip: string): Promise { async function getCountryCodeFromIp(ip: string): Promise { const geoIpCacheKey = `geoip:${ip}`; - let cachedCountryCode: string | undefined = cache.get(geoIpCacheKey); + let cachedCountryCode: string | undefined = localCache.get(geoIpCacheKey); if (!cachedCountryCode) { cachedCountryCode = await getCountryCodeForIp(ip); // do it locally - // Cache for longer since IP geolocation doesn't change frequently - cache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes + // Only cache successful lookups to avoid filling cache with undefined values + if (cachedCountryCode) { + // Cache for longer since IP geolocation doesn't change frequently + localCache.set(geoIpCacheKey, cachedCountryCode, 300); // 5 minutes + } } return cachedCountryCode; diff --git a/server/routers/billing/types.ts b/server/routers/billing/types.ts index 4e0aab52c..52cee8fed 100644 --- a/server/routers/billing/types.ts +++ b/server/routers/billing/types.ts @@ -1,8 +1,9 @@ import { Limit, Subscription, SubscriptionItem, Usage } from "@server/db"; export type GetOrgSubscriptionResponse = { - subscription: Subscription | null; - items: SubscriptionItem[]; + subscriptions: Array<{ subscription: Subscription; items: SubscriptionItem[] }>; + /** When build === saas, true if org has exceeded plan limits (sites, users, etc.) */ + limitsExceeded?: boolean; }; export type GetOrgUsageResponse = { diff --git a/server/routers/blueprints/applyJSONBlueprint.ts b/server/routers/blueprints/applyJSONBlueprint.ts index 7eee15bf1..fa7ed46ae 100644 --- a/server/routers/blueprints/applyJSONBlueprint.ts +++ b/server/routers/blueprints/applyJSONBlueprint.ts @@ -20,7 +20,7 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/blueprint", description: "Apply a base64 encoded JSON blueprint to an organization", - tags: [OpenAPITags.Org, OpenAPITags.Blueprint], + tags: [OpenAPITags.Blueprint], request: { params: applyBlueprintParamsSchema, body: { diff --git a/server/routers/blueprints/applyYAMLBlueprint.ts b/server/routers/blueprints/applyYAMLBlueprint.ts index 21402cd0c..665943edd 100644 --- a/server/routers/blueprints/applyYAMLBlueprint.ts +++ b/server/routers/blueprints/applyYAMLBlueprint.ts @@ -26,7 +26,8 @@ const applyBlueprintSchema = z message: `Invalid YAML: ${error instanceof Error ? error.message : "Unknown error"}` }); } - }) + }), + source: z.enum(["API", "UI", "CLI"]).optional() }) .strict(); @@ -42,7 +43,7 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/blueprint", description: "Create and apply a YAML blueprint to an organization", - tags: [OpenAPITags.Org, OpenAPITags.Blueprint], + tags: [OpenAPITags.Blueprint], request: { params: applyBlueprintParamsSchema, body: { @@ -84,7 +85,7 @@ export async function applyYAMLBlueprint( ); } - const { blueprint: contents, name } = parsedBody.data; + const { blueprint: contents, name, source = "UI" } = parsedBody.data; logger.debug(`Received blueprint:`, contents); @@ -107,7 +108,7 @@ export async function applyYAMLBlueprint( blueprint = await applyBlueprint({ orgId, name, - source: "UI", + source, configData: parsedConfig }); } catch (err) { diff --git a/server/routers/blueprints/getBlueprint.ts b/server/routers/blueprints/getBlueprint.ts index 915e04814..ea2ac2d05 100644 --- a/server/routers/blueprints/getBlueprint.ts +++ b/server/routers/blueprints/getBlueprint.ts @@ -53,7 +53,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/blueprint/{blueprintId}", description: "Get a blueprint by its blueprint ID.", - tags: [OpenAPITags.Org, OpenAPITags.Blueprint], + tags: [OpenAPITags.Blueprint], request: { params: getBlueprintSchema }, diff --git a/server/routers/blueprints/listBlueprints.ts b/server/routers/blueprints/listBlueprints.ts index 2ece9e53d..0235e7a18 100644 --- a/server/routers/blueprints/listBlueprints.ts +++ b/server/routers/blueprints/listBlueprints.ts @@ -67,7 +67,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/blueprints", description: "List all blueprints for a organization.", - tags: [OpenAPITags.Org, OpenAPITags.Blueprint], + tags: [OpenAPITags.Blueprint], request: { params: z.object({ orgId: z.string() diff --git a/server/routers/blueprints/types.ts b/server/routers/blueprints/types.ts index 52d613006..9a188b2c0 100644 --- a/server/routers/blueprints/types.ts +++ b/server/routers/blueprints/types.ts @@ -1,6 +1,6 @@ import type { Blueprint } from "@server/db"; -export type BlueprintSource = "API" | "UI" | "NEWT"; +export type BlueprintSource = "API" | "UI" | "NEWT" | "CLI"; export type BlueprintData = Omit & { source: BlueprintSource; diff --git a/server/routers/certificates/types.ts b/server/routers/certificates/types.ts index 3ec908578..bca9412c4 100644 --- a/server/routers/certificates/types.ts +++ b/server/routers/certificates/types.ts @@ -6,8 +6,8 @@ export type GetCertificateResponse = { status: string; // pending, requested, valid, expired, failed expiresAt: string | null; lastRenewalAttempt: Date | null; - createdAt: string; - updatedAt: string; + createdAt: number; + updatedAt: number; errorMessage?: string | null; renewalCount: number; }; diff --git a/server/routers/client/archiveClient.ts b/server/routers/client/archiveClient.ts new file mode 100644 index 000000000..34b7bb912 --- /dev/null +++ b/server/routers/client/archiveClient.ts @@ -0,0 +1,95 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const archiveClientSchema = z.strictObject({ + clientId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/archive", + description: "Archive a client by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: archiveClientSchema + }, + responses: {} +}); + +export async function archiveClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = archiveClientSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + // Check if client exists + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + if (client.archived) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Client with ID ${clientId} is already archived` + ) + ); + } + + await db.transaction(async (trx) => { + // Archive the client + await trx + .update(clients) + .set({ archived: true }) + .where(eq(clients.clientId, clientId)); + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Client archived successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to archive client" + ) + ); + } +} diff --git a/server/routers/client/blockClient.ts b/server/routers/client/blockClient.ts new file mode 100644 index 000000000..bd760b3da --- /dev/null +++ b/server/routers/client/blockClient.ts @@ -0,0 +1,102 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { sendTerminateClient } from "./terminate"; +import { OlmErrorCodes } from "../olm/error"; + +const blockClientSchema = z.strictObject({ + clientId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/block", + description: "Block a client by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: blockClientSchema + }, + responses: {} +}); + +export async function blockClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = blockClientSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + // Check if client exists + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + if (client.blocked) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Client with ID ${clientId} is already blocked` + ) + ); + } + + await db.transaction(async (trx) => { + // Block the client + await trx + .update(clients) + .set({ blocked: true, approvalState: "denied" }) + .where(eq(clients.clientId, clientId)); + + // Send terminate signal if there's an associated OLM and it's connected + if (client.olmId && client.online) { + await sendTerminateClient(client.clientId, OlmErrorCodes.TERMINATED_BLOCKED, client.olmId); + } + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Client blocked successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to block client" + ) + ); + } +} diff --git a/server/routers/client/createClient.ts b/server/routers/client/createClient.ts index ea3a371d3..337d7e714 100644 --- a/server/routers/client/createClient.ts +++ b/server/routers/client/createClient.ts @@ -26,6 +26,7 @@ import { generateId } from "@server/auth/sessions/app"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { getUniqueClientName } from "@server/db/names"; +import { build } from "@server/build"; const createClientParamsSchema = z.strictObject({ orgId: z.string() @@ -47,7 +48,7 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/client", description: "Create a new client for an organization.", - tags: [OpenAPITags.Client, OpenAPITags.Org], + tags: [OpenAPITags.Client], request: { params: createClientParamsSchema, body: { @@ -91,7 +92,7 @@ export async function createClient( const { orgId } = parsedParams.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -101,7 +102,7 @@ export async function createClient( return next( createHttpError( HttpCode.BAD_REQUEST, - "Invalid subnet format. Please provide a valid CIDR notation." + "Invalid subnet format. Please provide a valid IP." ) ); } @@ -195,6 +196,12 @@ export async function createClient( const randomExitNode = exitNodesList[Math.floor(Math.random() * exitNodesList.length)]; + if (!randomExitNode) { + return next( + createHttpError(HttpCode.NOT_FOUND, `No exit nodes available. ${build == "saas" ? "Please contact support." : "You need to install gerbil to use the clients."}`) + ); + } + const [adminRole] = await trx .select() .from(roles) @@ -227,7 +234,7 @@ export async function createClient( clientId: newClient.clientId }); - if (req.user && req.userOrgRoleId != adminRole.roleId) { + if (req.user && !req.userOrgRoleIds?.includes(adminRole.roleId)) { // make sure the user can access the client trx.insert(userClients).values({ userId: req.user.userId, diff --git a/server/routers/client/createUserClient.ts b/server/routers/client/createUserClient.ts index 5e9840f9d..d61eab15f 100644 --- a/server/routers/client/createUserClient.ts +++ b/server/routers/client/createUserClient.ts @@ -49,7 +49,7 @@ registry.registerPath({ path: "/org/{orgId}/user/{userId}/client", description: "Create a new client for a user and associate it with an existing olm.", - tags: [OpenAPITags.Client, OpenAPITags.Org, OpenAPITags.User], + tags: [OpenAPITags.Client], request: { params: paramsSchema, body: { diff --git a/server/routers/client/deleteClient.ts b/server/routers/client/deleteClient.ts index 775708ce1..276bfde96 100644 --- a/server/routers/client/deleteClient.ts +++ b/server/routers/client/deleteClient.ts @@ -11,6 +11,7 @@ import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "./terminate"; +import { OlmErrorCodes } from "../olm/error"; const deleteClientSchema = z.strictObject({ clientId: z.string().transform(Number).pipe(z.int().positive()) @@ -60,11 +61,12 @@ export async function deleteClient( ); } + // Only allow deletion of machine clients (clients without userId) if (client.userId) { return next( createHttpError( HttpCode.BAD_REQUEST, - `Cannot delete a user client with this endpoint` + `Cannot delete a user client. User clients must be archived instead.` ) ); } @@ -90,7 +92,7 @@ export async function deleteClient( await rebuildClientAssociationsFromClient(deletedClient, trx); if (olm) { - await sendTerminateClient(deletedClient.clientId, olm.olmId); // the olmId needs to be provided because it cant look it up after deletion + await sendTerminateClient(deletedClient.clientId, OlmErrorCodes.TERMINATED_DELETED, olm.olmId); // the olmId needs to be provided because it cant look it up after deletion } }); diff --git a/server/routers/client/getClient.ts b/server/routers/client/getClient.ts index cfb2652b1..375c027a7 100644 --- a/server/routers/client/getClient.ts +++ b/server/routers/client/getClient.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, olms } from "@server/db"; -import { clients } from "@server/db"; +import { db, olms, users } from "@server/db"; +import { clients, currentFingerprint } from "@server/db"; import { eq, and } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -10,6 +10,10 @@ import logger from "@server/logger"; import stoi from "@server/lib/stoi"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import { getUserDeviceName } from "@server/db/names"; +import { build } from "@server/build"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const getClientSchema = z.strictObject({ clientId: z @@ -29,6 +33,11 @@ async function query(clientId?: number, niceId?: string, orgId?: string) { .from(clients) .where(eq(clients.clientId, clientId)) .leftJoin(olms, eq(clients.clientId, olms.clientId)) + .leftJoin( + currentFingerprint, + eq(olms.olmId, currentFingerprint.olmId) + ) + .leftJoin(users, eq(clients.userId, users.userId)) .limit(1); return res; } else if (niceId && orgId) { @@ -36,16 +45,197 @@ async function query(clientId?: number, niceId?: string, orgId?: string) { .select() .from(clients) .where(and(eq(clients.niceId, niceId), eq(clients.orgId, orgId))) - .leftJoin(olms, eq(olms.clientId, olms.clientId)) + .leftJoin(olms, eq(clients.clientId, olms.clientId)) + .leftJoin( + currentFingerprint, + eq(olms.olmId, currentFingerprint.olmId) + ) + .leftJoin(users, eq(clients.userId, users.userId)) .limit(1); return res; } } +type PostureData = { + biometricsEnabled?: boolean | null | "-"; + diskEncrypted?: boolean | null | "-"; + firewallEnabled?: boolean | null | "-"; + autoUpdatesEnabled?: boolean | null | "-"; + tpmAvailable?: boolean | null | "-"; + windowsAntivirusEnabled?: boolean | null | "-"; + macosSipEnabled?: boolean | null | "-"; + macosGatekeeperEnabled?: boolean | null | "-"; + macosFirewallStealthMode?: boolean | null | "-"; + linuxAppArmorEnabled?: boolean | null | "-"; + linuxSELinuxEnabled?: boolean | null | "-"; +}; + +function maskPostureDataWithPlaceholder(posture: PostureData): PostureData { + const masked: PostureData = {}; + for (const key of Object.keys(posture) as (keyof PostureData)[]) { + if (posture[key] !== undefined && posture[key] !== null) { + (masked as Record)[key] = "-"; + } + } + return masked; +} + +function getPlatformPostureData( + platform: string | null | undefined, + fingerprint: typeof currentFingerprint.$inferSelect | null +): PostureData | null { + if (!fingerprint) return null; + + const normalizedPlatform = platform?.toLowerCase() || "unknown"; + const posture: PostureData = {}; + + // Windows: Hard drive encryption, Firewall, Auto updates, TPM availability, Windows Antivirus status + if (normalizedPlatform === "windows") { + if ( + fingerprint.diskEncrypted !== null && + fingerprint.diskEncrypted !== undefined + ) { + posture.diskEncrypted = fingerprint.diskEncrypted; + } + if ( + fingerprint.firewallEnabled !== null && + fingerprint.firewallEnabled !== undefined + ) { + posture.firewallEnabled = fingerprint.firewallEnabled; + } + if ( + fingerprint.tpmAvailable !== null && + fingerprint.tpmAvailable !== undefined + ) { + posture.tpmAvailable = fingerprint.tpmAvailable; + } + if ( + fingerprint.windowsAntivirusEnabled !== null && + fingerprint.windowsAntivirusEnabled !== undefined + ) { + posture.windowsAntivirusEnabled = + fingerprint.windowsAntivirusEnabled; + } + } + // macOS: Hard drive encryption, Biometric configuration, Firewall, System Integrity Protection (SIP), Gatekeeper, Firewall stealth mode + else if (normalizedPlatform === "macos") { + if ( + fingerprint.diskEncrypted !== null && + fingerprint.diskEncrypted !== undefined + ) { + posture.diskEncrypted = fingerprint.diskEncrypted; + } + if ( + fingerprint.biometricsEnabled !== null && + fingerprint.biometricsEnabled !== undefined + ) { + posture.biometricsEnabled = fingerprint.biometricsEnabled; + } + if ( + fingerprint.firewallEnabled !== null && + fingerprint.firewallEnabled !== undefined + ) { + posture.firewallEnabled = fingerprint.firewallEnabled; + } + if ( + fingerprint.macosSipEnabled !== null && + fingerprint.macosSipEnabled !== undefined + ) { + posture.macosSipEnabled = fingerprint.macosSipEnabled; + } + if ( + fingerprint.macosGatekeeperEnabled !== null && + fingerprint.macosGatekeeperEnabled !== undefined + ) { + posture.macosGatekeeperEnabled = fingerprint.macosGatekeeperEnabled; + } + if ( + fingerprint.macosFirewallStealthMode !== null && + fingerprint.macosFirewallStealthMode !== undefined + ) { + posture.macosFirewallStealthMode = + fingerprint.macosFirewallStealthMode; + } + if ( + fingerprint.autoUpdatesEnabled !== null && + fingerprint.autoUpdatesEnabled !== undefined + ) { + posture.autoUpdatesEnabled = fingerprint.autoUpdatesEnabled; + } + } + // Linux: Hard drive encryption, Firewall, AppArmor, SELinux, TPM availability + else if (normalizedPlatform === "linux") { + if ( + fingerprint.diskEncrypted !== null && + fingerprint.diskEncrypted !== undefined + ) { + posture.diskEncrypted = fingerprint.diskEncrypted; + } + if ( + fingerprint.firewallEnabled !== null && + fingerprint.firewallEnabled !== undefined + ) { + posture.firewallEnabled = fingerprint.firewallEnabled; + } + if ( + fingerprint.linuxAppArmorEnabled !== null && + fingerprint.linuxAppArmorEnabled !== undefined + ) { + posture.linuxAppArmorEnabled = fingerprint.linuxAppArmorEnabled; + } + if ( + fingerprint.linuxSELinuxEnabled !== null && + fingerprint.linuxSELinuxEnabled !== undefined + ) { + posture.linuxSELinuxEnabled = fingerprint.linuxSELinuxEnabled; + } + if ( + fingerprint.tpmAvailable !== null && + fingerprint.tpmAvailable !== undefined + ) { + posture.tpmAvailable = fingerprint.tpmAvailable; + } + } + // iOS: Biometric configuration + else if (normalizedPlatform === "ios") { + // none supported yet + } + // Android: Screen lock, Biometric configuration, Hard drive encryption + else if (normalizedPlatform === "android") { + if ( + fingerprint.diskEncrypted !== null && + fingerprint.diskEncrypted !== undefined + ) { + posture.diskEncrypted = fingerprint.diskEncrypted; + } + } + + // Only return if we have at least one posture field + return Object.keys(posture).length > 0 ? posture : null; +} + export type GetClientResponse = NonNullable< Awaited> >["clients"] & { olmId: string | null; + agent: string | null; + olmVersion: string | null; + userEmail: string | null; + userName: string | null; + userUsername: string | null; + fingerprint: { + username: string | null; + hostname: string | null; + platform: string | null; + osVersion: string | null; + kernelVersion: string | null; + arch: string | null; + deviceModel: string | null; + serialNumber: string | null; + firstSeen: number | null; + lastSeen: number | null; + } | null; + posture: PostureData | null; }; registry.registerPath({ @@ -53,7 +243,7 @@ registry.registerPath({ path: "/org/{orgId}/client/{niceId}", description: "Get a client by orgId and niceId. NiceId is a readable ID for the site and unique on a per org basis.", - tags: [OpenAPITags.Org, OpenAPITags.Site], + tags: [OpenAPITags.Site], request: { params: z.object({ orgId: z.string(), @@ -105,9 +295,59 @@ export async function getClient( ); } + const isUserDevice = client.user !== null && client.user !== undefined; + + // Replace name with device name if OLM exists + let clientName = client.clients.name; + if (client.olms && isUserDevice) { + const model = client.currentFingerprint?.deviceModel || null; + clientName = getUserDeviceName(model, client.clients.name); + } + + // Build fingerprint data if available + const fingerprintData = client.currentFingerprint + ? { + username: client.currentFingerprint.username || null, + hostname: client.currentFingerprint.hostname || null, + platform: client.currentFingerprint.platform || null, + osVersion: client.currentFingerprint.osVersion || null, + kernelVersion: + client.currentFingerprint.kernelVersion || null, + arch: client.currentFingerprint.arch || null, + deviceModel: client.currentFingerprint.deviceModel || null, + serialNumber: client.currentFingerprint.serialNumber || null, + firstSeen: client.currentFingerprint.firstSeen || null, + lastSeen: client.currentFingerprint.lastSeen || null + } + : null; + + // Build posture data if available (platform-specific) + // Licensed: real values; not licensed: same keys but values set to "-" + const rawPosture = getPlatformPostureData( + client.currentFingerprint?.platform || null, + client.currentFingerprint + ); + const isOrgLicensed = await isLicensedOrSubscribed( + client.clients.orgId, + tierMatrix.devicePosture + ); + const postureData: PostureData | null = rawPosture + ? isOrgLicensed + ? rawPosture + : maskPostureDataWithPlaceholder(rawPosture) + : null; + const data: GetClientResponse = { ...client.clients, - olmId: client.olms ? client.olms.olmId : null + name: clientName, + olmId: client.olms ? client.olms.olmId : null, + agent: client.olms?.agent || null, + olmVersion: client.olms?.version || null, + userEmail: client.user?.email ?? null, + userName: client.user?.name ?? null, + userUsername: client.user?.username ?? null, + fingerprint: fingerprintData, + posture: postureData }; return response(res, { diff --git a/server/routers/client/index.ts b/server/routers/client/index.ts index 8e88c11ec..e195d1c52 100644 --- a/server/routers/client/index.ts +++ b/server/routers/client/index.ts @@ -1,7 +1,12 @@ export * from "./pickClientDefaults"; export * from "./createClient"; export * from "./deleteClient"; +export * from "./archiveClient"; +export * from "./unarchiveClient"; +export * from "./blockClient"; +export * from "./unblockClient"; export * from "./listClients"; +export * from "./listUserDevices"; export * from "./updateClient"; export * from "./getClient"; export * from "./createUserClient"; diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 42e47efea..0bf798509 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -1,32 +1,38 @@ -import { db, olms, users } from "@server/db"; import { clients, + clientSitesAssociationsCache, + currentFingerprint, + db, + olms, orgs, roleClients, sites, userClients, - clientSitesAssociationsCache + users } from "@server/db"; -import logger from "@server/logger"; -import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import type { PaginatedResponse } from "@server/types/Pagination"; import { and, - count, + asc, + desc, eq, inArray, - isNotNull, isNull, + like, or, - sql + sql, + type SQL } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; -import { z } from "zod"; -import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; import NodeCache from "node-cache"; import semver from "semver"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; const olmVersionCache = new NodeCache({ stdTTL: 3600 }); @@ -56,15 +62,15 @@ async function getLatestOlmVersion(): Promise { return null; } - const tags = await response.json(); + let tags = await response.json(); if (!Array.isArray(tags) || tags.length === 0) { logger.warn("No tags found for Olm repository"); return null; } - + tags = tags.filter((version) => !version.name.includes("rc")); const latestVersion = tags[0].name; - olmVersionCache.set("latestOlmVersion", latestVersion); + olmVersionCache.set("latestOlmVersion", latestVersion, 3600); return latestVersion; } catch (error: any) { @@ -87,38 +93,86 @@ const listClientsParamsSchema = z.strictObject({ }); const listClientsSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().positive()), - offset: z - .string() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()), - filter: z.enum(["user", "machine"]).optional() + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional(), + sort_by: z + .enum(["name", "megabytesIn", "megabytesOut"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["name", "megabytesIn", "megabytesOut"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }), + online: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined) + .openapi({ + type: "boolean", + description: "Filter by online status" + }), + status: z.preprocess( + (val: string | undefined) => { + if (val) { + return val.split(","); // the search query array is an array joined by commas + } + return undefined; + }, + z + .array(z.enum(["active", "blocked", "archived"])) + .optional() + .default(["active"]) + .catch(["active"]) + .openapi({ + type: "array", + items: { + type: "string", + enum: ["active", "blocked", "archived"] + }, + default: ["active"], + description: + "Filter by client status. Can be a comma-separated list of values. Defaults to 'active'." + }) + ) }); -function queryClients( - orgId: string, - accessibleClientIds: number[], - filter?: "user" | "machine" -) { - const conditions = [ - inArray(clients.clientId, accessibleClientIds), - eq(clients.orgId, orgId) - ]; - - // Add filter condition based on filter type - if (filter === "user") { - conditions.push(isNotNull(clients.userId)); - } else if (filter === "machine") { - conditions.push(isNull(clients.userId)); - } - +function queryClientsBase() { return db .select({ clientId: clients.clientId, @@ -136,13 +190,17 @@ function queryClients( username: users.username, userEmail: users.email, niceId: clients.niceId, - agent: olms.agent + agent: olms.agent, + approvalState: clients.approvalState, + olmArchived: olms.archived, + archived: clients.archived, + blocked: clients.blocked }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) .leftJoin(olms, eq(clients.clientId, olms.clientId)) .leftJoin(users, eq(clients.userId, users.userId)) - .where(and(...conditions)); + .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)); } async function getSiteAssociations(clientIds: number[]) { @@ -160,29 +218,26 @@ async function getSiteAssociations(clientIds: number[]) { .where(inArray(clientSitesAssociationsCache.clientId, clientIds)); } -type OlmWithUpdateAvailable = Awaited>[0] & { +type ClientWithSites = Awaited>[0] & { + sites: Array<{ + siteId: number; + siteName: string | null; + siteNiceId: string | null; + }>; olmUpdateAvailable?: boolean; }; -export type ListClientsResponse = { - clients: Array< - Awaited>[0] & { - sites: Array<{ - siteId: number; - siteName: string | null; - siteNiceId: string | null; - }>; - olmUpdateAvailable?: boolean; - } - >; - pagination: { total: number; limit: number; offset: number }; -}; +type OlmWithUpdateAvailable = ClientWithSites; + +export type ListClientsResponse = PaginatedResponse<{ + clients: Array; +}>; registry.registerPath({ method: "get", path: "/org/{orgId}/clients", description: "List all clients for an organization.", - tags: [OpenAPITags.Client, OpenAPITags.Org], + tags: [OpenAPITags.Client], request: { query: listClientsSchema, params: listClientsParamsSchema @@ -205,7 +260,8 @@ export async function listClients( ) ); } - const { limit, offset, filter } = parsedQuery.data; + const { page, pageSize, online, query, status, sort_by, order } = + parsedQuery.data; const parsedParams = listClientsParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -241,7 +297,7 @@ export async function listClients( .where( or( eq(userClients.userId, req.user!.userId), - eq(roleClients.roleId, req.userOrgRoleId!) + inArray(roleClients.roleId, req.userOrgRoleIds!) ) ); } else { @@ -254,28 +310,73 @@ export async function listClients( const accessibleClientIds = accessibleClients.map( (client) => client.clientId ); - const baseQuery = queryClients(orgId, accessibleClientIds, filter); // Get client count with filter - const countConditions = [ - inArray(clients.clientId, accessibleClientIds), - eq(clients.orgId, orgId) + const conditions = [ + and( + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId), + isNull(clients.userId) + ) ]; - if (filter === "user") { - countConditions.push(isNotNull(clients.userId)); - } else if (filter === "machine") { - countConditions.push(isNull(clients.userId)); + if (typeof online !== "undefined") { + conditions.push(eq(clients.online, online)); } - const countQuery = db - .select({ count: count() }) - .from(clients) - .where(and(...countConditions)); + if (status.length > 0) { + const filterAggregates: (SQL | undefined)[] = []; - const clientsList = await baseQuery.limit(limit).offset(offset); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0].count; + if (status.includes("active")) { + filterAggregates.push( + and(eq(clients.archived, false), eq(clients.blocked, false)) + ); + } + + if (status.includes("archived")) { + filterAggregates.push(eq(clients.archived, true)); + } + if (status.includes("blocked")) { + filterAggregates.push(eq(clients.blocked, true)); + } + + conditions.push(or(...filterAggregates)); + } + + if (query) { + conditions.push( + or( + like( + sql`LOWER(${clients.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${clients.niceId})`, + "%" + query.toLowerCase() + "%" + ) + ) + ); + } + + const baseQuery = queryClientsBase().where(and(...conditions)); + + const countQuery = db.$count(baseQuery.as("filtered_clients")); + + const listMachinesQuery = baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(clients[sort_by]) + : desc(clients[sort_by]) + : asc(clients.name) + ); + + const [clientsList, totalCount] = await Promise.all([ + listMachinesQuery, + countQuery + ]); // Get associated sites for all clients const clientIds = clientsList.map((client) => client.clientId); @@ -304,11 +405,13 @@ export async function listClients( > ); - // Merge clients with their site associations - const clientsWithSites = clientsList.map((client) => ({ - ...client, - sites: sitesByClient[client.clientId] || [] - })); + // Merge clients with their site associations and replace name with device name + const clientsWithSites = clientsList.map((client) => { + return { + ...client, + sites: sitesByClient[client.clientId] || [] + }; + }); const latestOlVersionPromise = getLatestOlmVersion(); @@ -347,11 +450,11 @@ export async function listClients( return response(res, { data: { - clients: clientsWithSites, + clients: olmsWithUpdates, pagination: { total: totalCount, - limit, - offset + page, + pageSize } }, success: true, diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts new file mode 100644 index 000000000..0ae31165a --- /dev/null +++ b/server/routers/client/listUserDevices.ts @@ -0,0 +1,500 @@ +import { build } from "@server/build"; +import { + clients, + currentFingerprint, + db, + olms, + orgs, + roleClients, + userClients, + users +} from "@server/db"; +import { getUserDeviceName } from "@server/db/names"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import { + and, + asc, + desc, + eq, + inArray, + isNotNull, + isNull, + like, + or, + sql, + type SQL +} from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import NodeCache from "node-cache"; +import semver from "semver"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; + +const olmVersionCache = new NodeCache({ stdTTL: 3600 }); + +async function getLatestOlmVersion(): Promise { + try { + const cachedVersion = olmVersionCache.get("latestOlmVersion"); + if (cachedVersion) { + return cachedVersion; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1500); + + const response = await fetch( + "https://api.github.com/repos/fosrl/olm/tags", + { + signal: controller.signal + } + ); + + clearTimeout(timeoutId); + + if (!response.ok) { + logger.warn( + `Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}` + ); + return null; + } + + let tags = await response.json(); + if (!Array.isArray(tags) || tags.length === 0) { + logger.warn("No tags found for Olm repository"); + return null; + } + tags = tags.filter((version) => !version.name.includes("rc")); + const latestVersion = tags[0].name; + + olmVersionCache.set("latestOlmVersion", latestVersion, 3600); + + return latestVersion; + } catch (error: any) { + if (error.name === "AbortError") { + logger.warn("Request to fetch latest Olm version timed out (1.5s)"); + } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { + logger.warn("Connection timeout while fetching latest Olm version"); + } else { + logger.warn( + "Error fetching latest Olm version:", + error.message || error + ); + } + return null; + } +} + +const listUserDevicesParamsSchema = z.strictObject({ + orgId: z.string() +}); + +const listUserDevicesSchema = z.object({ + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() + .optional() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) + .optional() + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional(), + sort_by: z + .enum(["megabytesIn", "megabytesOut"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["megabytesIn", "megabytesOut"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }), + online: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined) + .openapi({ + type: "boolean", + description: "Filter by online status" + }), + agent: z + .enum([ + "windows", + "android", + "cli", + "olm", + "macos", + "ios", + "ipados", + "unknown" + ]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: [ + "windows", + "android", + "cli", + "olm", + "macos", + "ios", + "ipados", + "unknown" + ], + description: + "Filter by agent type. Use 'unknown' to filter clients with no agent detected." + }), + status: z.preprocess( + (val: string | undefined) => { + if (val) { + return val.split(","); // the search query array is an array joined by commas + } + return undefined; + }, + z + .array( + z.enum(["active", "pending", "denied", "blocked", "archived"]) + ) + .optional() + .default(["active", "pending"]) + .catch(["active", "pending"]) + .openapi({ + type: "array", + items: { + type: "string", + enum: ["active", "pending", "denied", "blocked", "archived"] + }, + default: ["active", "pending"], + description: + "Filter by device status. Can include multiple values separated by commas. 'active' means not archived, not blocked, and if approval is enabled, approved. 'pending' and 'denied' are only applicable if approval is enabled." + }) + ) +}); + +function queryUserDevicesBase() { + return db + .select({ + clientId: clients.clientId, + orgId: clients.orgId, + name: clients.name, + pubKey: clients.pubKey, + subnet: clients.subnet, + megabytesIn: clients.megabytesIn, + megabytesOut: clients.megabytesOut, + orgName: orgs.name, + type: clients.type, + online: clients.online, + olmVersion: olms.version, + userId: clients.userId, + username: users.username, + userEmail: users.email, + niceId: clients.niceId, + agent: olms.agent, + approvalState: clients.approvalState, + olmArchived: olms.archived, + archived: clients.archived, + blocked: clients.blocked, + deviceModel: currentFingerprint.deviceModel, + fingerprintPlatform: currentFingerprint.platform, + fingerprintOsVersion: currentFingerprint.osVersion, + fingerprintKernelVersion: currentFingerprint.kernelVersion, + fingerprintArch: currentFingerprint.arch, + fingerprintSerialNumber: currentFingerprint.serialNumber, + fingerprintUsername: currentFingerprint.username, + fingerprintHostname: currentFingerprint.hostname + }) + .from(clients) + .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) + .leftJoin(olms, eq(clients.clientId, olms.clientId)) + .leftJoin(users, eq(clients.userId, users.userId)) + .leftJoin(currentFingerprint, eq(olms.olmId, currentFingerprint.olmId)); +} + +type OlmWithUpdateAvailable = Awaited< + ReturnType +>[0] & { + olmUpdateAvailable?: boolean; +}; + +export type ListUserDevicesResponse = PaginatedResponse<{ + devices: Array; +}>; + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/user-devices", + description: "List all user devices for an organization.", + tags: [OpenAPITags.Client], + request: { + query: listUserDevicesSchema, + params: listUserDevicesParamsSchema + }, + responses: {} +}); + +export async function listUserDevices( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedQuery = listUserDevicesSchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error) + ) + ); + } + const { page, pageSize, query, sort_by, online, status, agent, order } = + parsedQuery.data; + + const parsedParams = listUserDevicesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error) + ) + ); + } + const { orgId } = parsedParams.data; + + if (req.user && orgId && orgId !== req.userOrgId) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "User does not have access to this organization" + ) + ); + } + + let accessibleClients; + if (req.user) { + accessibleClients = await db + .select({ + clientId: sql`COALESCE(${userClients.clientId}, ${roleClients.clientId})` + }) + .from(userClients) + .fullJoin( + roleClients, + eq(userClients.clientId, roleClients.clientId) + ) + .where( + or( + eq(userClients.userId, req.user!.userId), + inArray(roleClients.roleId, req.userOrgRoleIds!) + ) + ); + } else { + accessibleClients = await db + .select({ clientId: clients.clientId }) + .from(clients) + .where(eq(clients.orgId, orgId)); + } + + const accessibleClientIds = accessibleClients.map( + (client) => client.clientId + ); + // Get client count with filter + const conditions = [ + and( + inArray(clients.clientId, accessibleClientIds), + eq(clients.orgId, orgId), + isNotNull(clients.userId) + ) + ]; + + if (query) { + conditions.push( + or( + like( + sql`LOWER(${clients.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${clients.niceId})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${users.email})`, + "%" + query.toLowerCase() + "%" + ) + ) + ); + } + + if (typeof online !== "undefined") { + conditions.push(eq(clients.online, online)); + } + + const agentValueMap = { + windows: "Pangolin Windows", + android: "Pangolin Android", + ios: "Pangolin iOS", + ipados: "Pangolin iPadOS", + macos: "Pangolin macOS", + cli: "Pangolin CLI", + olm: "Olm CLI" + } satisfies Record< + Exclude, + string + >; + if (typeof agent !== "undefined") { + if (agent === "unknown") { + conditions.push(isNull(olms.agent)); + } else { + conditions.push(eq(olms.agent, agentValueMap[agent])); + } + } + + if (status.length > 0) { + const filterAggregates: (SQL | undefined)[] = []; + + if (status.includes("active")) { + filterAggregates.push( + and( + eq(clients.archived, false), + eq(clients.blocked, false), + build !== "oss" + ? or( + eq(clients.approvalState, "approved"), + isNull(clients.approvalState) // approval state of `NULL` means approved by default + ) + : undefined // undefined are automatically ignored by `drizzle-orm` + ) + ); + } + + if (status.includes("archived")) { + filterAggregates.push(eq(clients.archived, true)); + } + if (status.includes("blocked")) { + filterAggregates.push(eq(clients.blocked, true)); + } + + if (build !== "oss") { + if (status.includes("pending")) { + filterAggregates.push(eq(clients.approvalState, "pending")); + } + if (status.includes("denied")) { + filterAggregates.push(eq(clients.approvalState, "denied")); + } + } + + conditions.push(or(...filterAggregates)); + } + + const baseQuery = queryUserDevicesBase().where(and(...conditions)); + + const countQuery = db.$count(baseQuery.as("filtered_clients")); + + const listDevicesQuery = baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(clients[sort_by]) + : desc(clients[sort_by]) + : asc(clients.clientId) + ); + + const [clientsList, totalCount] = await Promise.all([ + listDevicesQuery, + countQuery + ]); + + // Merge clients with their site associations and replace name with device name + const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsList.map( + (client) => { + const model = client.deviceModel || null; + const newName = getUserDeviceName(model, client.name); + const OlmWithUpdate: OlmWithUpdateAvailable = { + ...client, + name: newName + }; + // Initially set to false, will be updated if version check succeeds + OlmWithUpdate.olmUpdateAvailable = false; + return OlmWithUpdate; + } + ); + + // Try to get the latest version, but don't block if it fails + try { + const latestOlmVersion = await getLatestOlmVersion(); + + if (latestOlmVersion) { + olmsWithUpdates.forEach((client) => { + try { + client.olmUpdateAvailable = semver.lt( + client.olmVersion ? client.olmVersion : "", + latestOlmVersion + ); + } catch (error) { + client.olmUpdateAvailable = false; + } + }); + } + } catch (error) { + // Log the error but don't let it block the response + logger.warn( + "Failed to check for OLM updates, continuing without update info:", + error + ); + } + + return response(res, { + data: { + devices: olmsWithUpdates, + pagination: { + total: totalCount, + page, + pageSize + } + }, + success: true, + error: false, + message: "Clients retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/client/pickClientDefaults.ts b/server/routers/client/pickClientDefaults.ts index fd31da127..5dffd77d7 100644 --- a/server/routers/client/pickClientDefaults.ts +++ b/server/routers/client/pickClientDefaults.ts @@ -23,7 +23,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/pick-client-defaults", description: "Return pre-requisite data for creating a client.", - tags: [OpenAPITags.Client, OpenAPITags.Site], + tags: [OpenAPITags.Client], request: { params: pickClientDefaultsSchema }, diff --git a/server/routers/client/targets.ts b/server/routers/client/targets.ts index b7b91925c..b2d49db4c 100644 --- a/server/routers/client/targets.ts +++ b/server/routers/client/targets.ts @@ -1,37 +1,128 @@ import { sendToClient } from "#dynamic/routers/ws"; -import { db, olms, Transaction } from "@server/db"; -import { Alias, SubnetProxyTarget } from "@server/lib/ip"; +import { db, newts, olms } from "@server/db"; +import { + Alias, + convertSubnetProxyTargetsV2ToV1, + SubnetProxyTarget, + SubnetProxyTargetV2 +} from "@server/lib/ip"; +import { canCompress } from "@server/lib/clientVersionChecks"; import logger from "@server/logger"; import { eq } from "drizzle-orm"; +import semver from "semver"; -export async function addTargets(newtId: string, targets: SubnetProxyTarget[]) { - await sendToClient(newtId, { - type: `newt/wg/targets/add`, - data: targets - }); +const NEWT_V2_TARGETS_VERSION = ">=1.10.3"; + +export async function convertTargetsIfNessicary( + newtId: string, + targets: SubnetProxyTarget[] | SubnetProxyTargetV2[] +) { + // get the newt + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.newtId, newtId)); + if (!newt) { + throw new Error(`No newt found for id: ${newtId}`); + } + + // check the semver + if ( + newt.version && + !semver.satisfies(newt.version, NEWT_V2_TARGETS_VERSION) + ) { + logger.debug( + `addTargets Newt version ${newt.version} does not support targets v2 falling back` + ); + targets = convertSubnetProxyTargetsV2ToV1( + targets as SubnetProxyTargetV2[] + ); + } + + return targets; +} + +export async function addTargets( + newtId: string, + targets: SubnetProxyTarget[] | SubnetProxyTargetV2[], + version?: string | null +) { + targets = await convertTargetsIfNessicary(newtId, targets); + + await sendToClient( + newtId, + { + type: `newt/wg/targets/add`, + data: targets + }, + { incrementConfigVersion: true, compress: canCompress(version, "newt") } + ); } export async function removeTargets( newtId: string, - targets: SubnetProxyTarget[] + targets: SubnetProxyTarget[] | SubnetProxyTargetV2[], + version?: string | null ) { - await sendToClient(newtId, { - type: `newt/wg/targets/remove`, - data: targets - }); + targets = await convertTargetsIfNessicary(newtId, targets); + + await sendToClient( + newtId, + { + type: `newt/wg/targets/remove`, + data: targets + }, + { incrementConfigVersion: true, compress: canCompress(version, "newt") } + ); } export async function updateTargets( newtId: string, targets: { - oldTargets: SubnetProxyTarget[]; - newTargets: SubnetProxyTarget[]; - } + oldTargets: SubnetProxyTarget[] | SubnetProxyTargetV2[]; + newTargets: SubnetProxyTarget[] | SubnetProxyTargetV2[]; + }, + version?: string | null ) { - await sendToClient(newtId, { - type: `newt/wg/targets/update`, - data: targets - }).catch((error) => { + // get the newt + const [newt] = await db + .select() + .from(newts) + .where(eq(newts.newtId, newtId)); + if (!newt) { + logger.error(`addTargetsL No newt found for id: ${newtId}`); + return; + } + + // check the semver + if ( + newt.version && + !semver.satisfies(newt.version, NEWT_V2_TARGETS_VERSION) + ) { + logger.debug( + `addTargets Newt version ${newt.version} does not support targets v2 falling back` + ); + targets = { + oldTargets: convertSubnetProxyTargetsV2ToV1( + targets.oldTargets as SubnetProxyTargetV2[] + ), + newTargets: convertSubnetProxyTargetsV2ToV1( + targets.newTargets as SubnetProxyTargetV2[] + ) + }; + } + + await sendToClient( + newtId, + { + type: `newt/wg/targets/update`, + data: { + oldTargets: targets.oldTargets, + newTargets: targets.newTargets + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "newt") } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); } @@ -41,7 +132,8 @@ export async function addPeerData( siteId: number, remoteSubnets: string[], aliases: Alias[], - olmId?: string + olmId?: string, + version?: string | null ) { if (!olmId) { const [olm] = await db @@ -53,16 +145,21 @@ export async function addPeerData( return; // ignore this because an olm might not be associated with the client anymore } olmId = olm.olmId; + version = olm.version; } - await sendToClient(olmId, { - type: `olm/wg/peer/data/add`, - data: { - siteId: siteId, - remoteSubnets: remoteSubnets, - aliases: aliases - } - }).catch((error) => { + await sendToClient( + olmId, + { + type: `olm/wg/peer/data/add`, + data: { + siteId: siteId, + remoteSubnets: remoteSubnets, + aliases: aliases + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "olm") } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); } @@ -72,7 +169,8 @@ export async function removePeerData( siteId: number, remoteSubnets: string[], aliases: Alias[], - olmId?: string + olmId?: string, + version?: string | null ) { if (!olmId) { const [olm] = await db @@ -84,16 +182,21 @@ export async function removePeerData( return; } olmId = olm.olmId; + version = olm.version; } - await sendToClient(olmId, { - type: `olm/wg/peer/data/remove`, - data: { - siteId: siteId, - remoteSubnets: remoteSubnets, - aliases: aliases - } - }).catch((error) => { + await sendToClient( + olmId, + { + type: `olm/wg/peer/data/remove`, + data: { + siteId: siteId, + remoteSubnets: remoteSubnets, + aliases: aliases + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "olm") } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); } @@ -113,7 +216,8 @@ export async function updatePeerData( newAliases: Alias[]; } | undefined, - olmId?: string + olmId?: string, + version?: string | null ) { if (!olmId) { const [olm] = await db @@ -125,16 +229,21 @@ export async function updatePeerData( return; } olmId = olm.olmId; + version = olm.version; } - await sendToClient(olmId, { - type: `olm/wg/peer/data/update`, - data: { - siteId: siteId, - ...remoteSubnets, - ...aliases - } - }).catch((error) => { + await sendToClient( + olmId, + { + type: `olm/wg/peer/data/update`, + data: { + siteId: siteId, + ...remoteSubnets, + ...aliases + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "olm") } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); } diff --git a/server/routers/client/terminate.ts b/server/routers/client/terminate.ts index 1cfdc7098..db9cfcb04 100644 --- a/server/routers/client/terminate.ts +++ b/server/routers/client/terminate.ts @@ -1,9 +1,11 @@ import { sendToClient } from "#dynamic/routers/ws"; import { db, olms } from "@server/db"; import { eq } from "drizzle-orm"; +import { OlmErrorCodes } from "../olm/error"; export async function sendTerminateClient( clientId: number, + error: (typeof OlmErrorCodes)[keyof typeof OlmErrorCodes], olmId?: string | null ) { if (!olmId) { @@ -20,6 +22,9 @@ export async function sendTerminateClient( await sendToClient(olmId, { type: `olm/terminate`, - data: {} + data: { + code: error.code, + message: error.message + } }); } diff --git a/server/routers/client/unarchiveClient.ts b/server/routers/client/unarchiveClient.ts new file mode 100644 index 000000000..62c5c17c8 --- /dev/null +++ b/server/routers/client/unarchiveClient.ts @@ -0,0 +1,93 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const unarchiveClientSchema = z.strictObject({ + clientId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/unarchive", + description: "Unarchive a client by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: unarchiveClientSchema + }, + responses: {} +}); + +export async function unarchiveClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = unarchiveClientSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + // Check if client exists + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + if (!client.archived) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Client with ID ${clientId} is not archived` + ) + ); + } + + // Unarchive the client + await db + .update(clients) + .set({ archived: false }) + .where(eq(clients.clientId, clientId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "Client unarchived successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to unarchive client" + ) + ); + } +} diff --git a/server/routers/client/unblockClient.ts b/server/routers/client/unblockClient.ts new file mode 100644 index 000000000..a16a1030a --- /dev/null +++ b/server/routers/client/unblockClient.ts @@ -0,0 +1,93 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { clients } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const unblockClientSchema = z.strictObject({ + clientId: z.string().transform(Number).pipe(z.int().positive()) +}); + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/unblock", + description: "Unblock a client by its client ID.", + tags: [OpenAPITags.Client], + request: { + params: unblockClientSchema + }, + responses: {} +}); + +export async function unblockClient( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = unblockClientSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + + // Check if client exists + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Client with ID ${clientId} not found` + ) + ); + } + + if (!client.blocked) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Client with ID ${clientId} is not blocked` + ) + ); + } + + // Unblock the client + await db + .update(clients) + .set({ blocked: false, approvalState: null }) + .where(eq(clients.clientId, clientId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "Client unblocked successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to unblock client" + ) + ); + } +} diff --git a/server/routers/client/updateClient.ts b/server/routers/client/updateClient.ts index 12d0a1992..8ef01a2fc 100644 --- a/server/routers/client/updateClient.ts +++ b/server/routers/client/updateClient.ts @@ -6,7 +6,7 @@ import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import { eq, and } from "drizzle-orm"; +import { eq, and, ne } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -93,7 +93,8 @@ export async function updateClient( .where( and( eq(clients.niceId, niceId), - eq(clients.orgId, clients.orgId) + eq(clients.orgId, clients.orgId), + ne(clients.clientId, clientId) ) ) .limit(1); diff --git a/server/routers/domain/createOrgDomain.ts b/server/routers/domain/createOrgDomain.ts index 6558d748c..ceb61b25f 100644 --- a/server/routers/domain/createOrgDomain.ts +++ b/server/routers/domain/createOrgDomain.ts @@ -131,7 +131,7 @@ export async function createOrgDomain( } const rejectDomains = await usageService.checkLimitSet( orgId, - false, + FeatureId.DOMAINS, { ...usage, @@ -148,7 +148,6 @@ export async function createOrgDomain( } } - let numOrgDomains: OrgDomains[] | undefined; let aRecords: CreateDomainResponse["aRecords"]; let cnameRecords: CreateDomainResponse["cnameRecords"]; let txtRecords: CreateDomainResponse["txtRecords"]; @@ -347,20 +346,9 @@ export async function createOrgDomain( await trx.insert(dnsRecords).values(recordsToInsert); } - numOrgDomains = await trx - .select() - .from(orgDomains) - .where(eq(orgDomains.orgId, orgId)); + await usageService.add(orgId, FeatureId.DOMAINS, 1, trx); }); - if (numOrgDomains) { - await usageService.updateDaily( - orgId, - FeatureId.DOMAINS, - numOrgDomains.length - ); - } - if (!returned) { return next( createHttpError( diff --git a/server/routers/domain/deleteOrgDomain.ts b/server/routers/domain/deleteOrgDomain.ts index fa916beb2..4c347668e 100644 --- a/server/routers/domain/deleteOrgDomain.ts +++ b/server/routers/domain/deleteOrgDomain.ts @@ -36,8 +36,6 @@ export async function deleteAccountDomain( } const { domainId, orgId } = parsed.data; - let numOrgDomains: OrgDomains[] | undefined; - await db.transaction(async (trx) => { const [existing] = await trx .select() @@ -79,20 +77,9 @@ export async function deleteAccountDomain( await trx.delete(domains).where(eq(domains.domainId, domainId)); - numOrgDomains = await trx - .select() - .from(orgDomains) - .where(eq(orgDomains.orgId, orgId)); + await usageService.add(orgId, FeatureId.DOMAINS, -1, trx); }); - if (numOrgDomains) { - await usageService.updateDaily( - orgId, - FeatureId.DOMAINS, - numOrgDomains.length - ); - } - return response(res, { data: { success: true }, success: true, diff --git a/server/routers/domain/listDomains.ts b/server/routers/domain/listDomains.ts index 20b236346..085acf0c6 100644 --- a/server/routers/domain/listDomains.ts +++ b/server/routers/domain/listDomains.ts @@ -40,7 +40,8 @@ async function queryDomains(orgId: string, limit: number, offset: number) { tries: domains.tries, configManaged: domains.configManaged, certResolver: domains.certResolver, - preferWildcardCert: domains.preferWildcardCert + preferWildcardCert: domains.preferWildcardCert, + errorMessage: domains.errorMessage }) .from(orgDomains) .where(eq(orgDomains.orgId, orgId)) @@ -59,7 +60,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/domains", description: "List all domains for a organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.Domain], request: { params: z.object({ orgId: z.string() diff --git a/server/routers/external.ts b/server/routers/external.ts index 54b48c6ef..177626aa2 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -18,6 +18,7 @@ import * as apiKeys from "./apiKeys"; import * as logs from "./auditLogs"; import * as newt from "./newt"; import * as olm from "./olm"; +import * as serverInfo from "./serverInfo"; import HttpCode from "@server/types/HttpCode"; import { verifyAccessTokenAccess, @@ -40,7 +41,8 @@ import { verifyUserHasAction, verifyUserIsOrgOwner, verifySiteResourceAccess, - verifyOlmAccess + verifyOlmAccess, + verifyLimits } from "@server/middlewares"; import { ActionsEnum } from "@server/auth/actions"; import rateLimit, { ipKeyGenerator } from "express-rate-limit"; @@ -48,7 +50,7 @@ import createHttpError from "http-errors"; import { build } from "@server/build"; import { createStore } from "#dynamic/lib/rateLimitStore"; import { logActionAudit } from "#dynamic/middlewares"; -import { log } from "console"; +import { checkRoundTripMessage } from "./ws"; // Root routes export const unauthenticated = Router(); @@ -63,9 +65,8 @@ authenticated.use(verifySessionUserMiddleware); authenticated.get("/pick-org-defaults", org.pickOrgDefaults); authenticated.get("/org/checkId", org.checkId); -if (build === "oss" || build === "enterprise") { - authenticated.put("/org", getUserOrgs, org.createOrg); -} + +authenticated.put("/org", getUserOrgs, org.createOrg); authenticated.get("/orgs", verifyUserIsServerAdmin, org.listOrgs); authenticated.get("/user/:userId/orgs", verifyIsLoggedInUser, org.listUserOrgs); @@ -79,21 +80,20 @@ authenticated.get( authenticated.post( "/org/:orgId", verifyOrgAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.updateOrg), logActionAudit(ActionsEnum.updateOrg), org.updateOrg ); -if (build !== "saas") { - authenticated.delete( - "/org/:orgId", - verifyOrgAccess, - verifyUserIsOrgOwner, - verifyUserHasAction(ActionsEnum.deleteOrg), - logActionAudit(ActionsEnum.deleteOrg), - org.deleteOrg - ); -} +authenticated.delete( + "/org/:orgId", + verifyOrgAccess, + verifyUserIsOrgOwner, + verifyUserHasAction(ActionsEnum.deleteOrg), + logActionAudit(ActionsEnum.deleteOrg), + org.deleteOrg +); authenticated.put( "/org/:orgId/site", @@ -102,6 +102,8 @@ authenticated.put( logActionAudit(ActionsEnum.createSite), site.createSite ); + + authenticated.get( "/org/:orgId/sites", verifyOrgAccess, @@ -143,6 +145,13 @@ authenticated.get( client.listClients ); +authenticated.get( + "/org/:orgId/user-devices", + verifyOrgAccess, + verifyUserHasAction(ActionsEnum.listClients), + client.listUserDevices +); + authenticated.get( "/client/:clientId", verifyClientAccess, @@ -161,6 +170,7 @@ authenticated.get( authenticated.put( "/org/:orgId/client", verifyOrgAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.createClient), logActionAudit(ActionsEnum.createClient), client.createClient @@ -175,9 +185,46 @@ authenticated.delete( client.deleteClient ); +authenticated.post( + "/client/:clientId/archive", + verifyClientAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.archiveClient), + logActionAudit(ActionsEnum.archiveClient), + client.archiveClient +); + +authenticated.post( + "/client/:clientId/unarchive", + verifyClientAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.unarchiveClient), + logActionAudit(ActionsEnum.unarchiveClient), + client.unarchiveClient +); + +authenticated.post( + "/client/:clientId/block", + verifyClientAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.blockClient), + logActionAudit(ActionsEnum.blockClient), + client.blockClient +); + +authenticated.post( + "/client/:clientId/unblock", + verifyClientAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.unblockClient), + logActionAudit(ActionsEnum.unblockClient), + client.unblockClient +); + authenticated.post( "/client/:clientId", verifyClientAccess, // this will check if the user has access to the client + verifyLimits, verifyUserHasAction(ActionsEnum.updateClient), // this will check if the user has permission to update the client logActionAudit(ActionsEnum.updateClient), client.updateClient @@ -192,6 +239,7 @@ authenticated.post( authenticated.post( "/site/:siteId", verifySiteAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.updateSite), logActionAudit(ActionsEnum.updateSite), site.updateSite @@ -239,9 +287,9 @@ authenticated.get( // Site Resource endpoints authenticated.put( - "/org/:orgId/site/:siteId/resource", + "/org/:orgId/site-resource", verifyOrgAccess, - verifySiteAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.createSiteResource), logActionAudit(ActionsEnum.createSiteResource), siteResource.createSiteResource @@ -263,28 +311,23 @@ authenticated.get( ); authenticated.get( - "/org/:orgId/site/:siteId/resource/:siteResourceId", - verifyOrgAccess, - verifySiteAccess, + "/site-resource/:siteResourceId", verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.getSiteResource), siteResource.getSiteResource ); authenticated.post( - "/org/:orgId/site/:siteId/resource/:siteResourceId", - verifyOrgAccess, - verifySiteAccess, + "/site-resource/:siteResourceId", verifySiteResourceAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.updateSiteResource), logActionAudit(ActionsEnum.updateSiteResource), siteResource.updateSiteResource ); authenticated.delete( - "/org/:orgId/site/:siteId/resource/:siteResourceId", - verifyOrgAccess, - verifySiteAccess, + "/site-resource/:siteResourceId", verifySiteResourceAccess, verifyUserHasAction(ActionsEnum.deleteSiteResource), logActionAudit(ActionsEnum.deleteSiteResource), @@ -316,6 +359,7 @@ authenticated.post( "/site-resource/:siteResourceId/roles", verifySiteResourceAccess, verifyRoleAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), siteResource.setSiteResourceRoles @@ -325,6 +369,7 @@ authenticated.post( "/site-resource/:siteResourceId/users", verifySiteResourceAccess, verifySetResourceUsers, + verifyLimits, verifyUserHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.setSiteResourceUsers @@ -334,6 +379,7 @@ authenticated.post( "/site-resource/:siteResourceId/clients", verifySiteResourceAccess, verifySetResourceClients, + verifyLimits, verifyUserHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.setSiteResourceClients @@ -343,6 +389,7 @@ authenticated.post( "/site-resource/:siteResourceId/clients/add", verifySiteResourceAccess, verifySetResourceClients, + verifyLimits, verifyUserHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.addClientToSiteResource @@ -352,6 +399,7 @@ authenticated.post( "/site-resource/:siteResourceId/clients/remove", verifySiteResourceAccess, verifySetResourceClients, + verifyLimits, verifyUserHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.removeClientFromSiteResource @@ -360,6 +408,7 @@ authenticated.post( authenticated.put( "/org/:orgId/resource", verifyOrgAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.createResource), logActionAudit(ActionsEnum.createResource), resource.createResource @@ -474,6 +523,7 @@ authenticated.get( authenticated.post( "/resource/:resourceId", verifyResourceAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.updateResource), logActionAudit(ActionsEnum.updateResource), resource.updateResource @@ -489,6 +539,7 @@ authenticated.delete( authenticated.put( "/resource/:resourceId/target", verifyResourceAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.createTarget), logActionAudit(ActionsEnum.createTarget), target.createTarget @@ -503,6 +554,7 @@ authenticated.get( authenticated.put( "/resource/:resourceId/rule", verifyResourceAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.createResourceRule), logActionAudit(ActionsEnum.createResourceRule), resource.createResourceRule @@ -516,6 +568,7 @@ authenticated.get( authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyResourceAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.updateResourceRule), logActionAudit(ActionsEnum.updateResourceRule), resource.updateResourceRule @@ -537,6 +590,7 @@ authenticated.get( authenticated.post( "/target/:targetId", verifyTargetAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.updateTarget), logActionAudit(ActionsEnum.updateTarget), target.updateTarget @@ -552,6 +606,7 @@ authenticated.delete( authenticated.put( "/org/:orgId/role", verifyOrgAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.createRole), logActionAudit(ActionsEnum.createRole), role.createRole @@ -562,6 +617,15 @@ authenticated.get( verifyUserHasAction(ActionsEnum.listRoles), role.listRoles ); + +authenticated.post( + "/role/:roleId", + verifyRoleAccess, + verifyLimits, + verifyUserHasAction(ActionsEnum.updateRole), + logActionAudit(ActionsEnum.updateRole), + role.updateRole +); // authenticated.get( // "/role/:roleId", // verifyRoleAccess, @@ -582,19 +646,22 @@ authenticated.delete( logActionAudit(ActionsEnum.deleteRole), role.deleteRole ); + authenticated.post( "/role/:roleId/add/:userId", verifyRoleAccess, verifyUserAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole), - user.addUserRole + user.addUserRoleLegacy ); authenticated.post( "/resource/:resourceId/roles", verifyResourceAccess, verifyRoleAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), resource.setResourceRoles @@ -604,6 +671,7 @@ authenticated.post( "/resource/:resourceId/users", verifyResourceAccess, verifySetResourceUsers, + verifyLimits, verifyUserHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), resource.setResourceUsers @@ -612,6 +680,7 @@ authenticated.post( authenticated.post( `/resource/:resourceId/password`, verifyResourceAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.setResourcePassword), logActionAudit(ActionsEnum.setResourcePassword), resource.setResourcePassword @@ -620,6 +689,7 @@ authenticated.post( authenticated.post( `/resource/:resourceId/pincode`, verifyResourceAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.setResourcePincode), logActionAudit(ActionsEnum.setResourcePincode), resource.setResourcePincode @@ -628,6 +698,7 @@ authenticated.post( authenticated.post( `/resource/:resourceId/header-auth`, verifyResourceAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.setResourceHeaderAuth), logActionAudit(ActionsEnum.setResourceHeaderAuth), resource.setResourceHeaderAuth @@ -636,6 +707,7 @@ authenticated.post( authenticated.post( `/resource/:resourceId/whitelist`, verifyResourceAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.setResourceWhitelist), logActionAudit(ActionsEnum.setResourceWhitelist), resource.setResourceWhitelist @@ -651,6 +723,7 @@ authenticated.get( authenticated.post( `/resource/:resourceId/access-token`, verifyResourceAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.generateAccessToken), logActionAudit(ActionsEnum.generateAccessToken), accessToken.generateAccessToken @@ -680,6 +753,8 @@ authenticated.get( authenticated.get(`/org/:orgId/overview`, verifyOrgAccess, org.getOrgOverview); +authenticated.get(`/server-info`, serverInfo.getServerInfo); + authenticated.post( `/supporter-key/validate`, supporterKey.validateSupporterKey @@ -739,6 +814,7 @@ authenticated.delete( authenticated.put( "/org/:orgId/user", verifyOrgAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.createOrgUser), logActionAudit(ActionsEnum.createOrgUser), user.createOrgUser @@ -748,6 +824,7 @@ authenticated.post( "/org/:orgId/user/:userId", verifyOrgAccess, verifyUserAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.updateOrgUser), logActionAudit(ActionsEnum.updateOrgUser), user.updateOrgUser @@ -816,11 +893,19 @@ authenticated.put("/user/:userId/olm", verifyIsLoggedInUser, olm.createUserOlm); authenticated.get("/user/:userId/olms", verifyIsLoggedInUser, olm.listUserOlms); -authenticated.delete( - "/user/:userId/olm/:olmId", +authenticated.post( + "/user/:userId/olm/:olmId/archive", verifyIsLoggedInUser, verifyOlmAccess, - olm.deleteUserOlm + verifyLimits, + olm.archiveUserOlm +); + +authenticated.post( + "/user/:userId/olm/:olmId/unarchive", + verifyIsLoggedInUser, + verifyOlmAccess, + olm.unarchiveUserOlm ); authenticated.get( @@ -830,6 +915,12 @@ authenticated.get( olm.getUserOlm ); +authenticated.post( + "/user/:userId/olm/recover", + verifyIsLoggedInUser, + olm.recoverOlmWithFingerprint +); + authenticated.put( "/idp/oidc", verifyUserIsServerAdmin, @@ -921,6 +1012,7 @@ authenticated.post( `/org/:orgId/api-key/:apiKeyId/actions`, verifyOrgAccess, verifyApiKeyAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.setApiKeyActions), logActionAudit(ActionsEnum.setApiKeyActions), apiKeys.setApiKeyActions @@ -937,6 +1029,7 @@ authenticated.get( authenticated.put( `/org/:orgId/api-key`, verifyOrgAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.createApiKey), logActionAudit(ActionsEnum.createApiKey), apiKeys.createOrgApiKey @@ -962,6 +1055,7 @@ authenticated.get( authenticated.put( `/org/:orgId/domain`, verifyOrgAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.createOrgDomain), logActionAudit(ActionsEnum.createOrgDomain), domain.createOrgDomain @@ -971,6 +1065,7 @@ authenticated.post( `/org/:orgId/domain/:domainId/restart`, verifyOrgAccess, verifyDomainAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.restartOrgDomain), logActionAudit(ActionsEnum.restartOrgDomain), domain.restartOrgDomain @@ -1017,6 +1112,7 @@ authenticated.get( authenticated.put( "/org/:orgId/blueprint", verifyOrgAccess, + verifyLimits, verifyUserHasAction(ActionsEnum.applyBlueprint), blueprints.applyYAMLBlueprint ); @@ -1028,6 +1124,8 @@ authenticated.get( blueprints.getBlueprint ); +authenticated.get("/ws/round-trip-message/:messageId", checkRoundTripMessage); + // Auth routes export const authRouter = Router(); unauthenticated.use("/auth", authRouter); @@ -1076,6 +1174,22 @@ authRouter.post( auth.login ); authRouter.post("/logout", auth.logout); +authRouter.post("/delete-my-account", auth.deleteMyAccount); +authRouter.post( + "/lookup-user", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 15, + keyGenerator: (req) => + `lookupUser:${req.body.identifier || ipKeyGenerator(req.ip || "")}`, + handler: (req, res, next) => { + const message = `You can only lookup users ${15} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + auth.lookupUser +); authRouter.post( "/newt/get-token", rateLimit({ @@ -1091,6 +1205,22 @@ authRouter.post( }), newt.getNewtToken ); + +authRouter.post( + "/newt/register", + rateLimit({ + windowMs: 15 * 60 * 1000, + max: 30, + keyGenerator: (req) => + `newtRegister:${req.body.provisioningKey?.split(".")[0] || ipKeyGenerator(req.ip || "")}`, + handler: (req, res, next) => { + const message = `You can only register a newt ${30} times every ${15} minutes. Please try again later.`; + return next(createHttpError(HttpCode.TOO_MANY_REQUESTS, message)); + }, + store: createStore() + }), + newt.registerNewt +); authRouter.post( "/olm/get-token", rateLimit({ diff --git a/server/routers/generatedLicense/types.ts b/server/routers/generatedLicense/types.ts index 76e86265a..d78f23326 100644 --- a/server/routers/generatedLicense/types.ts +++ b/server/routers/generatedLicense/types.ts @@ -6,6 +6,8 @@ export type GeneratedLicenseKey = { createdAt: string; tier: string; type: string; + users: number; + sites: number; }; export type ListGeneratedLicenseKeysResponse = GeneratedLicenseKey[]; @@ -19,6 +21,7 @@ export type NewLicenseKey = { tier: string; type: string; quantity: number; + quantity_2: number; isValid: boolean; updatedAt: string; createdAt: string; diff --git a/server/routers/gerbil/getAllRelays.ts b/server/routers/gerbil/getAllRelays.ts index b7d33b955..bbe314b2a 100644 --- a/server/routers/gerbil/getAllRelays.ts +++ b/server/routers/gerbil/getAllRelays.ts @@ -125,7 +125,7 @@ export async function generateRelayMappings(exitNode: ExitNode) { // Add site as a destination for this client const destination: PeerDestination = { destinationIP: site.subnet.split("/")[0], - destinationPort: site.listenPort + destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated }; // Check if this destination is already in the array to avoid duplicates @@ -165,7 +165,7 @@ export async function generateRelayMappings(exitNode: ExitNode) { const destination: PeerDestination = { destinationIP: peer.subnet.split("/")[0], - destinationPort: peer.listenPort + destinationPort: peer.listenPort || 1 // this satisfies gerbil for now but should be reevaluated }; // Check for duplicates diff --git a/server/routers/gerbil/getConfig.ts b/server/routers/gerbil/getConfig.ts index ba3ab7ad8..1557edce9 100644 --- a/server/routers/gerbil/getConfig.ts +++ b/server/routers/gerbil/getConfig.ts @@ -51,7 +51,10 @@ export async function getConfig( ); } - const exitNode = await createExitNode(publicKey, reachableAt); + // clean up the public key - keep only valid base64 characters (A-Z, a-z, 0-9, +, /, =) + const cleanedPublicKey = publicKey.replace(/[^A-Za-z0-9+/=]/g, ""); + + const exitNode = await createExitNode(cleanedPublicKey, reachableAt); if (!exitNode) { return next( diff --git a/server/routers/gerbil/receiveBandwidth.ts b/server/routers/gerbil/receiveBandwidth.ts index 5c9cacb25..787b7b702 100644 --- a/server/routers/gerbil/receiveBandwidth.ts +++ b/server/routers/gerbil/receiveBandwidth.ts @@ -1,6 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { eq, and, lt, inArray, sql } from "drizzle-orm"; -import { sites } from "@server/db"; +import { sql } from "drizzle-orm"; import { db } from "@server/db"; import logger from "@server/logger"; import createHttpError from "http-errors"; @@ -11,19 +10,34 @@ import { FeatureId } from "@server/lib/billing/features"; import { checkExitNodeOrg } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; -// Track sites that are already offline to avoid unnecessary queries -const offlineSites = new Set(); - -// Retry configuration for deadlock handling -const MAX_RETRIES = 3; -const BASE_DELAY_MS = 50; - interface PeerBandwidth { publicKey: string; bytesIn: number; bytesOut: number; } +interface AccumulatorEntry { + bytesIn: number; + bytesOut: number; + /** Present when the update came through a remote exit node. */ + exitNodeId?: number; + /** Whether to record egress usage for billing purposes. */ + calcUsage: boolean; +} + +// Retry configuration for deadlock handling +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 50; + +// How often to flush accumulated bandwidth data to the database +const FLUSH_INTERVAL_MS = 300_000; // 300 seconds + +// Maximum number of sites to include in a single batch UPDATE statement +const BATCH_CHUNK_SIZE = 250; + +// In-memory accumulator: publicKey -> AccumulatorEntry +let accumulator = new Map(); + /** * Check if an error is a deadlock error */ @@ -63,6 +77,266 @@ async function withDeadlockRetry( } } +/** + * Execute a raw SQL query that returns rows, in a way that works across both + * the PostgreSQL driver (which exposes `execute`) and the SQLite driver (which + * exposes `all`). Drizzle's typed query builder doesn't support bulk + * UPDATE … FROM (VALUES …) natively, so we drop to raw SQL here. + */ +async function dbQueryRows>( + query: Parameters<(typeof sql)["join"]>[0][number] +): Promise { + const anyDb = db as any; + if (typeof anyDb.execute === "function") { + // PostgreSQL (node-postgres via Drizzle) — returns { rows: [...] } or an array + const result = await anyDb.execute(query); + return (Array.isArray(result) ? result : (result.rows ?? [])) as T[]; + } + // SQLite (better-sqlite3 via Drizzle) — returns an array directly + return (await anyDb.all(query)) as T[]; +} + +/** + * Returns true when the active database driver is SQLite (better-sqlite3). + * Used to select the appropriate bulk-update strategy. + */ +function isSQLite(): boolean { + return typeof (db as any).execute !== "function"; +} + +/** + * Flush all accumulated site bandwidth data to the database. + * + * Swaps out the accumulator before writing so that any bandwidth messages + * received during the flush are captured in the new accumulator rather than + * being lost or causing contention. Sites are updated in chunks via a single + * batch UPDATE per chunk. Failed chunks are discarded — exact per-flush + * accuracy is not critical and re-queuing is not worth the added complexity. + * + * This function is exported so that the application's graceful-shutdown + * cleanup handler can call it before the process exits. + */ +export async function flushSiteBandwidthToDb(): Promise { + if (accumulator.size === 0) { + return; + } + + // Atomically swap out the accumulator so new data keeps flowing in + // while we write the snapshot to the database. + const snapshot = accumulator; + accumulator = new Map(); + + const currentTime = new Date().toISOString(); + + // Sort by publicKey for consistent lock ordering across concurrent + // writers — deadlock-prevention strategy. + const sortedEntries = [...snapshot.entries()].sort(([a], [b]) => + a.localeCompare(b) + ); + + logger.debug( + `Flushing accumulated bandwidth data for ${sortedEntries.length} site(s) to the database` + ); + + // Build a lookup so post-processing can reach each entry by publicKey. + const snapshotMap = new Map(sortedEntries); + + // Aggregate billing usage by org across all chunks. + const orgUsageMap = new Map(); + + // Process in chunks so individual queries stay at a reasonable size. + for (let i = 0; i < sortedEntries.length; i += BATCH_CHUNK_SIZE) { + const chunk = sortedEntries.slice(i, i + BATCH_CHUNK_SIZE); + const chunkEnd = i + chunk.length - 1; + + let rows: { orgId: string; pubKey: string }[] = []; + + try { + rows = await withDeadlockRetry(async () => { + if (isSQLite()) { + // SQLite: one UPDATE per row — no need for batch efficiency here. + const results: { orgId: string; pubKey: string }[] = []; + for (const [publicKey, { bytesIn, bytesOut }] of chunk) { + const result = await dbQueryRows<{ + orgId: string; + pubKey: string; + }>(sql` + UPDATE sites + SET + "bytesOut" = COALESCE("bytesOut", 0) + ${bytesIn}, + "bytesIn" = COALESCE("bytesIn", 0) + ${bytesOut}, + "lastBandwidthUpdate" = ${currentTime} + WHERE "pubKey" = ${publicKey} + RETURNING "orgId", "pubKey" + `); + results.push(...result); + } + return results; + } + + // PostgreSQL: batch UPDATE … FROM (VALUES …) — single round-trip per chunk. + const valuesList = chunk.map( + ([publicKey, { bytesIn, bytesOut }]) => + sql`(${publicKey}, ${bytesIn}, ${bytesOut})` + ); + const valuesClause = sql.join(valuesList, sql`, `); + return dbQueryRows<{ orgId: string; pubKey: string }>(sql` + UPDATE sites + SET + "bytesOut" = COALESCE("bytesOut", 0) + v.bytes_in, + "bytesIn" = COALESCE("bytesIn", 0) + v.bytes_out, + "lastBandwidthUpdate" = ${currentTime} + FROM (VALUES ${valuesClause}) AS v(pub_key, bytes_in, bytes_out) + WHERE sites."pubKey" = v.pub_key + RETURNING sites."orgId" AS "orgId", sites."pubKey" AS "pubKey" + `); + }, `flush bandwidth chunk [${i}–${chunkEnd}]`); + } catch (error) { + logger.error( + `Failed to flush bandwidth chunk [${i}–${chunkEnd}], discarding ${chunk.length} site(s):`, + error + ); + // Discard the chunk — exact per-flush accuracy is not critical. + continue; + } + + // Collect billing usage from the returned rows. + for (const { orgId, pubKey } of rows) { + const entry = snapshotMap.get(pubKey); + if (!entry) continue; + + const { bytesIn, bytesOut, exitNodeId, calcUsage } = entry; + + if (exitNodeId) { + const notAllowed = await checkExitNodeOrg(exitNodeId, orgId); + if (notAllowed) { + logger.warn( + `Exit node ${exitNodeId} is not allowed for org ${orgId}` + ); + continue; + } + } + + if (calcUsage) { + const current = orgUsageMap.get(orgId) ?? 0; + orgUsageMap.set(orgId, current + bytesIn + bytesOut); + } + } + } + + // Process billing usage updates after all chunks are written. + if (orgUsageMap.size > 0) { + const sortedOrgIds = [...orgUsageMap.keys()].sort(); + + for (const orgId of sortedOrgIds) { + try { + const totalBandwidth = orgUsageMap.get(orgId)!; + const bandwidthUsage = await usageService.add( + orgId, + FeatureId.EGRESS_DATA_MB, + totalBandwidth + ); + if (bandwidthUsage) { + // Fire-and-forget — don't block the flush on limit checking. + usageService + .checkLimitSet( + orgId, + FeatureId.EGRESS_DATA_MB, + bandwidthUsage + ) + .catch((error: any) => { + logger.error( + `Error checking bandwidth limits for org ${orgId}:`, + error + ); + }); + } + } catch (error) { + logger.error( + `Error processing usage for org ${orgId}:`, + error + ); + // Continue with other orgs. + } + } + } +} + +// --------------------------------------------------------------------------- +// Periodic flush timer +// --------------------------------------------------------------------------- + +const flushTimer = setInterval(async () => { + try { + await flushSiteBandwidthToDb(); + } catch (error) { + logger.error( + "Unexpected error during periodic site bandwidth flush:", + error + ); + } +}, FLUSH_INTERVAL_MS); + +// Allow the process to exit normally even while the timer is pending. +// The graceful-shutdown path (see server/cleanup.ts) will call +// flushSiteBandwidthToDb() explicitly before process.exit(), so no data +// is lost. +flushTimer.unref(); + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Accumulate bandwidth data reported by a gerbil or remote exit node. + * + * Only peers that actually transferred data (bytesIn > 0) are added to the + * accumulator; peers with no activity are silently ignored, which means the + * flush will only write rows that have genuinely changed. + * + * The function is intentionally synchronous in its fast path so that the + * HTTP handler can respond immediately without waiting for any I/O. + */ +export async function updateSiteBandwidth( + bandwidthData: PeerBandwidth[], + calcUsageAndLimits: boolean, + exitNodeId?: number +): Promise { + for (const { publicKey, bytesIn, bytesOut } of bandwidthData) { + // Skip peers that haven't transferred any data — writing zeros to the + // database would be a no-op anyway. + if (bytesIn <= 0 && bytesOut <= 0) { + continue; + } + + const existing = accumulator.get(publicKey); + if (existing) { + existing.bytesIn += bytesIn; + existing.bytesOut += bytesOut; + // Retain the most-recent exitNodeId for this peer. + if (exitNodeId !== undefined) { + existing.exitNodeId = exitNodeId; + } + // Once calcUsage has been requested for a peer, keep it set for + // the lifetime of this flush window. + if (calcUsageAndLimits) { + existing.calcUsage = true; + } + } else { + accumulator.set(publicKey, { + bytesIn, + bytesOut, + exitNodeId, + calcUsage: calcUsageAndLimits + }); + } + } +} + +// --------------------------------------------------------------------------- +// HTTP handler +// --------------------------------------------------------------------------- + export const receiveBandwidth = async ( req: Request, res: Response, @@ -75,7 +349,9 @@ export const receiveBandwidth = async ( throw new Error("Invalid bandwidth data"); } - await updateSiteBandwidth(bandwidthData, build == "saas"); // we are checking the usage on saas only + // Accumulate in memory; the periodic timer (and the shutdown hook) + // will write to the database. + await updateSiteBandwidth(bandwidthData, build == "saas"); return response(res, { data: {}, @@ -94,239 +370,3 @@ export const receiveBandwidth = async ( ); } }; - -export async function updateSiteBandwidth( - bandwidthData: PeerBandwidth[], - calcUsageAndLimits: boolean, - exitNodeId?: number -) { - const currentTime = new Date(); - const oneMinuteAgo = new Date(currentTime.getTime() - 60000); // 1 minute ago - - // Sort bandwidth data by publicKey to ensure consistent lock ordering across all instances - // This is critical for preventing deadlocks when multiple instances update the same sites - const sortedBandwidthData = [...bandwidthData].sort((a, b) => - a.publicKey.localeCompare(b.publicKey) - ); - - // First, handle sites that are actively reporting bandwidth - const activePeers = sortedBandwidthData.filter((peer) => peer.bytesIn > 0); - - // Aggregate usage data by organization (collected outside transaction) - const orgUsageMap = new Map(); - const orgUptimeMap = new Map(); - - if (activePeers.length > 0) { - // Remove any active peers from offline tracking since they're sending data - activePeers.forEach((peer) => offlineSites.delete(peer.publicKey)); - - // Update each active site individually with retry logic - // This reduces transaction scope and allows retries per-site - for (const peer of activePeers) { - try { - const updatedSite = await withDeadlockRetry(async () => { - const [result] = await db - .update(sites) - .set({ - megabytesOut: sql`${sites.megabytesOut} + ${peer.bytesIn}`, - megabytesIn: sql`${sites.megabytesIn} + ${peer.bytesOut}`, - lastBandwidthUpdate: currentTime.toISOString(), - online: true - }) - .where(eq(sites.pubKey, peer.publicKey)) - .returning({ - online: sites.online, - orgId: sites.orgId, - siteId: sites.siteId, - lastBandwidthUpdate: sites.lastBandwidthUpdate - }); - return result; - }, `update active site ${peer.publicKey}`); - - if (updatedSite) { - if (exitNodeId) { - const notAllowed = await checkExitNodeOrg( - exitNodeId, - updatedSite.orgId - ); - if (notAllowed) { - logger.warn( - `Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}` - ); - // Skip this site but continue processing others - continue; - } - } - - // Aggregate bandwidth usage for the org - const totalBandwidth = peer.bytesIn + peer.bytesOut; - const currentOrgUsage = - orgUsageMap.get(updatedSite.orgId) || 0; - orgUsageMap.set( - updatedSite.orgId, - currentOrgUsage + totalBandwidth - ); - - // Add 10 seconds of uptime for each active site - const currentOrgUptime = - orgUptimeMap.get(updatedSite.orgId) || 0; - orgUptimeMap.set( - updatedSite.orgId, - currentOrgUptime + 10 / 60 - ); - } - } catch (error) { - logger.error( - `Failed to update bandwidth for site ${peer.publicKey}:`, - error - ); - // Continue with other sites - } - } - } - - // Process usage updates outside of site update transactions - // This separates the concerns and reduces lock contention - if (calcUsageAndLimits && (orgUsageMap.size > 0 || orgUptimeMap.size > 0)) { - // Sort org IDs to ensure consistent lock ordering - const allOrgIds = [ - ...new Set([...orgUsageMap.keys(), ...orgUptimeMap.keys()]) - ].sort(); - - for (const orgId of allOrgIds) { - try { - // Process bandwidth usage for this org - const totalBandwidth = orgUsageMap.get(orgId); - if (totalBandwidth) { - const bandwidthUsage = await usageService.add( - orgId, - FeatureId.EGRESS_DATA_MB, - totalBandwidth - ); - if (bandwidthUsage) { - // Fire and forget - don't block on limit checking - usageService - .checkLimitSet( - orgId, - true, - FeatureId.EGRESS_DATA_MB, - bandwidthUsage - ) - .catch((error: any) => { - logger.error( - `Error checking bandwidth limits for org ${orgId}:`, - error - ); - }); - } - } - - // Process uptime usage for this org - const totalUptime = orgUptimeMap.get(orgId); - if (totalUptime) { - const uptimeUsage = await usageService.add( - orgId, - FeatureId.SITE_UPTIME, - totalUptime - ); - if (uptimeUsage) { - // Fire and forget - don't block on limit checking - usageService - .checkLimitSet( - orgId, - true, - FeatureId.SITE_UPTIME, - uptimeUsage - ) - .catch((error: any) => { - logger.error( - `Error checking uptime limits for org ${orgId}:`, - error - ); - }); - } - } - } catch (error) { - logger.error(`Error processing usage for org ${orgId}:`, error); - // Continue with other orgs - } - } - } - - // Handle sites that reported zero bandwidth but need online status updated - const zeroBandwidthPeers = sortedBandwidthData.filter( - (peer) => peer.bytesIn === 0 && !offlineSites.has(peer.publicKey) - ); - - if (zeroBandwidthPeers.length > 0) { - // Fetch all zero bandwidth sites in one query - const zeroBandwidthSites = await db - .select() - .from(sites) - .where( - inArray( - sites.pubKey, - zeroBandwidthPeers.map((p) => p.publicKey) - ) - ); - - // Sort by siteId to ensure consistent lock ordering - const sortedZeroBandwidthSites = zeroBandwidthSites.sort( - (a, b) => a.siteId - b.siteId - ); - - for (const site of sortedZeroBandwidthSites) { - let newOnlineStatus = site.online; - - // Check if site should go offline based on last bandwidth update WITH DATA - if (site.lastBandwidthUpdate) { - const lastUpdateWithData = new Date(site.lastBandwidthUpdate); - if (lastUpdateWithData < oneMinuteAgo) { - newOnlineStatus = false; - } - } else { - // No previous data update recorded, set to offline - newOnlineStatus = false; - } - - // Only update online status if it changed - if (site.online !== newOnlineStatus) { - try { - const updatedSite = await withDeadlockRetry(async () => { - const [result] = await db - .update(sites) - .set({ - online: newOnlineStatus - }) - .where(eq(sites.siteId, site.siteId)) - .returning(); - return result; - }, `update offline status for site ${site.siteId}`); - - if (updatedSite && exitNodeId) { - const notAllowed = await checkExitNodeOrg( - exitNodeId, - updatedSite.orgId - ); - if (notAllowed) { - logger.warn( - `Exit node ${exitNodeId} is not allowed for org ${updatedSite.orgId}` - ); - } - } - - // If site went offline, add it to our tracking set - if (!newOnlineStatus && site.pubKey) { - offlineSites.add(site.pubKey); - } - } catch (error) { - logger.error( - `Failed to update offline status for site ${site.siteId}:`, - error - ); - // Continue with other sites - } - } - } - } -} diff --git a/server/routers/gerbil/updateHolePunch.ts b/server/routers/gerbil/updateHolePunch.ts index 3f24430bf..810c44ff7 100644 --- a/server/routers/gerbil/updateHolePunch.ts +++ b/server/routers/gerbil/updateHolePunch.ts @@ -112,7 +112,7 @@ export async function updateHolePunch( destinations: destinations }); } catch (error) { - // logger.error(error); // FIX THIS + logger.error(error); return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, @@ -262,7 +262,7 @@ export async function updateAndGenerateEndpointDestinations( if (site.subnet && site.listenPort) { destinations.push({ destinationIP: site.subnet.split("/")[0], - destinationPort: site.listenPort + destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated }); } } @@ -339,10 +339,10 @@ export async function updateAndGenerateEndpointDestinations( handleSiteEndpointChange(newt.siteId, updatedSite.endpoint!); } - if (!updatedSite || !updatedSite.subnet) { - logger.warn(`Site not found: ${newt.siteId}`); - throw new Error("Site not found"); - } + // if (!updatedSite || !updatedSite.subnet) { + // logger.warn(`Site not found: ${newt.siteId}`); + // throw new Error("Site not found"); + // } // Find all clients that connect to this site // const sitesClientPairs = await db diff --git a/server/routers/idp/createIdpOrgPolicy.ts b/server/routers/idp/createIdpOrgPolicy.ts index b9a0098b5..da12bc12a 100644 --- a/server/routers/idp/createIdpOrgPolicy.ts +++ b/server/routers/idp/createIdpOrgPolicy.ts @@ -27,7 +27,7 @@ registry.registerPath({ method: "put", path: "/idp/{idpId}/org/{orgId}", description: "Create an IDP policy for an existing IDP on an organization.", - tags: [OpenAPITags.Idp], + tags: [OpenAPITags.GlobalIdp], request: { params: paramsSchema, body: { @@ -70,6 +70,15 @@ export async function createIdpOrgPolicy( const { idpId, orgId } = parsedParams.data; const { roleMapping, orgMapping } = parsedBody.data; + if (process.env.IDENTITY_PROVIDER_MODE === "org") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature." + ) + ); + } + const [existing] = await db .select() .from(idp) diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index c7eeaf305..0f0cc0cce 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -24,7 +24,9 @@ const bodySchema = z.strictObject({ emailPath: z.string().optional(), namePath: z.string().optional(), scopes: z.string().nonempty(), - autoProvision: z.boolean().optional() + autoProvision: z.boolean().optional(), + tags: z.string().optional(), + variant: z.enum(["oidc", "google", "azure"]).optional().default("oidc") }); export type CreateIdpResponse = { @@ -36,7 +38,7 @@ registry.registerPath({ method: "put", path: "/idp/oidc", description: "Create an OIDC IdP.", - tags: [OpenAPITags.Idp], + tags: [OpenAPITags.GlobalIdp], request: { body: { content: { @@ -75,9 +77,22 @@ export async function createOidcIdp( emailPath, namePath, name, - autoProvision + autoProvision, + tags, + variant } = parsedBody.data; + if ( + process.env.IDENTITY_PROVIDER_MODE === "org" + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature." + ) + ); + } + const key = config.getRawConfig().server.secret!; const encryptedSecret = encrypt(clientSecret, key); @@ -90,7 +105,10 @@ export async function createOidcIdp( .values({ name, autoProvision, - type: "oidc" + type: "oidc", + tags, + defaultOrgMapping: `'{{orgId}}'`, + defaultRoleMapping: `'Member'` }) .returning(); @@ -105,7 +123,8 @@ export async function createOidcIdp( scopes, identifierPath, emailPath, - namePath + namePath, + variant }); }); diff --git a/server/routers/idp/deleteIdp.ts b/server/routers/idp/deleteIdp.ts index f2b550993..8c15eada1 100644 --- a/server/routers/idp/deleteIdp.ts +++ b/server/routers/idp/deleteIdp.ts @@ -21,7 +21,7 @@ registry.registerPath({ method: "delete", path: "/idp/{idpId}", description: "Delete IDP.", - tags: [OpenAPITags.Idp], + tags: [OpenAPITags.GlobalIdp], request: { params: paramsSchema }, diff --git a/server/routers/idp/deleteIdpOrgPolicy.ts b/server/routers/idp/deleteIdpOrgPolicy.ts index b52a37df2..6793474a8 100644 --- a/server/routers/idp/deleteIdpOrgPolicy.ts +++ b/server/routers/idp/deleteIdpOrgPolicy.ts @@ -19,7 +19,7 @@ registry.registerPath({ method: "delete", path: "/idp/{idpId}/org/{orgId}", description: "Create an OIDC IdP for an organization.", - tags: [OpenAPITags.Idp], + tags: [OpenAPITags.GlobalIdp], request: { params: paramsSchema }, diff --git a/server/routers/idp/generateOidcUrl.ts b/server/routers/idp/generateOidcUrl.ts index 50b63ee52..646dc2c4a 100644 --- a/server/routers/idp/generateOidcUrl.ts +++ b/server/routers/idp/generateOidcUrl.ts @@ -14,8 +14,8 @@ import jsonwebtoken from "jsonwebtoken"; import config from "@server/lib/config"; import { decrypt } from "@server/lib/crypto"; import { build } from "@server/build"; -import { getOrgTierData } from "#dynamic/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; +import { isSubscribed } from "#dynamic/lib/isSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const paramsSchema = z .object({ @@ -113,8 +113,10 @@ export async function generateOidcUrl( } if (build === "saas") { - const { tier } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; + const subscribed = await isSubscribed( + orgId, + tierMatrix.orgOidc + ); if (!subscribed) { return next( createHttpError( diff --git a/server/routers/idp/getIdp.ts b/server/routers/idp/getIdp.ts index 072537513..db199f2d6 100644 --- a/server/routers/idp/getIdp.ts +++ b/server/routers/idp/getIdp.ts @@ -34,7 +34,7 @@ registry.registerPath({ method: "get", path: "/idp/{idpId}", description: "Get an IDP by its IDP ID.", - tags: [OpenAPITags.Idp], + tags: [OpenAPITags.GlobalIdp], request: { params: paramsSchema }, diff --git a/server/routers/idp/listIdpOrgPolicies.ts b/server/routers/idp/listIdpOrgPolicies.ts index 9f7cdb42b..ecfd6f33a 100644 --- a/server/routers/idp/listIdpOrgPolicies.ts +++ b/server/routers/idp/listIdpOrgPolicies.ts @@ -48,7 +48,7 @@ registry.registerPath({ method: "get", path: "/idp/{idpId}/org", description: "List all org policies on an IDP.", - tags: [OpenAPITags.Idp], + tags: [OpenAPITags.GlobalIdp], request: { params: paramsSchema, query: querySchema diff --git a/server/routers/idp/listIdps.ts b/server/routers/idp/listIdps.ts index 20d1899ea..ca0fd5fbf 100644 --- a/server/routers/idp/listIdps.ts +++ b/server/routers/idp/listIdps.ts @@ -33,7 +33,8 @@ async function query(limit: number, offset: number) { type: idp.type, variant: idpOidcConfig.variant, orgCount: sql`count(${idpOrg.orgId})`, - autoProvision: idp.autoProvision + autoProvision: idp.autoProvision, + tags: idp.tags }) .from(idp) .leftJoin(idpOrg, sql`${idp.idpId} = ${idpOrg.idpId}`) @@ -57,7 +58,7 @@ registry.registerPath({ method: "get", path: "/idp", description: "List all IDP in the system.", - tags: [OpenAPITags.Idp], + tags: [OpenAPITags.GlobalIdp], request: { query: querySchema }, diff --git a/server/routers/idp/updateIdpOrgPolicy.ts b/server/routers/idp/updateIdpOrgPolicy.ts index 6432faf69..5a9f882d4 100644 --- a/server/routers/idp/updateIdpOrgPolicy.ts +++ b/server/routers/idp/updateIdpOrgPolicy.ts @@ -26,7 +26,7 @@ registry.registerPath({ method: "post", path: "/idp/{idpId}/org/{orgId}", description: "Update an IDP org policy.", - tags: [OpenAPITags.Idp], + tags: [OpenAPITags.GlobalIdp], request: { params: paramsSchema, body: { @@ -69,6 +69,15 @@ export async function updateIdpOrgPolicy( const { idpId, orgId } = parsedParams.data; const { roleMapping, orgMapping } = parsedBody.data; + if (process.env.IDENTITY_PROVIDER_MODE === "org") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature." + ) + ); + } + // Check if IDP and policy exist const [existing] = await db .select() diff --git a/server/routers/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts index a4d55187f..905b32013 100644 --- a/server/routers/idp/updateOidcIdp.ts +++ b/server/routers/idp/updateOidcIdp.ts @@ -30,7 +30,9 @@ const bodySchema = z.strictObject({ scopes: z.string().optional(), autoProvision: z.boolean().optional(), defaultRoleMapping: z.string().optional(), - defaultOrgMapping: z.string().optional() + defaultOrgMapping: z.string().optional(), + tags: z.string().optional(), + variant: z.enum(["oidc", "google", "azure"]).optional() }); export type UpdateIdpResponse = { @@ -41,7 +43,7 @@ registry.registerPath({ method: "post", path: "/idp/{idpId}/oidc", description: "Update an OIDC IdP.", - tags: [OpenAPITags.Idp], + tags: [OpenAPITags.GlobalIdp], request: { params: paramsSchema, body: { @@ -94,9 +96,20 @@ export async function updateOidcIdp( name, autoProvision, defaultRoleMapping, - defaultOrgMapping + defaultOrgMapping, + tags, + variant } = parsedBody.data; + if (process.env.IDENTITY_PROVIDER_MODE === "org") { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Global IdP creation is not allowed in the current identity provider mode. Set app.identity_provider_mode to 'global' in the private configuration to enable this feature." + ) + ); + } + // Check if IDP exists and is of type OIDC const [existingIdp] = await db .select() @@ -127,7 +140,8 @@ export async function updateOidcIdp( name, autoProvision, defaultRoleMapping, - defaultOrgMapping + defaultOrgMapping, + tags }; // only update if at least one key is not undefined @@ -147,7 +161,8 @@ export async function updateOidcIdp( scopes, identifierPath, emailPath, - namePath + namePath, + variant }; keysToUpdate = Object.keys(configData).filter( diff --git a/server/routers/idp/validateOidcCallback.ts b/server/routers/idp/validateOidcCallback.ts index f6b21ff64..7c9e53cf2 100644 --- a/server/routers/idp/validateOidcCallback.ts +++ b/server/routers/idp/validateOidcCallback.ts @@ -13,6 +13,7 @@ import { orgs, Role, roles, + userOrgRoles, userOrgs, users } from "@server/db"; @@ -34,6 +35,14 @@ import { FeatureId } from "@server/lib/billing"; import { usageService } from "@server/lib/billing/usageService"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; +import { isSubscribed } from "#dynamic/lib/isSubscribed"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + assignUserToOrg, + removeUserFromOrg +} from "@server/lib/userOrg"; +import { unwrapRoleMapping } from "@app/lib/idpRoleMapping"; const ensureTrailingSlash = (url: string): string => { return url; @@ -192,11 +201,71 @@ export async function validateOidcCallback( state }); - const tokens = await client.validateAuthorizationCode( - ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl), - code, - codeVerifier - ); + let tokens: arctic.OAuth2Tokens; + try { + tokens = await client.validateAuthorizationCode( + ensureTrailingSlash(existingIdp.idpOidcConfig.tokenUrl), + code, + codeVerifier + ); + } catch (err: unknown) { + if (err instanceof arctic.OAuth2RequestError) { + logger.warn("OIDC provider rejected the authorization code", { + error: err.code, + description: err.description, + uri: err.uri, + state: err.state + }); + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + err.description || + `OIDC provider rejected the request (${err.code})` + ) + ); + } + + if (err instanceof arctic.UnexpectedResponseError) { + logger.error( + "OIDC provider returned an unexpected response during token exchange", + { status: err.status } + ); + return next( + createHttpError( + HttpCode.BAD_GATEWAY, + "Received an unexpected response from the identity provider while exchanging the authorization code." + ) + ); + } + + if (err instanceof arctic.UnexpectedErrorResponseBodyError) { + logger.error( + "OIDC provider returned an unexpected error payload during token exchange", + { status: err.status, data: err.data } + ); + return next( + createHttpError( + HttpCode.BAD_GATEWAY, + "Identity provider returned an unexpected error payload while exchanging the authorization code." + ) + ); + } + + if (err instanceof arctic.ArcticFetchError) { + logger.error( + "Failed to reach OIDC provider while exchanging authorization code", + { error: err.message } + ); + return next( + createHttpError( + HttpCode.BAD_GATEWAY, + "Unable to reach the identity provider while exchanging the authorization code. Please try again." + ) + ); + } + + throw err; + } const idToken = tokens.idToken(); logger.debug("ID token", { idToken }); @@ -266,6 +335,33 @@ export async function validateOidcCallback( .where(eq(idpOrg.idpId, existingIdp.idp.idpId)) .innerJoin(orgs, eq(orgs.orgId, idpOrg.orgId)); allOrgs = idpOrgs.map((o) => o.orgs); + + // TODO: when there are multiple orgs we need to do this better!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!1 + if (allOrgs.length > 1) { + // for some reason there is more than one org + logger.error( + "More than one organization linked to this IdP. This should not happen with auto-provisioning enabled." + ); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Multiple organizations linked to this IdP. Please contact support." + ) + ); + } + + const subscribed = await isSubscribed( + allOrgs[0].orgId, + tierMatrix.autoProvisioning + ); + if (!subscribed) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "This organization's current plan does not support this feature." + ) + ); + } } else { allOrgs = await db.select().from(orgs); } @@ -273,7 +369,7 @@ export async function validateOidcCallback( const defaultRoleMapping = existingIdp.idp.defaultRoleMapping; const defaultOrgMapping = existingIdp.idp.defaultOrgMapping; - const userOrgInfo: { orgId: string; roleId: number }[] = []; + const userOrgInfo: { orgId: string; roleIds: number[] }[] = []; for (const org of allOrgs) { const [idpOrgRes] = await db .select() @@ -285,8 +381,6 @@ export async function validateOidcCallback( ) ); - let roleId: number | undefined = undefined; - const orgMapping = idpOrgRes?.orgMapping || defaultOrgMapping; const hydratedOrgMapping = hydrateOrgMapping( orgMapping, @@ -311,42 +405,60 @@ export async function validateOidcCallback( idpOrgRes?.roleMapping || defaultRoleMapping; if (roleMapping) { logger.debug("Role Mapping", { roleMapping }); - const roleName = jmespath.search(claims, roleMapping); + const roleMappingJmes = unwrapRoleMapping( + roleMapping + ).evaluationExpression; + const roleMappingResult = jmespath.search( + claims, + roleMappingJmes + ); + const roleNames = normalizeRoleMappingResult( + roleMappingResult + ); - if (!roleName) { - logger.error("Role name not found in the ID token", { - roleName + const supportsMultiRole = await isLicensedOrSubscribed( + org.orgId, + tierMatrix.fullRbac + ); + const effectiveRoleNames = supportsMultiRole + ? roleNames + : roleNames.slice(0, 1); + + if (!effectiveRoleNames.length) { + logger.error("Role mapping returned no valid roles", { + roleMappingResult }); continue; } - const [roleRes] = await db + const roleRes = await db .select() .from(roles) .where( and( eq(roles.orgId, org.orgId), - eq(roles.name, roleName) + inArray(roles.name, effectiveRoleNames) ) ); - if (!roleRes) { - logger.error("Role not found", { + if (!roleRes.length) { + logger.error("No mapped roles found in organization", { orgId: org.orgId, - roleName + roleNames: effectiveRoleNames }); continue; } - roleId = roleRes.roleId; + const roleIds = [...new Set(roleRes.map((r) => r.roleId))]; userOrgInfo.push({ orgId: org.orgId, - roleId + roleIds }); } } + // These are the orgs that the user should be provisioned into based on the IdP mappings and the token claims logger.debug("User org info", { userOrgInfo }); let existingUserId = existingUser?.userId; @@ -365,15 +477,32 @@ export async function validateOidcCallback( ); if (!existingUserOrgs.length) { - // delete all auto -provisioned user orgs - await db - .delete(userOrgs) + // delete all auto-provisioned user orgs + const autoProvisionedUserOrgs = await db + .select() + .from(userOrgs) .where( and( eq(userOrgs.userId, existingUser.userId), eq(userOrgs.autoProvisioned, true) ) ); + const orgIdsToRemove = autoProvisionedUserOrgs.map( + (uo) => uo.orgId + ); + if (orgIdsToRemove.length > 0) { + const orgsToRemove = await db + .select() + .from(orgs) + .where(inArray(orgs.orgId, orgIdsToRemove)); + for (const org of orgsToRemove) { + await removeUserFromOrg( + org, + existingUser.userId, + db + ); + } + } await calculateUserClientsForOrgs(existingUser.userId); @@ -395,7 +524,7 @@ export async function validateOidcCallback( } } - const orgUserCounts: { orgId: string; userCount: number }[] = []; + const orgUserCounts: { orgId: string; userCount: number }[] = []; // sync the user with the orgs and roles await db.transaction(async (trx) => { @@ -449,43 +578,38 @@ export async function validateOidcCallback( ); if (orgsToDelete.length > 0) { - await trx.delete(userOrgs).where( - and( - eq(userOrgs.userId, userId!), - inArray( - userOrgs.orgId, - orgsToDelete.map((org) => org.orgId) - ) - ) - ); + const orgIdsToRemove = orgsToDelete.map((org) => org.orgId); + const fullOrgsToRemove = await trx + .select() + .from(orgs) + .where(inArray(orgs.orgId, orgIdsToRemove)); + for (const org of fullOrgsToRemove) { + await removeUserFromOrg(org, userId!, trx); + } } - // Update roles for existing auto-provisioned orgs where the role has changed - const orgsToUpdate = autoProvisionedOrgs.filter( - (currentOrg) => { - const newOrg = userOrgInfo.find( - (newOrg) => newOrg.orgId === currentOrg.orgId - ); - return newOrg && newOrg.roleId !== currentOrg.roleId; - } - ); + // Sync roles 1:1 with IdP policy for existing auto-provisioned orgs + for (const currentOrg of autoProvisionedOrgs) { + const newRole = userOrgInfo.find( + (newOrg) => newOrg.orgId === currentOrg.orgId + ); + if (!newRole) continue; - if (orgsToUpdate.length > 0) { - for (const org of orgsToUpdate) { - const newRole = userOrgInfo.find( - (newOrg) => newOrg.orgId === org.orgId + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId!), + eq(userOrgRoles.orgId, currentOrg.orgId) + ) ); - if (newRole) { - await trx - .update(userOrgs) - .set({ roleId: newRole.roleId }) - .where( - and( - eq(userOrgs.userId, userId!), - eq(userOrgs.orgId, org.orgId) - ) - ); - } + + for (const roleId of newRole.roleIds) { + await trx.insert(userOrgRoles).values({ + userId: userId!, + orgId: currentOrg.orgId, + roleId + }); } } @@ -498,15 +622,28 @@ export async function validateOidcCallback( ); if (orgsToAdd.length > 0) { - await trx.insert(userOrgs).values( - orgsToAdd.map((org) => ({ - userId: userId!, - orgId: org.orgId, - roleId: org.roleId, - autoProvisioned: true, - dateCreated: new Date().toISOString() - })) - ); + for (const org of orgsToAdd) { + if (org.roleIds.length === 0) { + continue; + } + + const [fullOrg] = await trx + .select() + .from(orgs) + .where(eq(orgs.orgId, org.orgId)); + if (fullOrg) { + await assignUserToOrg( + fullOrg, + { + orgId: org.orgId, + userId: userId!, + autoProvisioned: true, + }, + org.roleIds, + trx + ); + } + } } // Loop through all the orgs and get the total number of users from the userOrgs table @@ -527,7 +664,7 @@ export async function validateOidcCallback( }); for (const orgCount of orgUserCounts) { - await usageService.updateDaily( + await usageService.updateCount( orgCount.orgId, FeatureId.USERS, orgCount.userCount @@ -545,9 +682,18 @@ export async function validateOidcCallback( res.appendHeader("Set-Cookie", cookie); + let finalRedirectUrl = postAuthRedirectUrl; + if (loginPageId) { + finalRedirectUrl = `/auth/org/?redirect=${encodeURIComponent( + postAuthRedirectUrl + )}`; + } + + logger.debug("Final redirect URL", { finalRedirectUrl }); + return response(res, { data: { - redirectUrl: postAuthRedirectUrl + redirectUrl: finalRedirectUrl }, success: true, error: false, @@ -620,3 +766,25 @@ function hydrateOrgMapping( } return orgMapping.split("{{orgId}}").join(orgId); } + +function normalizeRoleMappingResult( + result: unknown +): string[] { + if (typeof result === "string") { + const role = result.trim(); + return role ? [role] : []; + } + + if (Array.isArray(result)) { + return [ + ...new Set( + result + .filter((value): value is string => typeof value === "string") + .map((value) => value.trim()) + .filter(Boolean) + ) + ]; + } + + return []; +} diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 878d61fa4..2865b4bcb 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -16,6 +16,7 @@ import { verifyApiKey, verifyApiKeyOrgAccess, verifyApiKeyHasAction, + verifyApiKeyCanSetUserOrgRoles, verifyApiKeySiteAccess, verifyApiKeyResourceAccess, verifyApiKeyTargetAccess, @@ -26,7 +27,9 @@ import { verifyApiKeyIsRoot, verifyApiKeyClientAccess, verifyApiKeySiteResourceAccess, - verifyApiKeySetResourceClients + verifyApiKeySetResourceClients, + verifyLimits, + verifyApiKeyDomainAccess } from "@server/middlewares"; import HttpCode from "@server/types/HttpCode"; import { Router } from "express"; @@ -74,6 +77,7 @@ authenticated.get( authenticated.post( "/org/:orgId", verifyApiKeyOrgAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateOrg), logActionAudit(ActionsEnum.updateOrg), org.updateOrg @@ -90,6 +94,7 @@ authenticated.delete( authenticated.put( "/org/:orgId/site", verifyApiKeyOrgAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.createSite), logActionAudit(ActionsEnum.createSite), site.createSite @@ -126,10 +131,18 @@ authenticated.get( authenticated.post( "/site/:siteId", verifyApiKeySiteAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateSite), logActionAudit(ActionsEnum.updateSite), site.updateSite ); +authenticated.post( + "/org/:orgId/reset-bandwidth", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.resetSiteBandwidth), + logActionAudit(ActionsEnum.resetSiteBandwidth), + org.resetOrgBandwidth +); authenticated.delete( "/site/:siteId", @@ -146,9 +159,9 @@ authenticated.get( ); // Site Resource endpoints authenticated.put( - "/org/:orgId/site/:siteId/resource", + "/org/:orgId/site-resource", verifyApiKeyOrgAccess, - verifyApiKeySiteAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.createSiteResource), logActionAudit(ActionsEnum.createSiteResource), siteResource.createSiteResource @@ -170,28 +183,23 @@ authenticated.get( ); authenticated.get( - "/org/:orgId/site/:siteId/resource/:siteResourceId", - verifyApiKeyOrgAccess, - verifyApiKeySiteAccess, + "/site-resource/:siteResourceId", verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.getSiteResource), siteResource.getSiteResource ); authenticated.post( - "/org/:orgId/site/:siteId/resource/:siteResourceId", - verifyApiKeyOrgAccess, - verifyApiKeySiteAccess, + "/site-resource/:siteResourceId", verifyApiKeySiteResourceAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateSiteResource), logActionAudit(ActionsEnum.updateSiteResource), siteResource.updateSiteResource ); authenticated.delete( - "/org/:orgId/site/:siteId/resource/:siteResourceId", - verifyApiKeyOrgAccess, - verifyApiKeySiteAccess, + "/site-resource/:siteResourceId", verifyApiKeySiteResourceAccess, verifyApiKeyHasAction(ActionsEnum.deleteSiteResource), logActionAudit(ActionsEnum.deleteSiteResource), @@ -223,6 +231,7 @@ authenticated.post( "/site-resource/:siteResourceId/roles", verifyApiKeySiteResourceAccess, verifyApiKeyRoleAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), siteResource.setSiteResourceRoles @@ -232,6 +241,7 @@ authenticated.post( "/site-resource/:siteResourceId/users", verifyApiKeySiteResourceAccess, verifyApiKeySetResourceUsers, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.setSiteResourceUsers @@ -241,6 +251,7 @@ authenticated.post( "/site-resource/:siteResourceId/roles/add", verifyApiKeySiteResourceAccess, verifyApiKeyRoleAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), siteResource.addRoleToSiteResource @@ -250,6 +261,7 @@ authenticated.post( "/site-resource/:siteResourceId/roles/remove", verifyApiKeySiteResourceAccess, verifyApiKeyRoleAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), siteResource.removeRoleFromSiteResource @@ -259,6 +271,7 @@ authenticated.post( "/site-resource/:siteResourceId/users/add", verifyApiKeySiteResourceAccess, verifyApiKeySetResourceUsers, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.addUserToSiteResource @@ -268,6 +281,7 @@ authenticated.post( "/site-resource/:siteResourceId/users/remove", verifyApiKeySiteResourceAccess, verifyApiKeySetResourceUsers, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.removeUserFromSiteResource @@ -277,6 +291,7 @@ authenticated.post( "/site-resource/:siteResourceId/clients", verifyApiKeySiteResourceAccess, verifyApiKeySetResourceClients, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.setSiteResourceClients @@ -286,6 +301,7 @@ authenticated.post( "/site-resource/:siteResourceId/clients/add", verifyApiKeySiteResourceAccess, verifyApiKeySetResourceClients, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.addClientToSiteResource @@ -295,14 +311,24 @@ authenticated.post( "/site-resource/:siteResourceId/clients/remove", verifyApiKeySiteResourceAccess, verifyApiKeySetResourceClients, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), siteResource.removeClientFromSiteResource ); +authenticated.post( + "/client/:clientId/site-resources", + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.setResourceUsers), + logActionAudit(ActionsEnum.setResourceUsers), + siteResource.batchAddClientToSiteResources +); + authenticated.put( "/org/:orgId/resource", verifyApiKeyOrgAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.createResource), logActionAudit(ActionsEnum.createResource), resource.createResource @@ -311,6 +337,7 @@ authenticated.put( authenticated.put( "/org/:orgId/site/:siteId/resource", verifyApiKeyOrgAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.createResource), logActionAudit(ActionsEnum.createResource), resource.createResource @@ -337,6 +364,56 @@ authenticated.get( domain.listDomains ); +authenticated.get( + "/org/:orgId/domain/:domainId", + verifyApiKeyOrgAccess, + verifyApiKeyDomainAccess, + verifyApiKeyHasAction(ActionsEnum.getDomain), + domain.getDomain +); + +authenticated.put( + "/org/:orgId/domain", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createOrgDomain), + logActionAudit(ActionsEnum.createOrgDomain), + domain.createOrgDomain +); + +authenticated.patch( + "/org/:orgId/domain/:domainId", + verifyApiKeyOrgAccess, + verifyApiKeyDomainAccess, + verifyApiKeyHasAction(ActionsEnum.updateOrgDomain), + domain.updateOrgDomain +); + +authenticated.delete( + "/org/:orgId/domain/:domainId", + verifyApiKeyOrgAccess, + verifyApiKeyDomainAccess, + verifyApiKeyHasAction(ActionsEnum.deleteOrgDomain), + logActionAudit(ActionsEnum.deleteOrgDomain), + domain.deleteAccountDomain +); + +authenticated.get( + "/org/:orgId/domain/:domainId/dns-records", + verifyApiKeyOrgAccess, + verifyApiKeyDomainAccess, + verifyApiKeyHasAction(ActionsEnum.getDNSRecords), + domain.getDNSRecords +); + +authenticated.post( + "/org/:orgId/domain/:domainId/restart", + verifyApiKeyOrgAccess, + verifyApiKeyDomainAccess, + verifyApiKeyHasAction(ActionsEnum.restartOrgDomain), + logActionAudit(ActionsEnum.restartOrgDomain), + domain.restartOrgDomain +); + authenticated.get( "/org/:orgId/invitations", verifyApiKeyOrgAccess, @@ -347,11 +424,20 @@ authenticated.get( authenticated.post( "/org/:orgId/create-invite", verifyApiKeyOrgAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.inviteUser), logActionAudit(ActionsEnum.inviteUser), user.inviteUser ); +authenticated.delete( + "/org/:orgId/invitations/:inviteId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.removeInvitation), + logActionAudit(ActionsEnum.removeInvitation), + user.removeInvitation +); + authenticated.get( "/resource/:resourceId/roles", verifyApiKeyResourceAccess, @@ -376,6 +462,7 @@ authenticated.get( authenticated.post( "/resource/:resourceId", verifyApiKeyResourceAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateResource), logActionAudit(ActionsEnum.updateResource), resource.updateResource @@ -392,6 +479,7 @@ authenticated.delete( authenticated.put( "/resource/:resourceId/target", verifyApiKeyResourceAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.createTarget), logActionAudit(ActionsEnum.createTarget), target.createTarget @@ -407,6 +495,7 @@ authenticated.get( authenticated.put( "/resource/:resourceId/rule", verifyApiKeyResourceAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.createResourceRule), logActionAudit(ActionsEnum.createResourceRule), resource.createResourceRule @@ -422,6 +511,7 @@ authenticated.get( authenticated.post( "/resource/:resourceId/rule/:ruleId", verifyApiKeyResourceAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateResourceRule), logActionAudit(ActionsEnum.updateResourceRule), resource.updateResourceRule @@ -445,6 +535,7 @@ authenticated.get( authenticated.post( "/target/:targetId", verifyApiKeyTargetAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateTarget), logActionAudit(ActionsEnum.updateTarget), target.updateTarget @@ -461,11 +552,21 @@ authenticated.delete( authenticated.put( "/org/:orgId/role", verifyApiKeyOrgAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.createRole), logActionAudit(ActionsEnum.createRole), role.createRole ); +authenticated.post( + "/role/:roleId", + verifyApiKeyRoleAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.updateRole), + logActionAudit(ActionsEnum.updateRole), + role.updateRole +); + authenticated.get( "/org/:orgId/roles", verifyApiKeyOrgAccess, @@ -492,15 +593,17 @@ authenticated.post( "/role/:roleId/add/:userId", verifyApiKeyRoleAccess, verifyApiKeyUserAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.addUserRole), logActionAudit(ActionsEnum.addUserRole), - user.addUserRole + user.addUserRoleLegacy ); authenticated.post( "/resource/:resourceId/roles", verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), resource.setResourceRoles @@ -510,6 +613,7 @@ authenticated.post( "/resource/:resourceId/users", verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), resource.setResourceUsers @@ -519,6 +623,7 @@ authenticated.post( "/resource/:resourceId/roles/add", verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), resource.addRoleToResource @@ -528,6 +633,7 @@ authenticated.post( "/resource/:resourceId/roles/remove", verifyApiKeyResourceAccess, verifyApiKeyRoleAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceRoles), logActionAudit(ActionsEnum.setResourceRoles), resource.removeRoleFromResource @@ -537,6 +643,7 @@ authenticated.post( "/resource/:resourceId/users/add", verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), resource.addUserToResource @@ -546,6 +653,7 @@ authenticated.post( "/resource/:resourceId/users/remove", verifyApiKeyResourceAccess, verifyApiKeySetResourceUsers, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceUsers), logActionAudit(ActionsEnum.setResourceUsers), resource.removeUserFromResource @@ -554,6 +662,7 @@ authenticated.post( authenticated.post( `/resource/:resourceId/password`, verifyApiKeyResourceAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePassword), logActionAudit(ActionsEnum.setResourcePassword), resource.setResourcePassword @@ -562,6 +671,7 @@ authenticated.post( authenticated.post( `/resource/:resourceId/pincode`, verifyApiKeyResourceAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourcePincode), logActionAudit(ActionsEnum.setResourcePincode), resource.setResourcePincode @@ -570,6 +680,7 @@ authenticated.post( authenticated.post( `/resource/:resourceId/header-auth`, verifyApiKeyResourceAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceHeaderAuth), logActionAudit(ActionsEnum.setResourceHeaderAuth), resource.setResourceHeaderAuth @@ -578,6 +689,7 @@ authenticated.post( authenticated.post( `/resource/:resourceId/whitelist`, verifyApiKeyResourceAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), logActionAudit(ActionsEnum.setResourceWhitelist), resource.setResourceWhitelist @@ -586,6 +698,7 @@ authenticated.post( authenticated.post( `/resource/:resourceId/whitelist/add`, verifyApiKeyResourceAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), resource.addEmailToResourceWhitelist ); @@ -593,6 +706,7 @@ authenticated.post( authenticated.post( `/resource/:resourceId/whitelist/remove`, verifyApiKeyResourceAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setResourceWhitelist), resource.removeEmailFromResourceWhitelist ); @@ -607,6 +721,7 @@ authenticated.get( authenticated.post( `/resource/:resourceId/access-token`, verifyApiKeyResourceAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.generateAccessToken), logActionAudit(ActionsEnum.generateAccessToken), accessToken.generateAccessToken @@ -641,9 +756,17 @@ authenticated.get( user.getOrgUser ); +authenticated.get( + "/org/:orgId/user-by-username", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getOrgUser), + user.getOrgUserByUsername +); + authenticated.post( "/user/:userId/2fa", verifyApiKeyIsRoot, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateUser), logActionAudit(ActionsEnum.updateUser), user.updateUser2FA @@ -666,6 +789,7 @@ authenticated.get( authenticated.put( "/org/:orgId/user", verifyApiKeyOrgAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.createOrgUser), logActionAudit(ActionsEnum.createOrgUser), user.createOrgUser @@ -675,6 +799,7 @@ authenticated.post( "/org/:orgId/user/:userId", verifyApiKeyOrgAccess, verifyApiKeyUserAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateOrgUser), logActionAudit(ActionsEnum.updateOrgUser), user.updateOrgUser @@ -705,6 +830,7 @@ authenticated.get( authenticated.post( `/org/:orgId/api-key/:apiKeyId/actions`, verifyApiKeyIsRoot, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.setApiKeyActions), logActionAudit(ActionsEnum.setApiKeyActions), apiKeys.setApiKeyActions @@ -720,6 +846,7 @@ authenticated.get( authenticated.put( `/org/:orgId/api-key`, verifyApiKeyIsRoot, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.createApiKey), logActionAudit(ActionsEnum.createApiKey), apiKeys.createOrgApiKey @@ -736,6 +863,7 @@ authenticated.delete( authenticated.put( "/idp/oidc", verifyApiKeyIsRoot, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.createIdp), logActionAudit(ActionsEnum.createIdp), idp.createOidcIdp @@ -744,15 +872,17 @@ authenticated.put( authenticated.post( "/idp/:idpId/oidc", verifyApiKeyIsRoot, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateIdp), logActionAudit(ActionsEnum.updateIdp), idp.updateOidcIdp ); authenticated.get( - "/idp", - verifyApiKeyIsRoot, - verifyApiKeyHasAction(ActionsEnum.listIdps), + "/idp", // no guards on this because anyone can list idps for login purposes + // we do the same for the external api + // verifyApiKeyIsRoot, + // verifyApiKeyHasAction(ActionsEnum.listIdps), idp.listIdps ); @@ -766,6 +896,7 @@ authenticated.get( authenticated.put( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.createIdpOrg), logActionAudit(ActionsEnum.createIdpOrg), idp.createIdpOrgPolicy @@ -774,6 +905,7 @@ authenticated.put( authenticated.post( "/idp/:idpId/org/:orgId", verifyApiKeyIsRoot, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateIdpOrg), logActionAudit(ActionsEnum.updateIdpOrg), idp.updateIdpOrgPolicy @@ -808,6 +940,13 @@ authenticated.get( client.listClients ); +authenticated.get( + "/org/:orgId/user-devices", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listClients), + client.listUserDevices +); + authenticated.get( "/client/:clientId", verifyApiKeyClientAccess, @@ -818,6 +957,7 @@ authenticated.get( authenticated.put( "/org/:orgId/client", verifyApiKeyOrgAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.createClient), logActionAudit(ActionsEnum.createClient), client.createClient @@ -841,9 +981,46 @@ authenticated.delete( client.deleteClient ); +authenticated.post( + "/client/:clientId/archive", + verifyApiKeyClientAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.archiveClient), + logActionAudit(ActionsEnum.archiveClient), + client.archiveClient +); + +authenticated.post( + "/client/:clientId/unarchive", + verifyApiKeyClientAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.unarchiveClient), + logActionAudit(ActionsEnum.unarchiveClient), + client.unarchiveClient +); + +authenticated.post( + "/client/:clientId/block", + verifyApiKeyClientAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.blockClient), + logActionAudit(ActionsEnum.blockClient), + client.blockClient +); + +authenticated.post( + "/client/:clientId/unblock", + verifyApiKeyClientAccess, + verifyLimits, + verifyApiKeyHasAction(ActionsEnum.unblockClient), + logActionAudit(ActionsEnum.unblockClient), + client.unblockClient +); + authenticated.post( "/client/:clientId", verifyApiKeyClientAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.updateClient), logActionAudit(ActionsEnum.updateClient), client.updateClient @@ -852,11 +1029,26 @@ authenticated.post( authenticated.put( "/org/:orgId/blueprint", verifyApiKeyOrgAccess, + verifyLimits, verifyApiKeyHasAction(ActionsEnum.applyBlueprint), logActionAudit(ActionsEnum.applyBlueprint), blueprints.applyJSONBlueprint ); +authenticated.get( + "/org/:orgId/blueprint/:blueprintId", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.getBlueprint), + blueprints.getBlueprint +); + +authenticated.get( + "/org/:orgId/blueprints", + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.listBlueprints), + blueprints.listBlueprints +); + authenticated.get( "/org/:orgId/logs/request", verifyApiKeyOrgAccess, diff --git a/server/routers/loginPage/types.ts b/server/routers/loginPage/types.ts index a68dd7d4a..8a253d072 100644 --- a/server/routers/loginPage/types.ts +++ b/server/routers/loginPage/types.ts @@ -1,4 +1,4 @@ -import { LoginPage } from "@server/db"; +import type { LoginPage, LoginPageBranding } from "@server/db"; export type CreateLoginPageResponse = LoginPage; @@ -9,3 +9,10 @@ export type GetLoginPageResponse = LoginPage; export type UpdateLoginPageResponse = LoginPage; export type LoadLoginPageResponse = LoginPage & { orgId: string }; + +export type LoadLoginPageBrandingResponse = LoginPageBranding & { + orgId: string; + orgName: string; +}; + +export type GetLoginPageBrandingResponse = LoginPageBranding; diff --git a/server/routers/newt/buildConfiguration.ts b/server/routers/newt/buildConfiguration.ts new file mode 100644 index 000000000..35d52816e --- /dev/null +++ b/server/routers/newt/buildConfiguration.ts @@ -0,0 +1,299 @@ +import { + clients, + clientSiteResourcesAssociationsCache, + clientSitesAssociationsCache, + db, + ExitNode, + resources, + Site, + siteResources, + targetHealthCheck, + targets +} from "@server/db"; +import logger from "@server/logger"; +import { initPeerAddHandshake, updatePeer } from "../olm/peers"; +import { eq, and } from "drizzle-orm"; +import config from "@server/lib/config"; +import { + formatEndpoint, + generateSubnetProxyTargetV2, + SubnetProxyTargetV2 +} from "@server/lib/ip"; + +export async function buildClientConfigurationForNewtClient( + site: Site, + exitNode?: ExitNode +) { + const siteId = site.siteId; + + // Get all clients connected to this site + const clientsRes = await db + .select() + .from(clients) + .innerJoin( + clientSitesAssociationsCache, + eq(clients.clientId, clientSitesAssociationsCache.clientId) + ) + .where(eq(clientSitesAssociationsCache.siteId, siteId)); + + let peers: Array<{ + publicKey: string; + allowedIps: string[]; + endpoint?: string; + }> = []; + + if (site.publicKey && site.endpoint && exitNode) { + // Prepare peers data for the response + peers = await Promise.all( + clientsRes + .filter((client) => { + if (!client.clients.pubKey) { + logger.warn( + `Client ${client.clients.clientId} has no public key, skipping` + ); + return false; + } + if (!client.clients.subnet) { + logger.warn( + `Client ${client.clients.clientId} has no subnet, skipping` + ); + return false; + } + return true; + }) + .map(async (client) => { + // Add or update this peer on the olm if it is connected + + // const allSiteResources = await db // only get the site resources that this client has access to + // .select() + // .from(siteResources) + // .innerJoin( + // clientSiteResourcesAssociationsCache, + // eq( + // siteResources.siteResourceId, + // clientSiteResourcesAssociationsCache.siteResourceId + // ) + // ) + // .where( + // and( + // eq(siteResources.siteId, site.siteId), + // eq( + // clientSiteResourcesAssociationsCache.clientId, + // client.clients.clientId + // ) + // ) + // ); + + if (!client.clientSitesAssociationsCache.isJitMode) { // if we are adding sites through jit then dont add the site to the olm + // update the peer info on the olm + // if the peer has not been added yet this will be a no-op + await updatePeer(client.clients.clientId, { + siteId: site.siteId, + endpoint: site.endpoint!, + relayEndpoint: `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`, + publicKey: site.publicKey!, + serverIP: site.address, + serverPort: site.listenPort + // remoteSubnets: generateRemoteSubnets( + // allSiteResources.map( + // ({ siteResources }) => siteResources + // ) + // ), + // aliases: generateAliasConfig( + // allSiteResources.map( + // ({ siteResources }) => siteResources + // ) + // ) + }); + + // also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch + // if it has already been added this will be a no-op + await initPeerAddHandshake( + // this will kick off the add peer process for the client + client.clients.clientId, + { + siteId, + exitNode: { + publicKey: exitNode.publicKey, + endpoint: exitNode.endpoint + } + } + ); + } + + return { + publicKey: client.clients.pubKey!, + allowedIps: [ + `${client.clients.subnet.split("/")[0]}/32` + ], // we want to only allow from that client + endpoint: client.clientSitesAssociationsCache.isRelayed + ? "" + : client.clientSitesAssociationsCache.endpoint! // if its relayed it should be localhost + }; + }) + ); + } + + // Filter out any null values from peers that didn't have an olm + const validPeers = peers.filter((peer) => peer !== null); + + // Get all enabled site resources for this site + const allSiteResources = await db + .select() + .from(siteResources) + .where(eq(siteResources.siteId, siteId)); + + const targetsToSend: SubnetProxyTargetV2[] = []; + + for (const resource of allSiteResources) { + // Get clients associated with this specific resource + const resourceClients = await db + .select({ + clientId: clients.clientId, + pubKey: clients.pubKey, + subnet: clients.subnet + }) + .from(clients) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + clients.clientId, + clientSiteResourcesAssociationsCache.clientId + ) + ) + .where( + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + resource.siteResourceId + ) + ); + + const resourceTarget = generateSubnetProxyTargetV2( + resource, + resourceClients + ); + + if (resourceTarget) { + targetsToSend.push(resourceTarget); + } + } + + return { + peers: validPeers, + targets: targetsToSend + }; +} + +export async function buildTargetConfigurationForNewtClient(siteId: number) { + // Get all enabled targets with their resource protocol information + const allTargets = await db + .select({ + resourceId: targets.resourceId, + targetId: targets.targetId, + ip: targets.ip, + method: targets.method, + port: targets.port, + internalPort: targets.internalPort, + enabled: targets.enabled, + protocol: resources.protocol, + hcEnabled: targetHealthCheck.hcEnabled, + hcPath: targetHealthCheck.hcPath, + hcScheme: targetHealthCheck.hcScheme, + hcMode: targetHealthCheck.hcMode, + hcHostname: targetHealthCheck.hcHostname, + hcPort: targetHealthCheck.hcPort, + hcInterval: targetHealthCheck.hcInterval, + hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval, + hcTimeout: targetHealthCheck.hcTimeout, + hcHeaders: targetHealthCheck.hcHeaders, + hcMethod: targetHealthCheck.hcMethod, + hcTlsServerName: targetHealthCheck.hcTlsServerName, + hcStatus: targetHealthCheck.hcStatus + }) + .from(targets) + .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) + .leftJoin( + targetHealthCheck, + eq(targets.targetId, targetHealthCheck.targetId) + ) + .where(and(eq(targets.siteId, siteId), eq(targets.enabled, true))); + + const { tcpTargets, udpTargets } = allTargets.reduce( + (acc, target) => { + // Filter out invalid targets + if (!target.internalPort || !target.ip || !target.port) { + return acc; + } + + // Format target into string (handles IPv6 bracketing) + const formattedTarget = `${target.internalPort}:${formatEndpoint(target.ip, target.port)}`; + + // Add to the appropriate protocol array + if (target.protocol === "tcp") { + acc.tcpTargets.push(formattedTarget); + } else { + acc.udpTargets.push(formattedTarget); + } + + return acc; + }, + { tcpTargets: [] as string[], udpTargets: [] as string[] } + ); + + const healthCheckTargets = allTargets.map((target) => { + // make sure the stuff is defined + if ( + !target.hcPath || + !target.hcHostname || + !target.hcPort || + !target.hcInterval || + !target.hcMethod + ) { + // logger.debug( + // `Skipping adding target health check ${target.targetId} due to missing health check fields` + // ); + return null; // Skip targets with missing health check fields + } + + // parse headers + const hcHeadersParse = target.hcHeaders + ? JSON.parse(target.hcHeaders) + : null; + const hcHeadersSend: { [key: string]: string } = {}; + if (hcHeadersParse) { + hcHeadersParse.forEach( + (header: { name: string; value: string }) => { + hcHeadersSend[header.name] = header.value; + } + ); + } + + return { + id: target.targetId, + hcEnabled: target.hcEnabled, + hcPath: target.hcPath, + hcScheme: target.hcScheme, + hcMode: target.hcMode, + hcHostname: target.hcHostname, + hcPort: target.hcPort, + hcInterval: target.hcInterval, // in seconds + hcUnhealthyInterval: target.hcUnhealthyInterval, // in seconds + hcTimeout: target.hcTimeout, // in seconds + hcHeaders: hcHeadersSend, + hcMethod: target.hcMethod, + hcTlsServerName: target.hcTlsServerName, + hcStatus: target.hcStatus + }; + }); + + // Filter out any null values from health check targets + const validHealthCheckTargets = healthCheckTargets.filter( + (target) => target !== null + ); + + return { + validHealthCheckTargets, + tcpTargets, + udpTargets + }; +} diff --git a/server/routers/newt/createNewt.ts b/server/routers/newt/createNewt.ts index b5da405e6..68cdcacf8 100644 --- a/server/routers/newt/createNewt.ts +++ b/server/routers/newt/createNewt.ts @@ -46,7 +46,7 @@ export async function createNewt( const { newtId, secret } = parsedBody.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); diff --git a/server/routers/newt/getNewtToken.ts b/server/routers/newt/getNewtToken.ts index 637973582..c5abb9968 100644 --- a/server/routers/newt/getNewtToken.ts +++ b/server/routers/newt/getNewtToken.ts @@ -1,6 +1,8 @@ import { generateSessionToken } from "@server/auth/sessions/app"; -import { db } from "@server/db"; +import { db, newtSessions } from "@server/db"; import { newts } from "@server/db"; +import { getOrCreateCachedToken } from "#dynamic/lib/tokenCache"; +import { EXPIRES } from "@server/auth/sessions/newt"; import HttpCode from "@server/types/HttpCode"; import response from "@server/lib/response"; import { eq } from "drizzle-orm"; @@ -92,8 +94,19 @@ export async function getNewtToken( ); } - const resToken = generateSessionToken(); - await createNewtSession(resToken, existingNewt.newtId); + // Return a cached token if one exists to prevent thundering herd on + // simultaneous restarts; falls back to creating a fresh session when + // Redis is unavailable or the cache has expired. + const resToken = await getOrCreateCachedToken( + `newt:token_cache:${existingNewt.newtId}`, + config.getRawConfig().server.secret!, + Math.floor(EXPIRES / 1000), + async () => { + const token = generateSessionToken(); + await createNewtSession(token, existingNewt.newtId); + return token; + } + ); return response<{ token: string; serverVersion: string }>(res, { data: { diff --git a/server/routers/newt/handleConnectionLogMessage.ts b/server/routers/newt/handleConnectionLogMessage.ts new file mode 100644 index 000000000..ca1b129d2 --- /dev/null +++ b/server/routers/newt/handleConnectionLogMessage.ts @@ -0,0 +1,13 @@ +import { MessageHandler } from "@server/routers/ws"; + +export async function flushConnectionLogToDb(): Promise { + return; +} + +export async function cleanUpOldLogs(orgId: string, retentionDays: number) { + return; +} + +export const handleConnectionLogMessage: MessageHandler = async (context) => { + return; +}; diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleGetConfigMessage.ts index bfe14ec51..c73098ce4 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -2,23 +2,17 @@ import { z } from "zod"; import { MessageHandler } from "@server/routers/ws"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { - db, - ExitNode, - exitNodes, - siteResources, - clientSiteResourcesAssociationsCache -} from "@server/db"; -import { clients, clientSitesAssociationsCache, Newt, sites } from "@server/db"; +import { db, ExitNode, exitNodes, Newt, sites } from "@server/db"; import { eq } from "drizzle-orm"; -import { initPeerAddHandshake, updatePeer } from "../olm/peers"; import { sendToExitNode } from "#dynamic/lib/exitNodes"; -import { generateSubnetProxyTargets, SubnetProxyTarget } from "@server/lib/ip"; -import config from "@server/lib/config"; +import { buildClientConfigurationForNewtClient } from "./buildConfiguration"; +import { convertTargetsIfNessicary } from "../client/targets"; +import { canCompress } from "@server/lib/clientVersionChecks"; const inputSchema = z.object({ publicKey: z.string(), - port: z.int().positive() + port: z.int().positive(), + chainId: z.string() }); type Input = z.infer; @@ -50,7 +44,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { return; } - const { publicKey, port } = message.data as Input; + const { publicKey, port, chainId } = message.data as Input; const siteId = newt.siteId; // Get the current site data @@ -113,11 +107,11 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { const payload = { oldDestination: { destinationIP: existingSite.subnet?.split("/")[0], - destinationPort: existingSite.listenPort + destinationPort: existingSite.listenPort || 1 // this satisfies gerbil for now but should be reevaluated }, newDestination: { destinationIP: site.subnet?.split("/")[0], - destinationPort: site.listenPort + destinationPort: site.listenPort || 1 // this satisfies gerbil for now but should be reevaluated } }; @@ -130,169 +124,26 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { } } - // Get all clients connected to this site - const clientsRes = await db - .select() - .from(clients) - .innerJoin( - clientSitesAssociationsCache, - eq(clients.clientId, clientSitesAssociationsCache.clientId) - ) - .where(eq(clientSitesAssociationsCache.siteId, siteId)); + const { peers, targets } = await buildClientConfigurationForNewtClient( + site, + exitNode + ); - let peers: Array<{ - publicKey: string; - allowedIps: string[]; - endpoint?: string; - }> = []; + const targetsToSend = await convertTargetsIfNessicary(newt.newtId, targets); - if (site.publicKey && site.endpoint && exitNode) { - // Prepare peers data for the response - peers = await Promise.all( - clientsRes - .filter((client) => { - if (!client.clients.pubKey) { - logger.warn( - `Client ${client.clients.clientId} has no public key, skipping` - ); - return false; - } - if (!client.clients.subnet) { - logger.warn( - `Client ${client.clients.clientId} has no subnet, skipping` - ); - return false; - } - return true; - }) - .map(async (client) => { - // Add or update this peer on the olm if it is connected - - // const allSiteResources = await db // only get the site resources that this client has access to - // .select() - // .from(siteResources) - // .innerJoin( - // clientSiteResourcesAssociationsCache, - // eq( - // siteResources.siteResourceId, - // clientSiteResourcesAssociationsCache.siteResourceId - // ) - // ) - // .where( - // and( - // eq(siteResources.siteId, site.siteId), - // eq( - // clientSiteResourcesAssociationsCache.clientId, - // client.clients.clientId - // ) - // ) - // ); - - // update the peer info on the olm - // if the peer has not been added yet this will be a no-op - await updatePeer(client.clients.clientId, { - siteId: site.siteId, - endpoint: site.endpoint!, - relayEndpoint: `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`, - publicKey: site.publicKey!, - serverIP: site.address, - serverPort: site.listenPort - // remoteSubnets: generateRemoteSubnets( - // allSiteResources.map( - // ({ siteResources }) => siteResources - // ) - // ), - // aliases: generateAliasConfig( - // allSiteResources.map( - // ({ siteResources }) => siteResources - // ) - // ) - }); - - // also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch - // if it has already been added this will be a no-op - await initPeerAddHandshake( - // this will kick off the add peer process for the client - client.clients.clientId, - { - siteId, - exitNode: { - publicKey: exitNode.publicKey, - endpoint: exitNode.endpoint - } - } - ); - - return { - publicKey: client.clients.pubKey!, - allowedIps: [ - `${client.clients.subnet.split("/")[0]}/32` - ], // we want to only allow from that client - endpoint: client.clientSitesAssociationsCache.isRelayed - ? "" - : client.clientSitesAssociationsCache.endpoint! // if its relayed it should be localhost - }; - }) - ); - } - - // Filter out any null values from peers that didn't have an olm - const validPeers = peers.filter((peer) => peer !== null); - - // Get all enabled site resources for this site - const allSiteResources = await db - .select() - .from(siteResources) - .where(eq(siteResources.siteId, siteId)); - - const targetsToSend: SubnetProxyTarget[] = []; - - for (const resource of allSiteResources) { - // Get clients associated with this specific resource - const resourceClients = await db - .select({ - clientId: clients.clientId, - pubKey: clients.pubKey, - subnet: clients.subnet - }) - .from(clients) - .innerJoin( - clientSiteResourcesAssociationsCache, - eq( - clients.clientId, - clientSiteResourcesAssociationsCache.clientId - ) - ) - .where( - eq( - clientSiteResourcesAssociationsCache.siteResourceId, - resource.siteResourceId - ) - ); - - const resourceTargets = generateSubnetProxyTargets( - resource, - resourceClients - ); - - targetsToSend.push(...resourceTargets); - } - - // Build the configuration response - const configResponse = { - ipAddress: site.address, - peers: validPeers, - targets: targetsToSend - }; - - logger.debug("Sending config: ", configResponse); return { message: { type: "newt/wg/receive-config", data: { - ...configResponse + ipAddress: site.address, + peers, + targets: targetsToSend, + chainId: chainId } }, + options: { + compress: canCompress(newt.version, "newt") + }, broadcast: false, excludeSender: false }; diff --git a/server/routers/newt/handleNewtDisconnectingMessage.ts b/server/routers/newt/handleNewtDisconnectingMessage.ts new file mode 100644 index 000000000..02c5a95ac --- /dev/null +++ b/server/routers/newt/handleNewtDisconnectingMessage.ts @@ -0,0 +1,36 @@ +import { MessageHandler } from "@server/routers/ws"; +import { db, Newt, sites } from "@server/db"; +import { eq } from "drizzle-orm"; +import logger from "@server/logger"; + +/** + * Handles disconnecting messages from sites to show disconnected in the ui + */ +export const handleNewtDisconnectingMessage: MessageHandler = async ( + context +) => { + const { message, client: c, sendToClient } = context; + const newt = c as Newt; + + if (!newt) { + logger.warn("Newt not found"); + return; + } + + if (!newt.siteId) { + logger.warn("Newt has no client ID!"); + return; + } + + try { + // Update the client's last ping timestamp + await db + .update(sites) + .set({ + online: false + }) + .where(eq(sites.siteId, newt.siteId)); + } catch (error) { + logger.error("Error handling disconnecting message", { error }); + } +}; diff --git a/server/routers/newt/handleNewtPingMessage.ts b/server/routers/newt/handleNewtPingMessage.ts new file mode 100644 index 000000000..da25852a0 --- /dev/null +++ b/server/routers/newt/handleNewtPingMessage.ts @@ -0,0 +1,163 @@ +import { db, newts, sites } from "@server/db"; +import { hasActiveConnections, getClientConfigVersion } from "#dynamic/routers/ws"; +import { MessageHandler } from "@server/routers/ws"; +import { Newt } from "@server/db"; +import { eq, lt, isNull, and, or } from "drizzle-orm"; +import logger from "@server/logger"; +import { sendNewtSyncMessage } from "./sync"; +import { recordPing } from "./pingAccumulator"; + +// Track if the offline checker interval is running +let offlineCheckerInterval: NodeJS.Timeout | null = null; +const OFFLINE_CHECK_INTERVAL = 30 * 1000; // Check every 30 seconds +const OFFLINE_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes + +/** + * Starts the background interval that checks for newt sites that haven't + * pinged recently and marks them as offline. For backward compatibility, + * a site is only marked offline when there is no active WebSocket connection + * either — so older newt versions that don't send pings but remain connected + * continue to be treated as online. + */ +export const startNewtOfflineChecker = (): void => { + if (offlineCheckerInterval) { + return; // Already running + } + + offlineCheckerInterval = setInterval(async () => { + try { + const twoMinutesAgo = Math.floor( + (Date.now() - OFFLINE_THRESHOLD_MS) / 1000 + ); + + // Find all online newt-type sites that haven't pinged recently + // (or have never pinged at all). Join newts to obtain the newtId + // needed for the WebSocket connection check. + const staleSites = await db + .select({ + siteId: sites.siteId, + newtId: newts.newtId, + lastPing: sites.lastPing + }) + .from(sites) + .innerJoin(newts, eq(newts.siteId, sites.siteId)) + .where( + and( + eq(sites.online, true), + eq(sites.type, "newt"), + or( + lt(sites.lastPing, twoMinutesAgo), + isNull(sites.lastPing) + ) + ) + ); + + for (const staleSite of staleSites) { + // Backward-compatibility check: if the newt still has an + // active WebSocket connection (older clients that don't send + // pings), keep the site online. + const isConnected = await hasActiveConnections(staleSite.newtId); + if (isConnected) { + logger.debug( + `Newt ${staleSite.newtId} has not pinged recently but is still connected via WebSocket — keeping site ${staleSite.siteId} online` + ); + continue; + } + + logger.info( + `Marking site ${staleSite.siteId} offline: newt ${staleSite.newtId} has no recent ping and no active WebSocket connection` + ); + + await db + .update(sites) + .set({ online: false }) + .where(eq(sites.siteId, staleSite.siteId)); + } + } catch (error) { + logger.error("Error in newt offline checker interval", { error }); + } + }, OFFLINE_CHECK_INTERVAL); + + logger.debug("Started newt offline checker interval"); +}; + +/** + * Stops the background interval that checks for offline newt sites. + */ +export const stopNewtOfflineChecker = (): void => { + if (offlineCheckerInterval) { + clearInterval(offlineCheckerInterval); + offlineCheckerInterval = null; + logger.info("Stopped newt offline checker interval"); + } +}; + +/** + * Handles ping messages from newt clients. + * + * On each ping: + * - Marks the associated site as online. + * - Records the current timestamp as the newt's last-ping time. + * - Triggers a config sync if the newt is running an outdated config version. + * - Responds with a pong message. + */ +export const handleNewtPingMessage: MessageHandler = async (context) => { + const { message, client: c } = context; + const newt = c as Newt; + + if (!newt) { + logger.warn("Newt ping message: Newt not found"); + return; + } + + if (!newt.siteId) { + logger.warn("Newt ping message: has no site ID"); + return; + } + + // Record the ping in memory; it will be flushed to the database + // periodically by the ping accumulator (every ~10s) in a single + // batched UPDATE instead of one query per ping. This prevents + // connection pool exhaustion under load, especially with + // cross-region latency to the database. + recordPing(newt.siteId); + + // Check config version and sync if stale. + const configVersion = await getClientConfigVersion(newt.newtId); + + if ( + message.configVersion != null && + configVersion != null && + configVersion !== message.configVersion + ) { + logger.warn( + `Newt ping with outdated config version: ${message.configVersion} (current: ${configVersion})` + ); + + const [site] = await db + .select() + .from(sites) + .where(eq(sites.siteId, newt.siteId)) + .limit(1); + + if (!site) { + logger.warn( + `Newt ping message: site with ID ${newt.siteId} not found` + ); + return; + } + + await sendNewtSyncMessage(newt, site); + } + + return { + message: { + type: "pong", + data: { + timestamp: new Date().toISOString() + } + }, + broadcast: false, + excludeSender: false + }; +}; diff --git a/server/routers/newt/handleNewtRegisterMessage.ts b/server/routers/newt/handleNewtRegisterMessage.ts index 77e49a202..fce42caa3 100644 --- a/server/routers/newt/handleNewtRegisterMessage.ts +++ b/server/routers/newt/handleNewtRegisterMessage.ts @@ -1,23 +1,19 @@ -import { db, ExitNode, exitNodeOrgs, newts, Transaction } from "@server/db"; +import { db, ExitNode, newts, Transaction } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { exitNodes, Newt, resources, sites, Target, targets } from "@server/db"; -import { targetHealthCheck } from "@server/db"; -import { eq, and, sql, inArray, ne } from "drizzle-orm"; +import { exitNodes, Newt, sites } from "@server/db"; +import { eq } from "drizzle-orm"; import { addPeer, deletePeer } from "../gerbil/peers"; import logger from "@server/logger"; import config from "@server/lib/config"; -import { - findNextAvailableCidr, - getNextAvailableClientSubnet -} from "@server/lib/ip"; -import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { findNextAvailableCidr } from "@server/lib/ip"; import { selectBestExitNode, verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; import { fetchContainers } from "./dockerSocket"; import { lockManager } from "#dynamic/lib/lock"; +import { buildTargetConfigurationForNewtClient } from "./buildConfiguration"; +import { canCompress } from "@server/lib/clientVersionChecks"; export type ExitNodePingResult = { exitNodeId: number; @@ -29,8 +25,6 @@ export type ExitNodePingResult = { wasPreviouslyConnected: boolean; }; -const numTimesLimitExceededForId: Record = {}; - export const handleNewtRegisterMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; const newt = client as Newt; @@ -49,7 +43,7 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { const siteId = newt.siteId; - const { publicKey, pingResults, newtVersion, backwardsCompatible } = + const { publicKey, pingResults, newtVersion, backwardsCompatible, chainId } = message.data; if (!publicKey) { logger.warn("Public key not provided"); @@ -95,42 +89,6 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { fetchContainers(newt.newtId); } - const rejectSiteUptime = await usageService.checkLimitSet( - oldSite.orgId, - false, - FeatureId.SITE_UPTIME - ); - const rejectEgressDataMb = await usageService.checkLimitSet( - oldSite.orgId, - false, - FeatureId.EGRESS_DATA_MB - ); - - // Do we need to check the users and domains daily limits here? - // const rejectUsers = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.USERS); - // const rejectDomains = await usageService.checkLimitSet(oldSite.orgId, false, FeatureId.DOMAINS); - - // if (rejectEgressDataMb || rejectSiteUptime || rejectUsers || rejectDomains) { - if (rejectEgressDataMb || rejectSiteUptime) { - logger.info( - `Usage limits exceeded for org ${oldSite.orgId}. Rejecting newt registration.` - ); - - // PREVENT FURTHER REGISTRATION ATTEMPTS SO WE DON'T SPAM - - // Increment the limit exceeded count for this site - numTimesLimitExceededForId[newt.newtId] = - (numTimesLimitExceededForId[newt.newtId] || 0) + 1; - - if (numTimesLimitExceededForId[newt.newtId] > 15) { - logger.debug( - `Newt ${newt.newtId} has exceeded usage limits 15 times. Terminating...` - ); - } - - return; - } - let siteSubnet = oldSite.subnet; let exitNodeIdToQuery = oldSite.exitNodeId; if (exitNodeId && (oldSite.exitNodeId !== exitNodeId || !oldSite.subnet)) { @@ -233,109 +191,8 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { .where(eq(newts.newtId, newt.newtId)); } - // Get all enabled targets with their resource protocol information - const allTargets = await db - .select({ - resourceId: targets.resourceId, - targetId: targets.targetId, - ip: targets.ip, - method: targets.method, - port: targets.port, - internalPort: targets.internalPort, - enabled: targets.enabled, - protocol: resources.protocol, - hcEnabled: targetHealthCheck.hcEnabled, - hcPath: targetHealthCheck.hcPath, - hcScheme: targetHealthCheck.hcScheme, - hcMode: targetHealthCheck.hcMode, - hcHostname: targetHealthCheck.hcHostname, - hcPort: targetHealthCheck.hcPort, - hcInterval: targetHealthCheck.hcInterval, - hcUnhealthyInterval: targetHealthCheck.hcUnhealthyInterval, - hcTimeout: targetHealthCheck.hcTimeout, - hcHeaders: targetHealthCheck.hcHeaders, - hcMethod: targetHealthCheck.hcMethod, - hcTlsServerName: targetHealthCheck.hcTlsServerName - }) - .from(targets) - .innerJoin(resources, eq(targets.resourceId, resources.resourceId)) - .leftJoin( - targetHealthCheck, - eq(targets.targetId, targetHealthCheck.targetId) - ) - .where(and(eq(targets.siteId, siteId), eq(targets.enabled, true))); - - const { tcpTargets, udpTargets } = allTargets.reduce( - (acc, target) => { - // Filter out invalid targets - if (!target.internalPort || !target.ip || !target.port) { - return acc; - } - - // Format target into string - const formattedTarget = `${target.internalPort}:${target.ip}:${target.port}`; - - // Add to the appropriate protocol array - if (target.protocol === "tcp") { - acc.tcpTargets.push(formattedTarget); - } else { - acc.udpTargets.push(formattedTarget); - } - - return acc; - }, - { tcpTargets: [] as string[], udpTargets: [] as string[] } - ); - - const healthCheckTargets = allTargets.map((target) => { - // make sure the stuff is defined - if ( - !target.hcPath || - !target.hcHostname || - !target.hcPort || - !target.hcInterval || - !target.hcMethod - ) { - logger.debug( - `Skipping target ${target.targetId} due to missing health check fields` - ); - return null; // Skip targets with missing health check fields - } - - // parse headers - const hcHeadersParse = target.hcHeaders - ? JSON.parse(target.hcHeaders) - : null; - const hcHeadersSend: { [key: string]: string } = {}; - if (hcHeadersParse) { - hcHeadersParse.forEach( - (header: { name: string; value: string }) => { - hcHeadersSend[header.name] = header.value; - } - ); - } - - return { - id: target.targetId, - hcEnabled: target.hcEnabled, - hcPath: target.hcPath, - hcScheme: target.hcScheme, - hcMode: target.hcMode, - hcHostname: target.hcHostname, - hcPort: target.hcPort, - hcInterval: target.hcInterval, // in seconds - hcUnhealthyInterval: target.hcUnhealthyInterval, // in seconds - hcTimeout: target.hcTimeout, // in seconds - hcHeaders: hcHeadersSend, - hcMethod: target.hcMethod, - hcTlsServerName: target.hcTlsServerName - }; - }); - - // Filter out any null values from health check targets - const validHealthCheckTargets = healthCheckTargets.filter( - (target) => target !== null - ); + const { tcpTargets, udpTargets, validHealthCheckTargets } = + await buildTargetConfigurationForNewtClient(siteId); logger.debug( `Sending health check targets to newt ${newt.newtId}: ${JSON.stringify(validHealthCheckTargets)}` @@ -346,6 +203,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], @@ -353,9 +211,13 @@ export const handleNewtRegisterMessage: MessageHandler = async (context) => { udp: udpTargets, tcp: tcpTargets }, - healthCheckTargets: validHealthCheckTargets + healthCheckTargets: validHealthCheckTargets, + chainId: chainId } }, + options: { + compress: canCompress(newt.version, "newt") + }, broadcast: false, // Send to all clients excludeSender: false // Include sender in broadcast }; diff --git a/server/routers/newt/handleReceiveBandwidthMessage.ts b/server/routers/newt/handleReceiveBandwidthMessage.ts index 3d060a0c1..f086333e7 100644 --- a/server/routers/newt/handleReceiveBandwidthMessage.ts +++ b/server/routers/newt/handleReceiveBandwidthMessage.ts @@ -1,7 +1,7 @@ import { db } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; -import { clients, Newt } from "@server/db"; -import { eq } from "drizzle-orm"; +import { clients } from "@server/db"; +import { eq, sql } from "drizzle-orm"; import logger from "@server/logger"; interface PeerBandwidth { @@ -10,13 +10,152 @@ interface PeerBandwidth { bytesOut: number; } +interface BandwidthAccumulator { + bytesIn: number; + bytesOut: number; +} + +// Retry configuration for deadlock handling +const MAX_RETRIES = 3; +const BASE_DELAY_MS = 50; + +// How often to flush accumulated bandwidth data to the database +const FLUSH_INTERVAL_MS = 120_000; // 120 seconds + +// In-memory accumulator: publicKey -> { bytesIn, bytesOut } +let accumulator = new Map(); + +/** + * Check if an error is a deadlock error + */ +function isDeadlockError(error: any): boolean { + return ( + error?.code === "40P01" || + error?.cause?.code === "40P01" || + (error?.message && error.message.includes("deadlock")) + ); +} + +/** + * Execute a function with retry logic for deadlock handling + */ +async function withDeadlockRetry( + operation: () => Promise, + context: string +): Promise { + let attempt = 0; + while (true) { + try { + return await operation(); + } catch (error: any) { + if (isDeadlockError(error) && attempt < MAX_RETRIES) { + attempt++; + const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS; + const jitter = Math.random() * baseDelay; + const delay = baseDelay + jitter; + logger.warn( + `Deadlock detected in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms` + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + throw error; + } + } +} + +/** + * Flush all accumulated bandwidth data to the database. + * + * Swaps out the accumulator before writing so that any bandwidth messages + * received during the flush are captured in the new accumulator rather than + * being lost or causing contention. Entries that fail to write are re-queued + * back into the accumulator so they will be retried on the next flush. + * + * This function is exported so that the application's graceful-shutdown + * cleanup handler can call it before the process exits. + */ +export async function flushBandwidthToDb(): Promise { + if (accumulator.size === 0) { + return; + } + + // Atomically swap out the accumulator so new data keeps flowing in + // while we write the snapshot to the database. + const snapshot = accumulator; + accumulator = new Map(); + + const currentTime = new Date().toISOString(); + + // Sort by publicKey for consistent lock ordering across concurrent + // writers — this is the same deadlock-prevention strategy used in the + // original per-message implementation. + const sortedEntries = [...snapshot.entries()].sort(([a], [b]) => + a.localeCompare(b) + ); + + logger.debug( + `Flushing accumulated bandwidth data for ${sortedEntries.length} client(s) to the database` + ); + + for (const [publicKey, { bytesIn, bytesOut }] of sortedEntries) { + try { + await withDeadlockRetry(async () => { + // Use atomic SQL increment to avoid the SELECT-then-UPDATE + // anti-pattern and the races it would introduce. + await db + .update(clients) + .set({ + // Note: bytesIn from peer goes to megabytesOut (data + // sent to client) and bytesOut from peer goes to + // megabytesIn (data received from client). + megabytesOut: sql`COALESCE(${clients.megabytesOut}, 0) + ${bytesIn}`, + megabytesIn: sql`COALESCE(${clients.megabytesIn}, 0) + ${bytesOut}`, + lastBandwidthUpdate: currentTime + }) + .where(eq(clients.pubKey, publicKey)); + }, `flush bandwidth for client ${publicKey}`); + } catch (error) { + logger.error( + `Failed to flush bandwidth for client ${publicKey}:`, + error + ); + + // Re-queue the failed entry so it is retried on the next flush + // rather than silently dropped. + const existing = accumulator.get(publicKey); + if (existing) { + existing.bytesIn += bytesIn; + existing.bytesOut += bytesOut; + } else { + accumulator.set(publicKey, { bytesIn, bytesOut }); + } + } + } +} + +const flushTimer = setInterval(async () => { + try { + await flushBandwidthToDb(); + } catch (error) { + logger.error("Unexpected error during periodic bandwidth flush:", error); + } +}, FLUSH_INTERVAL_MS); + +// Calling unref() means this timer will not keep the Node.js event loop alive +// on its own — the process can still exit normally when there is no other work +// left. The graceful-shutdown path (see server/cleanup.ts) will call +// flushBandwidthToDb() explicitly before process.exit(), so no data is lost. +flushTimer.unref(); + export const handleReceiveBandwidthMessage: MessageHandler = async ( context ) => { - const { message, client, sendToClient } = context; + const { message } = context; if (!message.data.bandwidthData) { logger.warn("No bandwidth data provided"); + return; } const bandwidthData: PeerBandwidth[] = message.data.bandwidthData; @@ -25,30 +164,21 @@ export const handleReceiveBandwidthMessage: MessageHandler = async ( throw new Error("Invalid bandwidth data"); } - await db.transaction(async (trx) => { - for (const peer of bandwidthData) { - const { publicKey, bytesIn, bytesOut } = peer; - - // Find the client by public key - const [client] = await trx - .select() - .from(clients) - .where(eq(clients.pubKey, publicKey)) - .limit(1); - - if (!client) { - continue; - } - - // Update the client's bandwidth usage - await trx - .update(clients) - .set({ - megabytesOut: (client.megabytesIn || 0) + bytesIn, - megabytesIn: (client.megabytesOut || 0) + bytesOut, - lastBandwidthUpdate: new Date().toISOString() - }) - .where(eq(clients.clientId, client.clientId)); + // Accumulate the incoming data in memory; the periodic timer (and the + // shutdown hook) will take care of writing it to the database. + for (const { publicKey, bytesIn, bytesOut } of bandwidthData) { + // Skip peers that haven't transferred any data — writing zeros to the + // database would be a no-op anyway. + if (bytesIn <= 0 && bytesOut <= 0) { + continue; } - }); + + const existing = accumulator.get(publicKey); + if (existing) { + existing.bytesIn += bytesIn; + existing.bytesOut += bytesOut; + } else { + accumulator.set(publicKey, { bytesIn, bytesOut }); + } + } }; diff --git a/server/routers/newt/handleSocketMessages.ts b/server/routers/newt/handleSocketMessages.ts index f26f69c97..383ab5541 100644 --- a/server/routers/newt/handleSocketMessages.ts +++ b/server/routers/newt/handleSocketMessages.ts @@ -2,7 +2,7 @@ import { MessageHandler } from "@server/routers/ws"; import logger from "@server/logger"; import { Newt } from "@server/db"; import { applyNewtDockerBlueprint } from "@server/lib/blueprints/applyNewtDockerBlueprint"; -import cache from "@server/lib/cache"; +import cache from "#dynamic/lib/cache"; export const handleDockerStatusMessage: MessageHandler = async (context) => { const { message, client, sendToClient } = context; @@ -24,8 +24,8 @@ export const handleDockerStatusMessage: MessageHandler = async (context) => { if (available) { logger.info(`Newt ${newt.newtId} has Docker socket access`); - cache.set(`${newt.newtId}:socketPath`, socketPath, 0); - cache.set(`${newt.newtId}:isAvailable`, available, 0); + await cache.set(`${newt.newtId}:socketPath`, socketPath, 0); + await cache.set(`${newt.newtId}:isAvailable`, available, 0); } else { logger.warn(`Newt ${newt.newtId} does not have Docker socket access`); } @@ -54,7 +54,7 @@ export const handleDockerContainersMessage: MessageHandler = async ( ); if (containers && containers.length > 0) { - cache.set(`${newt.newtId}:dockerContainers`, containers, 0); + await cache.set(`${newt.newtId}:dockerContainers`, containers, 0); } else { logger.warn(`Newt ${newt.newtId} does not have Docker containers`); } diff --git a/server/routers/newt/index.ts b/server/routers/newt/index.ts index 6b17f3249..33b5caf7c 100644 --- a/server/routers/newt/index.ts +++ b/server/routers/newt/index.ts @@ -6,3 +6,7 @@ export * from "./handleGetConfigMessage"; export * from "./handleSocketMessages"; export * from "./handleNewtPingRequestMessage"; export * from "./handleApplyBlueprintMessage"; +export * from "./handleNewtPingMessage"; +export * from "./handleNewtDisconnectingMessage"; +export * from "./handleConnectionLogMessage"; +export * from "./registerNewt"; diff --git a/server/routers/newt/peers.ts b/server/routers/newt/peers.ts index c7546ff0d..4b74d863d 100644 --- a/server/routers/newt/peers.ts +++ b/server/routers/newt/peers.ts @@ -39,7 +39,7 @@ export async function addPeer( await sendToClient(newtId, { type: "newt/wg/peer/add", data: peer - }).catch((error) => { + }, { incrementConfigVersion: true }).catch((error) => { logger.warn(`Error sending message:`, error); }); @@ -81,7 +81,7 @@ export async function deletePeer( data: { publicKey } - }).catch((error) => { + }, { incrementConfigVersion: true }).catch((error) => { logger.warn(`Error sending message:`, error); }); @@ -128,7 +128,7 @@ export async function updatePeer( publicKey, ...peer } - }).catch((error) => { + }, { incrementConfigVersion: true }).catch((error) => { logger.warn(`Error sending message:`, error); }); diff --git a/server/routers/newt/pingAccumulator.ts b/server/routers/newt/pingAccumulator.ts new file mode 100644 index 000000000..83afd613e --- /dev/null +++ b/server/routers/newt/pingAccumulator.ts @@ -0,0 +1,382 @@ +import { db } from "@server/db"; +import { sites, clients, olms } from "@server/db"; +import { eq, inArray } from "drizzle-orm"; +import logger from "@server/logger"; + +/** + * Ping Accumulator + * + * Instead of writing to the database on every single newt/olm ping (which + * causes pool exhaustion under load, especially with cross-region latency), + * we accumulate pings in memory and flush them to the database periodically + * in a single batch. + * + * This is the same pattern used for bandwidth flushing in + * receiveBandwidth.ts and handleReceiveBandwidthMessage.ts. + * + * Supports two kinds of pings: + * - **Site pings** (from newts): update `sites.online` and `sites.lastPing` + * - **Client pings** (from OLMs): update `clients.online`, `clients.lastPing`, + * `clients.archived`, and optionally reset `olms.archived` + */ + +const FLUSH_INTERVAL_MS = 10_000; // Flush every 10 seconds +const MAX_RETRIES = 2; +const BASE_DELAY_MS = 50; + +// ── Site (newt) pings ────────────────────────────────────────────────── +// Map of siteId -> latest ping timestamp (unix seconds) +const pendingSitePings: Map = new Map(); + +// ── Client (OLM) pings ──────────────────────────────────────────────── +// Map of clientId -> latest ping timestamp (unix seconds) +const pendingClientPings: Map = new Map(); +// Set of olmIds whose `archived` flag should be reset to false +const pendingOlmArchiveResets: Set = new Set(); + +let flushTimer: NodeJS.Timeout | null = null; + +// ── Public API ───────────────────────────────────────────────────────── + +/** + * Record a ping for a newt site. This does NOT write to the database + * immediately. Instead it stores the latest ping timestamp in memory, + * to be flushed periodically by the background timer. + */ +export function recordSitePing(siteId: number): void { + const now = Math.floor(Date.now() / 1000); + pendingSitePings.set(siteId, now); +} + +/** @deprecated Use `recordSitePing` instead. Alias kept for existing call-sites. */ +export const recordPing = recordSitePing; + +/** + * Record a ping for an OLM client. Batches the `clients` table update + * (`online`, `lastPing`, `archived`) and, when `olmArchived` is true, + * also queues an `olms` table update to clear the archived flag. + */ +export function recordClientPing( + clientId: number, + olmId: string, + olmArchived: boolean +): void { + const now = Math.floor(Date.now() / 1000); + pendingClientPings.set(clientId, now); + if (olmArchived) { + pendingOlmArchiveResets.add(olmId); + } +} + +// ── Flush Logic ──────────────────────────────────────────────────────── + +/** + * Flush all accumulated site pings to the database. + */ +async function flushSitePingsToDb(): Promise { + if (pendingSitePings.size === 0) { + return; + } + + // Snapshot and clear so new pings arriving during the flush go into a + // fresh map for the next cycle. + const pingsToFlush = new Map(pendingSitePings); + pendingSitePings.clear(); + + // Sort by siteId for consistent lock ordering (prevents deadlocks) + const sortedEntries = Array.from(pingsToFlush.entries()).sort( + ([a], [b]) => a - b + ); + + const BATCH_SIZE = 50; + for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) { + const batch = sortedEntries.slice(i, i + BATCH_SIZE); + + try { + await withRetry(async () => { + // Group by timestamp for efficient bulk updates + const byTimestamp = new Map(); + for (const [siteId, timestamp] of batch) { + const group = byTimestamp.get(timestamp) || []; + group.push(siteId); + byTimestamp.set(timestamp, group); + } + + if (byTimestamp.size === 1) { + const [timestamp, siteIds] = Array.from( + byTimestamp.entries() + )[0]; + await db + .update(sites) + .set({ + online: true, + lastPing: timestamp + }) + .where(inArray(sites.siteId, siteIds)); + } else { + await db.transaction(async (tx) => { + for (const [timestamp, siteIds] of byTimestamp) { + await tx + .update(sites) + .set({ + online: true, + lastPing: timestamp + }) + .where(inArray(sites.siteId, siteIds)); + } + }); + } + }, "flushSitePingsToDb"); + } catch (error) { + logger.error( + `Failed to flush site ping batch (${batch.length} sites), re-queuing for next cycle`, + { error } + ); + for (const [siteId, timestamp] of batch) { + const existing = pendingSitePings.get(siteId); + if (!existing || existing < timestamp) { + pendingSitePings.set(siteId, timestamp); + } + } + } + } +} + +/** + * Flush all accumulated client (OLM) pings to the database. + */ +async function flushClientPingsToDb(): Promise { + if (pendingClientPings.size === 0 && pendingOlmArchiveResets.size === 0) { + return; + } + + // Snapshot and clear + const pingsToFlush = new Map(pendingClientPings); + pendingClientPings.clear(); + + const olmResetsToFlush = new Set(pendingOlmArchiveResets); + pendingOlmArchiveResets.clear(); + + // ── Flush client pings ───────────────────────────────────────────── + if (pingsToFlush.size > 0) { + const sortedEntries = Array.from(pingsToFlush.entries()).sort( + ([a], [b]) => a - b + ); + + const BATCH_SIZE = 50; + for (let i = 0; i < sortedEntries.length; i += BATCH_SIZE) { + const batch = sortedEntries.slice(i, i + BATCH_SIZE); + + try { + await withRetry(async () => { + const byTimestamp = new Map(); + for (const [clientId, timestamp] of batch) { + const group = byTimestamp.get(timestamp) || []; + group.push(clientId); + byTimestamp.set(timestamp, group); + } + + if (byTimestamp.size === 1) { + const [timestamp, clientIds] = Array.from( + byTimestamp.entries() + )[0]; + await db + .update(clients) + .set({ + lastPing: timestamp, + online: true, + archived: false + }) + .where(inArray(clients.clientId, clientIds)); + } else { + await db.transaction(async (tx) => { + for (const [timestamp, clientIds] of byTimestamp) { + await tx + .update(clients) + .set({ + lastPing: timestamp, + online: true, + archived: false + }) + .where( + inArray(clients.clientId, clientIds) + ); + } + }); + } + }, "flushClientPingsToDb"); + } catch (error) { + logger.error( + `Failed to flush client ping batch (${batch.length} clients), re-queuing for next cycle`, + { error } + ); + for (const [clientId, timestamp] of batch) { + const existing = pendingClientPings.get(clientId); + if (!existing || existing < timestamp) { + pendingClientPings.set(clientId, timestamp); + } + } + } + } + } + + // ── Flush OLM archive resets ─────────────────────────────────────── + if (olmResetsToFlush.size > 0) { + const olmIds = Array.from(olmResetsToFlush).sort(); + + const BATCH_SIZE = 50; + for (let i = 0; i < olmIds.length; i += BATCH_SIZE) { + const batch = olmIds.slice(i, i + BATCH_SIZE); + + try { + await withRetry(async () => { + await db + .update(olms) + .set({ archived: false }) + .where(inArray(olms.olmId, batch)); + }, "flushOlmArchiveResets"); + } catch (error) { + logger.error( + `Failed to flush OLM archive reset batch (${batch.length} olms), re-queuing for next cycle`, + { error } + ); + for (const olmId of batch) { + pendingOlmArchiveResets.add(olmId); + } + } + } + } +} + +/** + * Flush everything — called by the interval timer and during shutdown. + */ +export async function flushPingsToDb(): Promise { + await flushSitePingsToDb(); + await flushClientPingsToDb(); +} + +// ── Retry / Error Helpers ────────────────────────────────────────────── + +/** + * Simple retry wrapper with exponential backoff for transient errors + * (connection timeouts, unexpected disconnects). + */ +async function withRetry( + operation: () => Promise, + context: string +): Promise { + let attempt = 0; + while (true) { + try { + return await operation(); + } catch (error: any) { + if (isTransientError(error) && attempt < MAX_RETRIES) { + attempt++; + const baseDelay = Math.pow(2, attempt - 1) * BASE_DELAY_MS; + const jitter = Math.random() * baseDelay; + const delay = baseDelay + jitter; + logger.warn( + `Transient DB error in ${context}, retrying attempt ${attempt}/${MAX_RETRIES} after ${delay.toFixed(0)}ms` + ); + await new Promise((resolve) => setTimeout(resolve, delay)); + continue; + } + throw error; + } + } +} + +/** + * Detect transient connection errors that are safe to retry. + */ +function isTransientError(error: any): boolean { + if (!error) return false; + + const message = (error.message || "").toLowerCase(); + const causeMessage = (error.cause?.message || "").toLowerCase(); + const code = error.code || ""; + + // Connection timeout / terminated + if ( + message.includes("connection timeout") || + message.includes("connection terminated") || + message.includes("timeout exceeded when trying to connect") || + causeMessage.includes("connection terminated unexpectedly") || + causeMessage.includes("connection timeout") + ) { + return true; + } + + // PostgreSQL deadlock + if (code === "40P01" || message.includes("deadlock")) { + return true; + } + + // ECONNRESET, ECONNREFUSED, EPIPE + if ( + code === "ECONNRESET" || + code === "ECONNREFUSED" || + code === "EPIPE" || + code === "ETIMEDOUT" + ) { + return true; + } + + return false; +} + +// ── Lifecycle ────────────────────────────────────────────────────────── + +/** + * Start the background flush timer. Call this once at server startup. + */ +export function startPingAccumulator(): void { + if (flushTimer) { + return; // Already running + } + + flushTimer = setInterval(async () => { + try { + await flushPingsToDb(); + } catch (error) { + logger.error("Unhandled error in ping accumulator flush", { + error + }); + } + }, FLUSH_INTERVAL_MS); + + // Don't prevent the process from exiting + flushTimer.unref(); + + logger.info( + `Ping accumulator started (flush interval: ${FLUSH_INTERVAL_MS}ms)` + ); +} + +/** + * Stop the background flush timer and perform a final flush. + * Call this during graceful shutdown. + */ +export async function stopPingAccumulator(): Promise { + if (flushTimer) { + clearInterval(flushTimer); + flushTimer = null; + } + + // Final flush to persist any remaining pings + try { + await flushPingsToDb(); + } catch (error) { + logger.error("Error during final ping accumulator flush", { error }); + } + + logger.info("Ping accumulator stopped"); +} + +/** + * Get the number of pending (unflushed) pings. Useful for monitoring. + */ +export function getPendingPingCount(): number { + return pendingSitePings.size + pendingClientPings.size; +} \ No newline at end of file diff --git a/server/routers/newt/registerNewt.ts b/server/routers/newt/registerNewt.ts new file mode 100644 index 000000000..427ac173f --- /dev/null +++ b/server/routers/newt/registerNewt.ts @@ -0,0 +1,266 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { + siteProvisioningKeys, + siteProvisioningKeyOrg, + newts, + orgs, + roles, + roleSites, + sites +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { eq, and, sql } from "drizzle-orm"; +import { fromError } from "zod-validation-error"; +import { verifyPassword, hashPassword } from "@server/auth/password"; +import { + generateId, + generateIdFromEntropySize +} from "@server/auth/sessions/app"; +import { getUniqueSiteName } from "@server/db/names"; +import moment from "moment"; +import { build } from "@server/build"; +import { usageService } from "@server/lib/billing/usageService"; +import { FeatureId } from "@server/lib/billing"; +import { INSPECT_MAX_BYTES } from "buffer"; +import { v } from "@faker-js/faker/dist/airline-Dz1uGqgJ"; + +const bodySchema = z.object({ + provisioningKey: z.string().nonempty() +}); + +export type RegisterNewtBody = z.infer; + +export type RegisterNewtResponse = { + newtId: string; + secret: string; +}; + +export async function registerNewt( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { provisioningKey } = parsedBody.data; + + // Keys are in the format "siteProvisioningKeyId.secret" + const dotIndex = provisioningKey.indexOf("."); + if (dotIndex === -1) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid provisioning key format" + ) + ); + } + + const provisioningKeyId = provisioningKey.substring(0, dotIndex); + const provisioningKeySecret = provisioningKey.substring(dotIndex + 1); + + // Look up the provisioning key by ID, joining to get the orgId + const [keyRecord] = await db + .select({ + siteProvisioningKeyId: + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyHash: + siteProvisioningKeys.siteProvisioningKeyHash, + orgId: siteProvisioningKeyOrg.orgId, + maxBatchSize: siteProvisioningKeys.maxBatchSize, + numUsed: siteProvisioningKeys.numUsed, + validUntil: siteProvisioningKeys.validUntil + }) + .from(siteProvisioningKeys) + .innerJoin( + siteProvisioningKeyOrg, + eq( + siteProvisioningKeys.siteProvisioningKeyId, + siteProvisioningKeyOrg.siteProvisioningKeyId + ) + ) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + provisioningKeyId + ) + ) + .limit(1); + + if (!keyRecord) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Invalid provisioning key" + ) + ); + } + + // Verify the secret portion against the stored hash + const validSecret = await verifyPassword( + provisioningKeySecret, + keyRecord.siteProvisioningKeyHash + ); + if (!validSecret) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Invalid provisioning key" + ) + ); + } + + if (keyRecord.maxBatchSize && keyRecord.numUsed >= keyRecord.maxBatchSize) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Provisioning key has reached its maximum usage" + ) + ); + } + + if (keyRecord.validUntil && new Date(keyRecord.validUntil) < new Date()) { + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "Provisioning key has expired" + ) + ); + } + + const { orgId } = keyRecord; + + // Verify the org exists + const [org] = await db.select().from(orgs).where(eq(orgs.orgId, orgId)); + if (!org) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Organization not found") + ); + } + + // SaaS billing check + if (build == "saas") { + const usage = await usageService.getUsage(orgId, FeatureId.SITES); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectSites = await usageService.checkLimitSet( + orgId, + FeatureId.SITES, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } + ); + if (rejectSites) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Site limit exceeded. Please upgrade your plan." + ) + ); + } + } + + const niceId = await getUniqueSiteName(orgId); + const newtId = generateId(15); + const newtSecret = generateIdFromEntropySize(25); + const secretHash = await hashPassword(newtSecret); + + let newSiteId: number | undefined; + + await db.transaction(async (trx) => { + // Create the site (type "newt", name = niceId) + const [newSite] = await trx + .insert(sites) + .values({ + orgId, + name: niceId, + niceId, + type: "newt", + dockerSocketEnabled: true + }) + .returning(); + + newSiteId = newSite.siteId; + + // Grant admin role access to the new site + const [adminRole] = await trx + .select() + .from(roles) + .where(and(eq(roles.isAdmin, true), eq(roles.orgId, orgId))) + .limit(1); + + if (!adminRole) { + throw new Error(`Admin role not found for org ${orgId}`); + } + + await trx.insert(roleSites).values({ + roleId: adminRole.roleId, + siteId: newSite.siteId + }); + + // Create the newt for this site + await trx.insert(newts).values({ + newtId, + secretHash, + siteId: newSite.siteId, + dateCreated: moment().toISOString() + }); + + // Consume the provisioning key — cascade removes siteProvisioningKeyOrg + await trx + .update(siteProvisioningKeys) + .set({ + lastUsed: moment().toISOString(), + numUsed: sql`${siteProvisioningKeys.numUsed} + 1` + }) + .where( + eq( + siteProvisioningKeys.siteProvisioningKeyId, + provisioningKeyId + ) + ); + + await usageService.add(orgId, FeatureId.SITES, 1, trx); + }); + + logger.info( + `Provisioned new site (ID: ${newSiteId}) and newt (ID: ${newtId}) for org ${orgId} via provisioning key ${provisioningKeyId}` + ); + + return response(res, { + data: { + newtId, + secret: newtSecret + }, + success: true, + error: false, + message: "Newt registered successfully", + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/newt/sync.ts b/server/routers/newt/sync.ts new file mode 100644 index 000000000..6fce13ff3 --- /dev/null +++ b/server/routers/newt/sync.ts @@ -0,0 +1,48 @@ +import { ExitNode, exitNodes, Newt, Site, db } from "@server/db"; +import { eq } from "drizzle-orm"; +import { sendToClient } from "#dynamic/routers/ws"; +import logger from "@server/logger"; +import { + buildClientConfigurationForNewtClient, + buildTargetConfigurationForNewtClient +} from "./buildConfiguration"; +import { canCompress } from "@server/lib/clientVersionChecks"; + +export async function sendNewtSyncMessage(newt: Newt, site: Site) { + const { tcpTargets, udpTargets, validHealthCheckTargets } = + await buildTargetConfigurationForNewtClient(site.siteId); + + let exitNode: ExitNode | undefined; + if (site.exitNodeId) { + [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, site.exitNodeId)) + .limit(1); + } + const { peers, targets } = await buildClientConfigurationForNewtClient( + site, + exitNode + ); + + await sendToClient( + newt.newtId, + { + type: "newt/sync", + data: { + proxyTargets: { + udp: udpTargets, + tcp: tcpTargets + }, + healthCheckTargets: validHealthCheckTargets, + peers: peers, + clientTargets: targets + } + }, + { + compress: canCompress(newt.version, "newt") + } + ).catch((error) => { + logger.warn(`Error sending newt sync message:`, error); + }); +} diff --git a/server/routers/newt/targets.ts b/server/routers/newt/targets.ts index e97aed35d..6a523ebe9 100644 --- a/server/routers/newt/targets.ts +++ b/server/routers/newt/targets.ts @@ -2,13 +2,14 @@ import { Target, TargetHealthCheck, db, targetHealthCheck } from "@server/db"; import { sendToClient } from "#dynamic/routers/ws"; import logger from "@server/logger"; import { eq, inArray } from "drizzle-orm"; +import { canCompress } from "@server/lib/clientVersionChecks"; export async function addTargets( newtId: string, targets: Target[], healthCheckData: TargetHealthCheck[], protocol: string, - port: number | null = null + version?: string | null ) { //create a list of udp and tcp targets const payloadTargets = targets.map((target) => { @@ -22,7 +23,7 @@ export async function addTargets( data: { targets: payloadTargets } - }); + }, { incrementConfigVersion: true, compress: canCompress(version, "newt") }); // Create a map for quick lookup const healthCheckMap = new Map(); @@ -103,14 +104,14 @@ export async function addTargets( data: { targets: validHealthCheckTargets } - }); + }, { incrementConfigVersion: true, compress: canCompress(version, "newt") }); } export async function removeTargets( newtId: string, targets: Target[], protocol: string, - port: number | null = null + version?: string | null ) { //create a list of udp and tcp targets const payloadTargets = targets.map((target) => { @@ -124,7 +125,7 @@ export async function removeTargets( data: { targets: payloadTargets } - }); + }, { incrementConfigVersion: true }); const healthCheckTargets = targets.map((target) => { return target.targetId; @@ -135,5 +136,5 @@ export async function removeTargets( data: { ids: healthCheckTargets } - }); + }, { incrementConfigVersion: true, compress: canCompress(version, "newt") }); } diff --git a/server/routers/olm/archiveUserOlm.ts b/server/routers/olm/archiveUserOlm.ts new file mode 100644 index 000000000..cdb7a5a04 --- /dev/null +++ b/server/routers/olm/archiveUserOlm.ts @@ -0,0 +1,60 @@ +import { NextFunction, Request, Response } from "express"; +import { db } from "@server/db"; +import { olms } from "@server/db"; +import { eq } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; + +const paramsSchema = z + .object({ + userId: z.string(), + olmId: z.string() + }) + .strict(); + +export async function archiveUserOlm( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { olmId } = parsedParams.data; + + await db.transaction(async (trx) => { + await trx + .update(olms) + .set({ archived: true }) + .where(eq(olms.olmId, olmId)); + }); + + return response(res, { + data: null, + success: true, + error: false, + message: "Device archived successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to archive device" + ) + ); + } +} diff --git a/server/routers/olm/buildConfiguration.ts b/server/routers/olm/buildConfiguration.ts new file mode 100644 index 000000000..bc2611b1c --- /dev/null +++ b/server/routers/olm/buildConfiguration.ts @@ -0,0 +1,188 @@ +import { + Client, + clientSiteResourcesAssociationsCache, + clientSitesAssociationsCache, + db, + exitNodes, + siteResources, + sites +} from "@server/db"; +import { + Alias, + generateAliasConfig, + generateRemoteSubnets +} from "@server/lib/ip"; +import logger from "@server/logger"; +import { and, eq } from "drizzle-orm"; +import { addPeer, deletePeer } from "../newt/peers"; +import config from "@server/lib/config"; + +export async function buildSiteConfigurationForOlmClient( + client: Client, + publicKey: string | null, + relay: boolean, + jitMode: boolean = false +) { + const siteConfigurations: { + siteId: number; + name?: string + endpoint?: string + publicKey?: string + serverIP?: string | null + serverPort?: number | null + remoteSubnets?: string[]; + aliases: Alias[]; + }[] = []; + + // Get all sites data + const sitesData = await db + .select() + .from(sites) + .innerJoin( + clientSitesAssociationsCache, + eq(sites.siteId, clientSitesAssociationsCache.siteId) + ) + .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); + + // Process each site + for (const { + sites: site, + clientSitesAssociationsCache: association + } of sitesData) { + const allSiteResources = await db // only get the site resources that this client has access to + .select() + .from(siteResources) + .innerJoin( + clientSiteResourcesAssociationsCache, + eq( + siteResources.siteResourceId, + clientSiteResourcesAssociationsCache.siteResourceId + ) + ) + .where( + and( + eq(siteResources.siteId, site.siteId), + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ) + ) + ); + + if (jitMode) { + // Add site configuration to the array + siteConfigurations.push({ + siteId: site.siteId, + // remoteSubnets: generateRemoteSubnets( + // allSiteResources.map(({ siteResources }) => siteResources) + // ), + aliases: generateAliasConfig( + allSiteResources.map(({ siteResources }) => siteResources) + ) + }); + continue; + } + + if (!site.exitNodeId) { + logger.warn( + `Site ${site.siteId} does not have exit node, skipping` + ); + continue; + } + + // Validate endpoint and hole punch status + if (!site.endpoint) { + logger.warn( + `In olm register: site ${site.siteId} has no endpoint, skipping` + ); + continue; + } + + if (!site.publicKey || site.publicKey == "") { // the site is not ready to accept new peers + logger.warn( + `Site ${site.siteId} has no public key, skipping` + ); + continue; + } + + // if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) { + // logger.warn( + // `Site ${site.siteId} last hole punch is too old, skipping` + // ); + // continue; + // } + + // If public key changed, delete old peer from this site + if (client.pubKey && client.pubKey != publicKey) { + logger.info( + `Public key mismatch. Deleting old peer from site ${site.siteId}...` + ); + await deletePeer(site.siteId, client.pubKey!); + } + + if (!site.subnet) { + logger.warn(`Site ${site.siteId} has no subnet, skipping`); + continue; + } + + const [clientSite] = await db + .select() + .from(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.clientId, client.clientId), + eq(clientSitesAssociationsCache.siteId, site.siteId) + ) + ) + .limit(1); + + // Add the peer to the exit node for this site + if (clientSite.endpoint && publicKey) { + logger.info( + `Adding peer ${publicKey} to site ${site.siteId} with endpoint ${clientSite.endpoint}` + ); + await addPeer(site.siteId, { + publicKey: publicKey, + allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client + endpoint: relay ? "" : clientSite.endpoint + }); + } else { + logger.warn( + `Client ${client.clientId} has no endpoint, skipping peer addition` + ); + } + + let relayEndpoint: string | undefined = undefined; + if (relay) { + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, site.exitNodeId)) + .limit(1); + if (!exitNode) { + logger.warn(`Exit node not found for site ${site.siteId}`); + continue; + } + relayEndpoint = `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`; + } + + // Add site configuration to the array + siteConfigurations.push({ + siteId: site.siteId, + name: site.name, + // relayEndpoint: relayEndpoint, // this can be undefined now if not relayed // lets not do this for now because it would conflict with the hole punch testing + endpoint: site.endpoint, + publicKey: site.publicKey, + serverIP: site.address, + serverPort: site.listenPort, + remoteSubnets: generateRemoteSubnets( + allSiteResources.map(({ siteResources }) => siteResources) + ), + aliases: generateAliasConfig( + allSiteResources.map(({ siteResources }) => siteResources) + ) + }); + } + + return siteConfigurations; +} diff --git a/server/routers/olm/createOlm.ts b/server/routers/olm/createOlm.ts index b5da405e6..68cdcacf8 100644 --- a/server/routers/olm/createOlm.ts +++ b/server/routers/olm/createOlm.ts @@ -46,7 +46,7 @@ export async function createNewt( const { newtId, secret } = parsedBody.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); diff --git a/server/routers/olm/deleteUserOlm.ts b/server/routers/olm/deleteUserOlm.ts index 83a3d16fc..2c2814899 100644 --- a/server/routers/olm/deleteUserOlm.ts +++ b/server/routers/olm/deleteUserOlm.ts @@ -11,6 +11,7 @@ import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; import { sendTerminateClient } from "../client/terminate"; +import { OlmErrorCodes } from "./error"; const paramsSchema = z .object({ @@ -76,6 +77,7 @@ export async function deleteUserOlm( if (olm) { await sendTerminateClient( deletedClient.clientId, + OlmErrorCodes.TERMINATED_DELETED, olm.olmId ); // the olmId needs to be provided because it cant look it up after deletion } diff --git a/server/routers/olm/error.ts b/server/routers/olm/error.ts new file mode 100644 index 000000000..6ea209cea --- /dev/null +++ b/server/routers/olm/error.ts @@ -0,0 +1,104 @@ +import { sendToClient } from "#dynamic/routers/ws"; +// Error codes for registration failures +export const OlmErrorCodes = { + OLM_NOT_FOUND: { + code: "OLM_NOT_FOUND", + message: "The specified device could not be found." + }, + CLIENT_ID_NOT_FOUND: { + code: "CLIENT_ID_NOT_FOUND", + message: "No client ID was provided in the request." + }, + CLIENT_NOT_FOUND: { + code: "CLIENT_NOT_FOUND", + message: "The specified client does not exist." + }, + CLIENT_BLOCKED: { + code: "CLIENT_BLOCKED", + message: + "This client has been blocked in this organization and cannot connect. Please contact your administrator." + }, + CLIENT_PENDING: { + code: "CLIENT_PENDING", + message: + "This client is pending approval and cannot connect yet. Please contact your administrator." + }, + ORG_NOT_FOUND: { + code: "ORG_NOT_FOUND", + message: + "The organization could not be found. Please select a valid organization." + }, + USER_ID_NOT_FOUND: { + code: "USER_ID_NOT_FOUND", + message: "No user ID was provided in the request." + }, + INVALID_USER_SESSION: { + code: "INVALID_USER_SESSION", + message: + "Your user session is invalid or has expired. Please log in again." + }, + USER_ID_MISMATCH: { + code: "USER_ID_MISMATCH", + message: "The provided user ID does not match the session." + }, + ORG_ACCESS_POLICY_DENIED: { + code: "ORG_ACCESS_POLICY_DENIED", + message: + "Access to this organization has been denied by policy. Please contact your administrator." + }, + ORG_ACCESS_POLICY_PASSWORD_EXPIRED: { + code: "ORG_ACCESS_POLICY_PASSWORD_EXPIRED", + message: + "Access to this organization has been denied because your password has expired. Please visit this organization's dashboard to update your password." + }, + ORG_ACCESS_POLICY_SESSION_EXPIRED: { + code: "ORG_ACCESS_POLICY_SESSION_EXPIRED", + message: + "Access to this organization has been denied because your session has expired. Please log in again to refresh the session." + }, + ORG_ACCESS_POLICY_2FA_REQUIRED: { + code: "ORG_ACCESS_POLICY_2FA_REQUIRED", + message: + "Access to this organization requires two-factor authentication. Please visit this organization's dashboard to enable two-factor authentication." + }, + TERMINATED_REKEYED: { + code: "TERMINATED_REKEYED", + message: + "This session was terminated because encryption keys were regenerated." + }, + TERMINATED_ORG_DELETED: { + code: "TERMINATED_ORG_DELETED", + message: + "This session was terminated because the organization was deleted." + }, + TERMINATED_INACTIVITY: { + code: "TERMINATED_INACTIVITY", + message: "This session was terminated due to inactivity." + }, + TERMINATED_DELETED: { + code: "TERMINATED_DELETED", + message: "This session was terminated because it was deleted." + }, + TERMINATED_ARCHIVED: { + code: "TERMINATED_ARCHIVED", + message: "This session was terminated because it was archived." + }, + TERMINATED_BLOCKED: { + code: "TERMINATED_BLOCKED", + message: "This session was terminated because access was blocked." + } +} as const; + +// Helper function to send registration error +export async function sendOlmError( + error: (typeof OlmErrorCodes)[keyof typeof OlmErrorCodes], + olmId: string +) { + sendToClient(olmId, { + type: "olm/error", + data: { + code: error.code, + message: error.message + } + }); +} diff --git a/server/routers/olm/fingerprintingUtils.ts b/server/routers/olm/fingerprintingUtils.ts new file mode 100644 index 000000000..90fafd3cc --- /dev/null +++ b/server/routers/olm/fingerprintingUtils.ts @@ -0,0 +1,224 @@ +import { sha256 } from "@oslojs/crypto/sha2"; +import { encodeHexLowerCase } from "@oslojs/encoding"; +import { currentFingerprint, db, fingerprintSnapshots, Olm } from "@server/db"; +import { calculateCutoffTimestamp } from "@server/lib/cleanupLogs"; +import { desc, eq, lt } from "drizzle-orm"; + +function fingerprintSnapshotHash(fingerprint: any, postures: any): string { + const canonical = { + username: fingerprint.username ?? null, + hostname: fingerprint.hostname ?? null, + platform: fingerprint.platform ?? null, + osVersion: fingerprint.osVersion ?? null, + kernelVersion: fingerprint.kernelVersion ?? null, + arch: fingerprint.arch ?? null, + deviceModel: fingerprint.deviceModel ?? null, + serialNumber: fingerprint.serialNumber ?? null, + platformFingerprint: fingerprint.platformFingerprint ?? null, + + biometricsEnabled: postures.biometricsEnabled ?? false, + diskEncrypted: postures.diskEncrypted ?? false, + firewallEnabled: postures.firewallEnabled ?? false, + autoUpdatesEnabled: postures.autoUpdatesEnabled ?? false, + tpmAvailable: postures.tpmAvailable ?? false, + + windowsAntivirusEnabled: postures.windowsAntivirusEnabled ?? false, + + macosSipEnabled: postures.macosSipEnabled ?? false, + macosGatekeeperEnabled: postures.macosGatekeeperEnabled ?? false, + macosFirewallStealthMode: postures.macosFirewallStealthMode ?? false, + + linuxAppArmorEnabled: postures.linuxAppArmorEnabled ?? false, + linuxSELinuxEnabled: postures.linuxSELinuxEnabled ?? false + }; + + return encodeHexLowerCase( + sha256(new TextEncoder().encode(JSON.stringify(canonical))) + ); +} + +export async function handleFingerprintInsertion( + olm: Olm, + fingerprint: any, + postures: any +) { + if ( + !olm?.olmId || + !fingerprint || + !postures || + Object.keys(fingerprint).length === 0 || + Object.keys(postures).length === 0 + ) { + return; + } + + const now = Math.floor(Date.now() / 1000); + const hash = fingerprintSnapshotHash(fingerprint, postures); + + const [current] = await db + .select() + .from(currentFingerprint) + .where(eq(currentFingerprint.olmId, olm.olmId)) + .limit(1); + + if (!current) { + const [inserted] = await db + .insert(currentFingerprint) + .values({ + olmId: olm.olmId, + firstSeen: now, + lastSeen: now, + lastCollectedAt: now, + + // fingerprint + username: fingerprint.username, + hostname: fingerprint.hostname, + platform: fingerprint.platform, + osVersion: fingerprint.osVersion, + kernelVersion: fingerprint.kernelVersion, + arch: fingerprint.arch, + deviceModel: fingerprint.deviceModel, + serialNumber: fingerprint.serialNumber, + platformFingerprint: fingerprint.platformFingerprint, + + biometricsEnabled: postures.biometricsEnabled, + diskEncrypted: postures.diskEncrypted, + firewallEnabled: postures.firewallEnabled, + autoUpdatesEnabled: postures.autoUpdatesEnabled, + tpmAvailable: postures.tpmAvailable, + + windowsAntivirusEnabled: postures.windowsAntivirusEnabled, + + macosSipEnabled: postures.macosSipEnabled, + macosGatekeeperEnabled: postures.macosGatekeeperEnabled, + macosFirewallStealthMode: postures.macosFirewallStealthMode, + + linuxAppArmorEnabled: postures.linuxAppArmorEnabled, + linuxSELinuxEnabled: postures.linuxSELinuxEnabled + }) + .returning(); + + await db.insert(fingerprintSnapshots).values({ + fingerprintId: inserted.fingerprintId, + + username: fingerprint.username, + hostname: fingerprint.hostname, + platform: fingerprint.platform, + osVersion: fingerprint.osVersion, + kernelVersion: fingerprint.kernelVersion, + arch: fingerprint.arch, + deviceModel: fingerprint.deviceModel, + serialNumber: fingerprint.serialNumber, + platformFingerprint: fingerprint.platformFingerprint, + + biometricsEnabled: postures.biometricsEnabled, + diskEncrypted: postures.diskEncrypted, + firewallEnabled: postures.firewallEnabled, + autoUpdatesEnabled: postures.autoUpdatesEnabled, + tpmAvailable: postures.tpmAvailable, + + windowsAntivirusEnabled: postures.windowsAntivirusEnabled, + + macosSipEnabled: postures.macosSipEnabled, + macosGatekeeperEnabled: postures.macosGatekeeperEnabled, + macosFirewallStealthMode: postures.macosFirewallStealthMode, + + linuxAppArmorEnabled: postures.linuxAppArmorEnabled, + linuxSELinuxEnabled: postures.linuxSELinuxEnabled, + + hash, + collectedAt: now + }); + + return; + } + + const [latestSnapshot] = await db + .select({ hash: fingerprintSnapshots.hash }) + .from(fingerprintSnapshots) + .where(eq(fingerprintSnapshots.fingerprintId, current.fingerprintId)) + .orderBy(desc(fingerprintSnapshots.collectedAt)) + .limit(1); + + const changed = !latestSnapshot || latestSnapshot.hash !== hash; + + if (changed) { + await db.insert(fingerprintSnapshots).values({ + fingerprintId: current.fingerprintId, + + username: fingerprint.username, + hostname: fingerprint.hostname, + platform: fingerprint.platform, + osVersion: fingerprint.osVersion, + kernelVersion: fingerprint.kernelVersion, + arch: fingerprint.arch, + deviceModel: fingerprint.deviceModel, + serialNumber: fingerprint.serialNumber, + platformFingerprint: fingerprint.platformFingerprint, + + biometricsEnabled: postures.biometricsEnabled, + diskEncrypted: postures.diskEncrypted, + firewallEnabled: postures.firewallEnabled, + autoUpdatesEnabled: postures.autoUpdatesEnabled, + tpmAvailable: postures.tpmAvailable, + + windowsAntivirusEnabled: postures.windowsAntivirusEnabled, + + macosSipEnabled: postures.macosSipEnabled, + macosGatekeeperEnabled: postures.macosGatekeeperEnabled, + macosFirewallStealthMode: postures.macosFirewallStealthMode, + + linuxAppArmorEnabled: postures.linuxAppArmorEnabled, + linuxSELinuxEnabled: postures.linuxSELinuxEnabled, + + hash, + collectedAt: now + }); + + await db + .update(currentFingerprint) + .set({ + lastSeen: now, + lastCollectedAt: now, + + username: fingerprint.username, + hostname: fingerprint.hostname, + platform: fingerprint.platform, + osVersion: fingerprint.osVersion, + kernelVersion: fingerprint.kernelVersion, + arch: fingerprint.arch, + deviceModel: fingerprint.deviceModel, + serialNumber: fingerprint.serialNumber, + platformFingerprint: fingerprint.platformFingerprint, + + biometricsEnabled: postures.biometricsEnabled, + diskEncrypted: postures.diskEncrypted, + firewallEnabled: postures.firewallEnabled, + autoUpdatesEnabled: postures.autoUpdatesEnabled, + tpmAvailable: postures.tpmAvailable, + + windowsAntivirusEnabled: postures.windowsAntivirusEnabled, + + macosSipEnabled: postures.macosSipEnabled, + macosGatekeeperEnabled: postures.macosGatekeeperEnabled, + macosFirewallStealthMode: postures.macosFirewallStealthMode, + + linuxAppArmorEnabled: postures.linuxAppArmorEnabled, + linuxSELinuxEnabled: postures.linuxSELinuxEnabled + }) + .where(eq(currentFingerprint.fingerprintId, current.fingerprintId)); + } else { + await db + .update(currentFingerprint) + .set({ lastSeen: now }) + .where(eq(currentFingerprint.fingerprintId, current.fingerprintId)); + } +} + +export async function cleanUpOldFingerprintSnapshots(retentionDays: number) { + const cutoff = calculateCutoffTimestamp(retentionDays); + + await db + .delete(fingerprintSnapshots) + .where(lt(fingerprintSnapshots.collectedAt, cutoff)); +} diff --git a/server/routers/olm/getOlmToken.ts b/server/routers/olm/getOlmToken.ts index 3852b00ed..5b8411eb7 100644 --- a/server/routers/olm/getOlmToken.ts +++ b/server/routers/olm/getOlmToken.ts @@ -1,11 +1,14 @@ -import { generateSessionToken } from "@server/auth/sessions/app"; +import { + generateSessionToken, + validateSessionToken +} from "@server/auth/sessions/app"; import { clients, db, ExitNode, exitNodes, sites, - clientSitesAssociationsCache + clientSitesAssociationsCache, } from "@server/db"; import { olms } from "@server/db"; import HttpCode from "@server/types/HttpCode"; @@ -17,8 +20,10 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import { createOlmSession, - validateOlmSessionToken + validateOlmSessionToken, + EXPIRES } from "@server/auth/sessions/olm"; +import { getOrCreateCachedToken } from "#dynamic/lib/tokenCache"; import { verifyPassword } from "@server/auth/password"; import logger from "@server/logger"; import config from "@server/lib/config"; @@ -26,8 +31,9 @@ import { APP_VERSION } from "@server/lib/consts"; export const olmGetTokenBodySchema = z.object({ olmId: z.string(), - secret: z.string(), - token: z.string().optional(), + secret: z.string().optional(), + userToken: z.string().optional(), + token: z.string().optional(), // this is the olm token orgId: z.string().optional() }); @@ -49,7 +55,7 @@ export async function getOlmToken( ); } - const { olmId, secret, token, orgId } = parsedBody.data; + const { olmId, secret, token, orgId, userToken } = parsedBody.data; try { if (token) { @@ -84,26 +90,63 @@ export async function getOlmToken( ); } - const validSecret = await verifyPassword( - secret, - existingOlm.secretHash - ); - - if (!validSecret) { - if (config.getRawConfig().app.log_failed_attempts) { - logger.info( - `Olm id or secret is incorrect. Olm: ID ${olmId}. IP: ${req.ip}.` + if (userToken) { + const { session: userSession, user } = + await validateSessionToken(userToken); + if (!userSession || !user) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid user token") ); } + if (user.userId !== existingOlm.userId) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "User token does not match olm" + ) + ); + } + } else if (secret) { + // this is for backward compatibility, we want to move towards userToken but some old clients may still be using secret so we will support both for now + const validSecret = await verifyPassword( + secret, + existingOlm.secretHash + ); + + if (!validSecret) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Olm id or secret is incorrect. Olm: ID ${olmId}. IP: ${req.ip}.` + ); + } + return next( + createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect") + ); + } + } else { return next( - createHttpError(HttpCode.BAD_REQUEST, "Secret is incorrect") + createHttpError( + HttpCode.BAD_REQUEST, + "Either secret or userToken is required" + ) ); } logger.debug("Creating new olm session token"); - const resToken = generateSessionToken(); - await createOlmSession(resToken, existingOlm.olmId); + // Return a cached token if one exists to prevent thundering herd on + // simultaneous restarts; falls back to creating a fresh session when + // Redis is unavailable or the cache has expired. + const resToken = await getOrCreateCachedToken( + `olm:token_cache:${existingOlm.olmId}`, + config.getRawConfig().server.secret!, + Math.floor(EXPIRES / 1000), + async () => { + const token = generateSessionToken(); + await createOlmSession(token, existingOlm.olmId); + return token; + } + ); let clientIdToUse; if (orgId) { @@ -194,10 +237,23 @@ export async function getOlmToken( .where(inArray(exitNodes.exitNodeId, exitNodeIds)); } + // Map exitNodeId to siteIds + const exitNodeIdToSiteIds: Record = {}; + for (const { sites: site } of clientSites) { + if (site.exitNodeId !== null) { + if (!exitNodeIdToSiteIds[site.exitNodeId]) { + exitNodeIdToSiteIds[site.exitNodeId] = []; + } + exitNodeIdToSiteIds[site.exitNodeId].push(site.siteId); + } + } + const exitNodesHpData = allExitNodes.map((exitNode: ExitNode) => { return { publicKey: exitNode.publicKey, - endpoint: exitNode.endpoint + relayPort: config.getRawConfig().gerbil.clients_start_port, + endpoint: exitNode.endpoint, + siteIds: exitNodeIdToSiteIds[exitNode.exitNodeId] ?? [] }; }); diff --git a/server/routers/olm/getUserOlm.ts b/server/routers/olm/getUserOlm.ts index aa9b89af6..f7ba038a7 100644 --- a/server/routers/olm/getUserOlm.ts +++ b/server/routers/olm/getUserOlm.ts @@ -1,6 +1,6 @@ import { NextFunction, Request, Response } from "express"; import { db } from "@server/db"; -import { olms } from "@server/db"; +import { olms, clients, currentFingerprint } from "@server/db"; import { eq, and } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -8,7 +8,8 @@ import response from "@server/lib/response"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; -import { OpenAPITags, registry } from "@server/openApi"; +import { getUserDeviceName } from "@server/db/names"; +// import { OpenAPITags, registry } from "@server/openApi"; const paramsSchema = z .object({ @@ -17,6 +18,10 @@ const paramsSchema = z }) .strict(); +const querySchema = z.object({ + orgId: z.string().optional() +}); + // registry.registerPath({ // method: "get", // path: "/user/{userId}/olm/{olmId}", @@ -44,15 +49,63 @@ export async function getUserOlm( ); } - const { olmId, userId } = parsedParams.data; + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } - const [olm] = await db + const { olmId, userId } = parsedParams.data; + const { orgId } = parsedQuery.data; + + const [result] = await db .select() .from(olms) - .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId))); + .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId))) + .leftJoin( + currentFingerprint, + eq(olms.olmId, currentFingerprint.olmId) + ) + .limit(1); + + if (!result || !result.olms) { + return next(createHttpError(HttpCode.NOT_FOUND, "Olm not found")); + } + + const olm = result.olms; + + // If orgId is provided and olm has a clientId, fetch the client to check blocked status + let blocked: boolean | undefined; + if (orgId && olm.clientId) { + const [client] = await db + .select({ blocked: clients.blocked }) + .from(clients) + .where( + and( + eq(clients.clientId, olm.clientId), + eq(clients.orgId, orgId) + ) + ) + .limit(1); + + blocked = client?.blocked ?? false; + } + + // Replace name with device name + const model = result.currentFingerprint?.deviceModel || null; + const newName = getUserDeviceName(model, olm.name); + + const responseData = + blocked !== undefined + ? { ...olm, name: newName, blocked } + : { ...olm, name: newName }; return response(res, { - data: olm, + data: responseData, success: true, error: false, message: "Successfully retrieved olm", diff --git a/server/routers/olm/handleOlmDisconnectingMessage.ts b/server/routers/olm/handleOlmDisconnectingMessage.ts new file mode 100644 index 000000000..ecd101724 --- /dev/null +++ b/server/routers/olm/handleOlmDisconnectingMessage.ts @@ -0,0 +1,34 @@ +import { MessageHandler } from "@server/routers/ws"; +import { clients, db, Olm } from "@server/db"; +import { eq } from "drizzle-orm"; +import logger from "@server/logger"; + +/** + * Handles disconnecting messages from clients to show disconnected in the ui + */ +export const handleOlmDisconnectingMessage: MessageHandler = async (context) => { + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no client ID!"); + return; + } + + try { + // Update the client's last ping timestamp + await db + .update(clients) + .set({ + online: false + }) + .where(eq(clients.clientId, olm.clientId)); + } catch (error) { + logger.error("Error handling disconnecting message", { error }); + } +}; diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts index 0fa490c82..0f520b234 100644 --- a/server/routers/olm/handleOlmPingMessage.ts +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -1,14 +1,18 @@ +import { disconnectClient, getClientConfigVersion } from "#dynamic/routers/ws"; import { db } from "@server/db"; -import { disconnectClient } from "#dynamic/routers/ws"; import { MessageHandler } from "@server/routers/ws"; -import { clients, Olm } from "@server/db"; +import { clients, olms, Olm } from "@server/db"; import { eq, lt, isNull, and, or } from "drizzle-orm"; +import { recordClientPing } from "@server/routers/newt/pingAccumulator"; import logger from "@server/logger"; import { validateSessionToken } from "@server/auth/sessions/app"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { sendTerminateClient } from "../client/terminate"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; +import { sendOlmSyncMessage } from "./sync"; +import { OlmErrorCodes } from "./error"; +import { handleFingerprintInsertion } from "./fingerprintingUtils"; // Track if the offline checker interval is running let offlineCheckerInterval: NodeJS.Timeout | null = null; @@ -63,6 +67,7 @@ export const startOlmOfflineChecker = (): void => { try { await sendTerminateClient( offlineClient.clientId, + OlmErrorCodes.TERMINATED_INACTIVITY, offlineClient.olmId ); // terminate first // wait a moment to ensure the message is sent @@ -101,79 +106,116 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { const { message, client: c, sendToClient } = context; const olm = c as Olm; - const { userToken } = message.data; + const { userToken, fingerprint, postures } = message.data; if (!olm) { logger.warn("Olm not found"); return; } - if (olm.userId) { - // we need to check a user token to make sure its still valid - const { session: userSession, user } = - await validateSessionToken(userToken); - if (!userSession || !user) { - logger.warn("Invalid user session for olm ping"); - return; // by returning here we just ignore the ping and the setInterval will force it to disconnect - } - if (user.userId !== olm.userId) { - logger.warn("User ID mismatch for olm ping"); - return; - } - - // get the client - const [client] = await db - .select() - .from(clients) - .where( - and( - eq(clients.olmId, olm.olmId), - eq(clients.userId, olm.userId) - ) - ) - .limit(1); - - if (!client) { - logger.warn("Client not found for olm ping"); - return; - } - - const sessionId = encodeHexLowerCase( - sha256(new TextEncoder().encode(userToken)) - ); - - const policyCheck = await checkOrgAccessPolicy({ - orgId: client.orgId, - userId: olm.userId, - sessionId // this is the user token passed in the message - }); - - if (!policyCheck.allowed) { - logger.warn( - `Olm user ${olm.userId} does not pass access policies for org ${client.orgId}: ${policyCheck.error}` - ); - return; - } - } - if (!olm.clientId) { logger.warn("Olm has no client ID!"); return; } + const isUserDevice = olm.userId !== null && olm.userId !== undefined; + try { - // Update the client's last ping timestamp - await db - .update(clients) - .set({ - lastPing: Math.floor(Date.now() / 1000), - online: true - }) - .where(eq(clients.clientId, olm.clientId)); + // get the client + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, olm.clientId)) + .limit(1); + + if (!client) { + logger.warn("Client not found for olm ping"); + return; + } + + if (client.blocked) { + // NOTE: by returning we dont update the lastPing, so the offline checker will eventually disconnect them + logger.debug( + `Blocked client ${client.clientId} attempted olm ping` + ); + return; + } + + if (olm.userId) { + // we need to check a user token to make sure its still valid + const { session: userSession, user } = + await validateSessionToken(userToken); + if (!userSession || !user) { + logger.warn("Invalid user session for olm ping"); + return; // by returning here we just ignore the ping and the setInterval will force it to disconnect + } + if (user.userId !== olm.userId) { + logger.warn("User ID mismatch for olm ping"); + return; + } + if (user.userId !== client.userId) { + logger.warn("Client user ID mismatch for olm ping"); + return; + } + + const sessionId = encodeHexLowerCase( + sha256(new TextEncoder().encode(userToken)) + ); + + const policyCheck = await checkOrgAccessPolicy({ + orgId: client.orgId, + userId: olm.userId, + sessionId // this is the user token passed in the message + }); + + if (!policyCheck.allowed) { + logger.warn( + `Olm user ${olm.userId} does not pass access policies for org ${client.orgId}: ${policyCheck.error}` + ); + return; + } + } + + // get the version + logger.debug( + `handleOlmPingMessage: About to get config version for olmId: ${olm.olmId}` + ); + const configVersion = await getClientConfigVersion(olm.olmId); + logger.debug( + `handleOlmPingMessage: Got config version: ${configVersion} (type: ${typeof configVersion})` + ); + + if (configVersion == null || configVersion === undefined) { + logger.debug( + `handleOlmPingMessage: could not get config version from server for olmId: ${olm.olmId}` + ); + } + + if ( + message.configVersion != null && + configVersion != null && + configVersion != message.configVersion + ) { + logger.debug( + `handleOlmPingMessage: Olm ping with outdated config version: ${message.configVersion} (current: ${configVersion})` + ); + await sendOlmSyncMessage(olm, client); + } + + // Record the ping in memory; it will be flushed to the database + // periodically by the ping accumulator (every ~10s) in a single + // batched UPDATE instead of one query per ping. This prevents + // connection pool exhaustion under load, especially with + // cross-region latency to the database. + recordClientPing(olm.clientId, olm.olmId, !!olm.archived); } catch (error) { logger.error("Error handling ping message", { error }); } + if (isUserDevice) { + await handleFingerprintInsertion(olm, fingerprint, postures); + } + return { message: { type: "pong", diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 0f71ee8b3..26dbff1bd 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -1,28 +1,25 @@ -import { - clientSiteResourcesAssociationsCache, - db, - orgs, - siteResources -} from "@server/db"; +import { db, orgs } from "@server/db"; import { MessageHandler } from "@server/routers/ws"; import { clients, clientSitesAssociationsCache, - exitNodes, Olm, olms, sites } from "@server/db"; -import { and, eq, inArray, isNull } from "drizzle-orm"; -import { addPeer, deletePeer } from "../newt/peers"; +import { count, eq } from "drizzle-orm"; import logger from "@server/logger"; -import { generateAliasConfig } from "@server/lib/ip"; -import { generateRemoteSubnets } from "@server/lib/ip"; import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { validateSessionToken } from "@server/auth/sessions/app"; -import config from "@server/lib/config"; import { encodeHexLowerCase } from "@oslojs/encoding"; import { sha256 } from "@oslojs/crypto/sha2"; +import { getUserDeviceName } from "@server/db/names"; +import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; +import { OlmErrorCodes, sendOlmError } from "./error"; +import { handleFingerprintInsertion } from "./fingerprintingUtils"; +import { Alias } from "@server/lib/ip"; +import { build } from "@server/build"; +import { canCompress } from "@server/lib/clientVersionChecks"; export const handleOlmRegisterMessage: MessageHandler = async (context) => { logger.info("Handling register olm message!"); @@ -36,14 +33,51 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { return; } - const { publicKey, relay, olmVersion, olmAgent, orgId, userToken } = - message.data; + const { + publicKey, + relay, + olmVersion, + olmAgent, + orgId, + userToken, + fingerprint, + postures, + chainId + } = message.data; if (!olm.clientId) { logger.warn("Olm client ID not found"); + sendOlmError(OlmErrorCodes.CLIENT_ID_NOT_FOUND, olm.olmId); return; } + logger.debug("Handling fingerprint insertion for olm register...", { + olmId: olm.olmId, + fingerprint, + postures + }); + + const isUserDevice = olm.userId !== null && olm.userId !== undefined; + + if (isUserDevice) { + await handleFingerprintInsertion(olm, fingerprint, postures); + } + + if ( + (olmVersion && olm.version !== olmVersion) || + (olmAgent && olm.agent !== olmAgent) || + olm.archived + ) { + await db + .update(olms) + .set({ + version: olmVersion, + agent: olmAgent, + archived: false + }) + .where(eq(olms.olmId, olm.olmId)); + } + const [client] = await db .select() .from(clients) @@ -52,9 +86,41 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { if (!client) { logger.warn("Client ID not found"); + sendOlmError(OlmErrorCodes.CLIENT_NOT_FOUND, olm.olmId); return; } + if (client.blocked) { + logger.debug( + `Client ${client.clientId} is blocked. Ignoring register.` + ); + sendOlmError(OlmErrorCodes.CLIENT_BLOCKED, olm.olmId); + return; + } + + if (client.approvalState == "pending") { + logger.debug( + `Client ${client.clientId} approval is pending. Ignoring register.` + ); + sendOlmError(OlmErrorCodes.CLIENT_PENDING, olm.olmId); + return; + } + + const deviceModel = fingerprint?.deviceModel ?? null; + const computedName = getUserDeviceName(deviceModel, client.name); + if (computedName && computedName !== client.name) { + await db + .update(clients) + .set({ name: computedName }) + .where(eq(clients.clientId, client.clientId)); + } + if (computedName && computedName !== olm.name) { + await db + .update(olms) + .set({ name: computedName }) + .where(eq(olms.olmId, olm.olmId)); + } + const [org] = await db .select() .from(orgs) @@ -63,12 +129,14 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { if (!org) { logger.warn("Org not found"); + sendOlmError(OlmErrorCodes.ORG_NOT_FOUND, olm.olmId); return; } if (orgId) { if (!olm.userId) { logger.warn("Olm has no user ID"); + sendOlmError(OlmErrorCodes.USER_ID_NOT_FOUND, olm.olmId); return; } @@ -76,10 +144,12 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { await validateSessionToken(userToken); if (!userSession || !user) { logger.warn("Invalid user session for olm register"); - return; // by returning here we just ignore the ping and the setInterval will force it to disconnect + sendOlmError(OlmErrorCodes.INVALID_USER_SESSION, olm.olmId); + return; } if (user.userId !== olm.userId) { logger.warn("User ID mismatch for olm register"); + sendOlmError(OlmErrorCodes.USER_ID_MISMATCH, olm.olmId); return; } @@ -93,14 +163,80 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { sessionId // this is the user token passed in the message }); - if (!policyCheck.allowed) { + logger.debug("Policy check result:", policyCheck); + + if (policyCheck?.error) { + logger.error( + `Error checking access policies for olm user ${olm.userId} in org ${orgId}: ${policyCheck?.error}` + ); + sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId); + return; + } + + if (policyCheck.policies?.passwordAge?.compliant === false) { + logger.warn( + `Olm user ${olm.userId} has non-compliant password age for org ${orgId}` + ); + sendOlmError( + OlmErrorCodes.ORG_ACCESS_POLICY_PASSWORD_EXPIRED, + olm.olmId + ); + return; + } else if ( + policyCheck.policies?.maxSessionLength?.compliant === false + ) { + logger.warn( + `Olm user ${olm.userId} has non-compliant session length for org ${orgId}` + ); + sendOlmError( + OlmErrorCodes.ORG_ACCESS_POLICY_SESSION_EXPIRED, + olm.olmId + ); + return; + } else if (policyCheck.policies?.requiredTwoFactor === false) { + logger.warn( + `Olm user ${olm.userId} does not have 2FA enabled for org ${orgId}` + ); + sendOlmError( + OlmErrorCodes.ORG_ACCESS_POLICY_2FA_REQUIRED, + olm.olmId + ); + return; + } else if (!policyCheck.allowed) { logger.warn( `Olm user ${olm.userId} does not pass access policies for org ${orgId}: ${policyCheck.error}` ); + sendOlmError(OlmErrorCodes.ORG_ACCESS_POLICY_DENIED, olm.olmId); return; } } + // Get all sites data + const sitesCountResult = await db + .select({ count: count() }) + .from(sites) + .innerJoin( + clientSitesAssociationsCache, + eq(sites.siteId, clientSitesAssociationsCache.siteId) + ) + .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); + + // Extract the count value from the result array + const sitesCount = + sitesCountResult.length > 0 ? sitesCountResult[0].count : 0; + + // Prepare an array to store site configurations + logger.debug(`Found ${sitesCount} sites for client ${client.clientId}`); + + let jitMode = false; + if (sitesCount > 250 && build == "saas") { + // THIS IS THE MAX ON THE BUSINESS TIER + // we have too many sites + // If we have too many sites we need to drop into fully JIT mode by not sending any of the sites + logger.info("Too many sites (%d), dropping into JIT mode", sitesCount); + jitMode = true; + } + logger.debug( `Olm client ID: ${client.clientId}, Public Key: ${publicKey}, Relay: ${relay}` ); @@ -110,20 +246,7 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { return; } - if ( - (olmVersion && olm.version !== olmVersion) || - (olmAgent && olm.agent !== olmAgent) - ) { - await db - .update(olms) - .set({ - version: olmVersion, - agent: olmAgent - }) - .where(eq(olms.olmId, olm.olmId)); - } - - if (client.pubKey !== publicKey) { + if (client.pubKey !== publicKey || client.archived) { logger.info( "Public key mismatch. Updating public key and clearing session info..." ); @@ -131,7 +254,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { await db .update(clients) .set({ - pubKey: publicKey + pubKey: publicKey, + archived: false }) .where(eq(clients.clientId, client.clientId)); @@ -139,161 +263,29 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { await db .update(clientSitesAssociationsCache) .set({ - isRelayed: relay == true + isRelayed: relay == true, + isJitMode: jitMode }) .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); } - // Get all sites data - const sitesData = await db - .select() - .from(sites) - .innerJoin( - clientSitesAssociationsCache, - eq(sites.siteId, clientSitesAssociationsCache.siteId) - ) - .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); - - // Prepare an array to store site configurations - const siteConfigurations = []; - logger.debug( - `Found ${sitesData.length} sites for client ${client.clientId}` - ); - // this prevents us from accepting a register from an olm that has not hole punched yet. // the olm will pump the register so we can keep checking // TODO: I still think there is a better way to do this rather than locking it out here but ??? - if (now - (client.lastHolePunch || 0) > 5 && sitesData.length > 0) { + if (now - (client.lastHolePunch || 0) > 5 && sitesCount > 0) { logger.warn( "Client last hole punch is too old and we have sites to send; skipping this register" ); return; } - // Process each site - for (const { - sites: site, - clientSitesAssociationsCache: association - } of sitesData) { - if (!site.exitNodeId) { - logger.warn( - `Site ${site.siteId} does not have exit node, skipping` - ); - continue; - } - - // Validate endpoint and hole punch status - if (!site.endpoint) { - logger.warn( - `In olm register: site ${site.siteId} has no endpoint, skipping` - ); - continue; - } - - // if (site.lastHolePunch && now - site.lastHolePunch > 6 && relay) { - // logger.warn( - // `Site ${site.siteId} last hole punch is too old, skipping` - // ); - // continue; - // } - - // If public key changed, delete old peer from this site - if (client.pubKey && client.pubKey != publicKey) { - logger.info( - `Public key mismatch. Deleting old peer from site ${site.siteId}...` - ); - await deletePeer(site.siteId, client.pubKey!); - } - - if (!site.subnet) { - logger.warn(`Site ${site.siteId} has no subnet, skipping`); - continue; - } - - const [clientSite] = await db - .select() - .from(clientSitesAssociationsCache) - .where( - and( - eq(clientSitesAssociationsCache.clientId, client.clientId), - eq(clientSitesAssociationsCache.siteId, site.siteId) - ) - ) - .limit(1); - - // Add the peer to the exit node for this site - if (clientSite.endpoint) { - logger.info( - `Adding peer ${publicKey} to site ${site.siteId} with endpoint ${clientSite.endpoint}` - ); - await addPeer(site.siteId, { - publicKey: publicKey, - allowedIps: [`${client.subnet.split("/")[0]}/32`], // we want to only allow from that client - endpoint: relay ? "" : clientSite.endpoint - }); - } else { - logger.warn( - `Client ${client.clientId} has no endpoint, skipping peer addition` - ); - } - - let relayEndpoint: string | undefined = undefined; - if (relay) { - const [exitNode] = await db - .select() - .from(exitNodes) - .where(eq(exitNodes.exitNodeId, site.exitNodeId)) - .limit(1); - if (!exitNode) { - logger.warn(`Exit node not found for site ${site.siteId}`); - continue; - } - relayEndpoint = `${exitNode.endpoint}:${config.getRawConfig().gerbil.clients_start_port}`; - } - - const allSiteResources = await db // only get the site resources that this client has access to - .select() - .from(siteResources) - .innerJoin( - clientSiteResourcesAssociationsCache, - eq( - siteResources.siteResourceId, - clientSiteResourcesAssociationsCache.siteResourceId - ) - ) - .where( - and( - eq(siteResources.siteId, site.siteId), - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ) - ) - ); - - // Add site configuration to the array - siteConfigurations.push({ - siteId: site.siteId, - name: site.name, - // relayEndpoint: relayEndpoint, // this can be undefined now if not relayed // lets not do this for now because it would conflict with the hole punch testing - endpoint: site.endpoint, - publicKey: site.publicKey, - serverIP: site.address, - serverPort: site.listenPort, - remoteSubnets: generateRemoteSubnets( - allSiteResources.map(({ siteResources }) => siteResources) - ), - aliases: generateAliasConfig( - allSiteResources.map(({ siteResources }) => siteResources) - ) - }); - } - - // REMOVED THIS SO IT CREATES THE INTERFACE AND JUST WAITS FOR THE SITES - // if (siteConfigurations.length === 0) { - // logger.warn("No valid site configurations found"); - // return; - // } + // NOTE: its important that the client here is the old client and the public key is the new key + const siteConfigurations = await buildSiteConfigurationForOlmClient( + client, + publicKey, + relay, + jitMode + ); // Return connect message with all site configurations return { @@ -302,9 +294,13 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { data: { sites: siteConfigurations, tunnelIP: client.subnet, - utilitySubnet: org.utilitySubnet + utilitySubnet: org.utilitySubnet, + chainId: chainId } }, + options: { + compress: canCompress(olm.version, "olm") + }, broadcast: false, excludeSender: false }; diff --git a/server/routers/olm/handleOlmRelayMessage.ts b/server/routers/olm/handleOlmRelayMessage.ts index 595b35ba8..7196824d2 100644 --- a/server/routers/olm/handleOlmRelayMessage.ts +++ b/server/routers/olm/handleOlmRelayMessage.ts @@ -4,6 +4,7 @@ import { clients, clientSitesAssociationsCache, Olm } from "@server/db"; import { and, eq } from "drizzle-orm"; import { updatePeer as newtUpdatePeer } from "../newt/peers"; import logger from "@server/logger"; +import config from "@server/lib/config"; export const handleOlmRelayMessage: MessageHandler = async (context) => { const { message, client: c, sendToClient } = context; @@ -17,7 +18,7 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => { } if (!olm.clientId) { - logger.warn("Olm has no site!"); // TODO: Maybe we create the site here? + logger.warn("Olm has no client!"); return; } @@ -40,7 +41,7 @@ export const handleOlmRelayMessage: MessageHandler = async (context) => { return; } - const { siteId } = message.data; + const { siteId, chainId } = message.data; // Get the site const [site] = await db @@ -88,7 +89,9 @@ 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, + chainId } }, broadcast: false, diff --git a/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts new file mode 100644 index 000000000..54badb2dc --- /dev/null +++ b/server/routers/olm/handleOlmServerInitAddPeerHandshake.ts @@ -0,0 +1,241 @@ +import { + clientSiteResourcesAssociationsCache, + clientSitesAssociationsCache, + db, + exitNodes, + Site, + siteResources +} from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { clients, Olm, sites } from "@server/db"; +import { and, eq, or } from "drizzle-orm"; +import logger from "@server/logger"; +import { initPeerAddHandshake } from "./peers"; + +export const handleOlmServerInitAddPeerHandshake: MessageHandler = async ( + context +) => { + logger.info("Handling register olm message!"); + const { message, client: c, sendToClient } = context; + const olm = c as Olm; + + if (!olm) { + logger.warn("Olm not found"); + return; + } + + if (!olm.clientId) { + logger.warn("Olm has no client!"); // TODO: Maybe we create the site here? + return; + } + + const clientId = olm.clientId; + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + logger.warn("Client not found"); + return; + } + + const { siteId, resourceId, chainId } = message.data; + + let site: Site | null = null; + if (siteId) { + // get the site + const [siteRes] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteId)) + .limit(1); + if (siteRes) { + site = siteRes; + } + } + + if (resourceId && !site) { + const resources = await db + .select() + .from(siteResources) + .where( + and( + or( + eq(siteResources.niceId, resourceId), + eq(siteResources.alias, resourceId) + ), + eq(siteResources.orgId, client.orgId) + ) + ); + + if (!resources || resources.length === 0) { + logger.error(`handleOlmServerPeerAddMessage: Resource not found`); + // cancel the request from the olm side to not keep doing this + await sendToClient( + olm.olmId, + { + type: "olm/wg/peer/chain/cancel", + data: { + chainId + } + }, + { incrementConfigVersion: false } + ).catch((error) => { + logger.warn(`Error sending message:`, error); + }); + return; + } + + if (resources.length > 1) { + // error but this should not happen because the nice id cant contain a dot and the alias has to have a dot and both have to be unique within the org so there should never be multiple matches + logger.error( + `handleOlmServerPeerAddMessage: Multiple resources found matching the criteria` + ); + return; + } + + const resource = resources[0]; + + const currentResourceAssociationCaches = await db + .select() + .from(clientSiteResourcesAssociationsCache) + .where( + and( + eq( + clientSiteResourcesAssociationsCache.siteResourceId, + resource.siteResourceId + ), + eq( + clientSiteResourcesAssociationsCache.clientId, + client.clientId + ) + ) + ); + + if (currentResourceAssociationCaches.length === 0) { + logger.error( + `handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to resource ${resource.siteResourceId}` + ); + // cancel the request from the olm side to not keep doing this + await sendToClient( + olm.olmId, + { + type: "olm/wg/peer/chain/cancel", + data: { + chainId + } + }, + { incrementConfigVersion: false } + ).catch((error) => { + logger.warn(`Error sending message:`, error); + }); + return; + } + + const siteIdFromResource = resource.siteId; + + // get the site + const [siteRes] = await db + .select() + .from(sites) + .where(eq(sites.siteId, siteIdFromResource)); + if (!siteRes) { + logger.error( + `handleOlmServerPeerAddMessage: Site with ID ${site} not found` + ); + return; + } + + site = siteRes; + } + + if (!site) { + logger.error(`handleOlmServerPeerAddMessage: Site not found`); + return; + } + + // check if the client can access this site using the cache + const currentSiteAssociationCaches = await db + .select() + .from(clientSitesAssociationsCache) + .where( + and( + eq(clientSitesAssociationsCache.clientId, client.clientId), + eq(clientSitesAssociationsCache.siteId, site.siteId) + ) + ); + + if (currentSiteAssociationCaches.length === 0) { + logger.error( + `handleOlmServerPeerAddMessage: Client ${client.clientId} does not have access to site ${site.siteId}` + ); + // cancel the request from the olm side to not keep doing this + await sendToClient( + olm.olmId, + { + type: "olm/wg/peer/chain/cancel", + data: { + chainId + } + }, + { incrementConfigVersion: false } + ).catch((error) => { + logger.warn(`Error sending message:`, error); + }); + return; + } + + if (!site.exitNodeId) { + logger.error( + `handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node` + ); + // cancel the request from the olm side to not keep doing this + await sendToClient( + olm.olmId, + { + type: "olm/wg/peer/chain/cancel", + data: { + chainId + } + }, + { incrementConfigVersion: false } + ).catch((error) => { + logger.warn(`Error sending message:`, error); + }); + return; + } + + // get the exit node from the side + const [exitNode] = await db + .select() + .from(exitNodes) + .where(eq(exitNodes.exitNodeId, site.exitNodeId)); + + if (!exitNode) { + logger.error( + `handleOlmServerPeerAddMessage: Site with ID ${site.siteId} has no exit node` + ); + return; + } + + // also trigger the peer add handshake in case the peer was not already added to the olm and we need to hole punch + // if it has already been added this will be a no-op + await initPeerAddHandshake( + // this will kick off the add peer process for the client + client.clientId, + { + siteId: site.siteId, + exitNode: { + publicKey: exitNode.publicKey, + endpoint: exitNode.endpoint + } + }, + olm.olmId, + chainId + ); + + return; +}; diff --git a/server/routers/olm/handleOlmServerPeerAddMessage.ts b/server/routers/olm/handleOlmServerPeerAddMessage.ts index 53f3474ce..64284f493 100644 --- a/server/routers/olm/handleOlmServerPeerAddMessage.ts +++ b/server/routers/olm/handleOlmServerPeerAddMessage.ts @@ -54,7 +54,7 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( return; } - const { siteId } = message.data; + const { siteId, chainId } = message.data; // get the site const [site] = await db @@ -179,7 +179,8 @@ export const handleOlmServerPeerAddMessage: MessageHandler = async ( ), aliases: generateAliasConfig( allSiteResources.map(({ siteResources }) => siteResources) - ) + ), + chainId: chainId, } }, broadcast: false, diff --git a/server/routers/olm/handleOlmUnRelayMessage.ts b/server/routers/olm/handleOlmUnRelayMessage.ts index 5f47a095e..a7b426023 100644 --- a/server/routers/olm/handleOlmUnRelayMessage.ts +++ b/server/routers/olm/handleOlmUnRelayMessage.ts @@ -17,7 +17,7 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => { } if (!olm.clientId) { - logger.warn("Olm has no site!"); // TODO: Maybe we create the site here? + logger.warn("Olm has no client!"); return; } @@ -40,7 +40,7 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => { return; } - const { siteId } = message.data; + const { siteId, chainId } = message.data; // Get the site const [site] = await db @@ -87,7 +87,8 @@ export const handleOlmUnRelayMessage: MessageHandler = async (context) => { type: "olm/wg/peer/unrelay", data: { siteId: siteId, - endpoint: site.endpoint + endpoint: site.endpoint, + chainId } }, broadcast: false, diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index 594ef9cbd..322428572 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -3,9 +3,12 @@ export * from "./getOlmToken"; export * from "./createUserOlm"; export * from "./handleOlmRelayMessage"; export * from "./handleOlmPingMessage"; -export * from "./deleteUserOlm"; +export * from "./archiveUserOlm"; +export * from "./unarchiveUserOlm"; export * from "./listUserOlms"; -export * from "./deleteUserOlm"; export * from "./getUserOlm"; export * from "./handleOlmServerPeerAddMessage"; export * from "./handleOlmUnRelayMessage"; +export * from "./recoverOlmWithFingerprint"; +export * from "./handleOlmDisconnectingMessage"; +export * from "./handleOlmServerInitAddPeerHandshake"; diff --git a/server/routers/olm/listUserOlms.ts b/server/routers/olm/listUserOlms.ts index 2756c9174..ac92afc87 100644 --- a/server/routers/olm/listUserOlms.ts +++ b/server/routers/olm/listUserOlms.ts @@ -1,5 +1,5 @@ import { NextFunction, Request, Response } from "express"; -import { db } from "@server/db"; +import { db, currentFingerprint } from "@server/db"; import { olms } from "@server/db"; import { eq, count, desc } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; @@ -9,6 +9,7 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; +import { getUserDeviceName } from "@server/db/names"; const querySchema = z.object({ limit: z @@ -51,6 +52,7 @@ export type ListUserOlmsResponse = { name: string | null; clientId: number | null; userId: string | null; + archived: boolean; }>; pagination: { total: number; @@ -89,7 +91,7 @@ export async function listUserOlms( const { userId } = parsedParams.data; - // Get total count + // Get total count (including archived OLMs) const [totalCountResult] = await db .select({ count: count() }) .from(olms) @@ -97,22 +99,34 @@ export async function listUserOlms( const total = totalCountResult?.count || 0; - // Get OLMs for the current user - const userOlms = await db - .select({ - olmId: olms.olmId, - dateCreated: olms.dateCreated, - version: olms.version, - name: olms.name, - clientId: olms.clientId, - userId: olms.userId - }) + // Get OLMs for the current user (including archived OLMs) + const list = await db + .select() .from(olms) .where(eq(olms.userId, userId)) + .leftJoin( + currentFingerprint, + eq(olms.olmId, currentFingerprint.olmId) + ) .orderBy(desc(olms.dateCreated)) .limit(limit) .offset(offset); + const userOlms = list.map((item) => { + const model = item.currentFingerprint?.deviceModel || null; + const newName = getUserDeviceName(model, item.olms.name); + + return { + olmId: item.olms.olmId, + dateCreated: item.olms.dateCreated, + version: item.olms.version, + name: newName, + clientId: item.olms.clientId, + userId: item.olms.userId, + archived: item.olms.archived + }; + }); + return response(res, { data: { olms: userOlms, diff --git a/server/routers/olm/peers.ts b/server/routers/olm/peers.ts index 4aa8edd7d..05e153fea 100644 --- a/server/routers/olm/peers.ts +++ b/server/routers/olm/peers.ts @@ -1,7 +1,9 @@ import { sendToClient } from "#dynamic/routers/ws"; -import { db, olms } from "@server/db"; +import { clientSitesAssociationsCache, db, olms } from "@server/db"; +import { canCompress } from "@server/lib/clientVersionChecks"; +import config from "@server/lib/config"; import logger from "@server/logger"; -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; import { Alias } from "yaml"; export async function addPeer( @@ -17,7 +19,8 @@ export async function addPeer( remoteSubnets: string[] | null; // optional, comma-separated list of subnets that this site can access aliases: Alias[]; }, - olmId?: string + olmId?: string, + version?: string | null ) { if (!olmId) { const [olm] = await db @@ -29,22 +32,27 @@ export async function addPeer( return; // ignore this because an olm might not be associated with the client anymore } olmId = olm.olmId; + version = olm.version; } - await sendToClient(olmId, { - type: "olm/wg/peer/add", - data: { - siteId: peer.siteId, - name: peer.name, - publicKey: peer.publicKey, - endpoint: peer.endpoint, - relayEndpoint: peer.relayEndpoint, - serverIP: peer.serverIP, - serverPort: peer.serverPort, - remoteSubnets: peer.remoteSubnets, // optional, comma-separated list of subnets that this site can access - aliases: peer.aliases - } - }).catch((error) => { + await sendToClient( + olmId, + { + type: "olm/wg/peer/add", + data: { + siteId: peer.siteId, + name: peer.name, + publicKey: peer.publicKey, + endpoint: peer.endpoint, + relayEndpoint: peer.relayEndpoint, + serverIP: peer.serverIP, + serverPort: peer.serverPort, + remoteSubnets: peer.remoteSubnets, // optional, comma-separated list of subnets that this site can access + aliases: peer.aliases + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "olm") } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); @@ -55,7 +63,8 @@ export async function deletePeer( clientId: number, siteId: number, publicKey: string, - olmId?: string + olmId?: string, + version?: string | null ) { if (!olmId) { const [olm] = await db @@ -67,15 +76,20 @@ export async function deletePeer( return; } olmId = olm.olmId; + version = olm.version; } - await sendToClient(olmId, { - type: "olm/wg/peer/remove", - data: { - publicKey, - siteId: siteId - } - }).catch((error) => { + await sendToClient( + olmId, + { + type: "olm/wg/peer/remove", + data: { + publicKey, + siteId: siteId + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "olm") } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); @@ -94,7 +108,8 @@ export async function updatePeer( remoteSubnets?: string[] | null; // optional, comma-separated list of subnets that aliases?: Alias[] | null; }, - olmId?: string + olmId?: string, + version?: string | null ) { if (!olmId) { const [olm] = await db @@ -106,21 +121,26 @@ export async function updatePeer( return; } olmId = olm.olmId; + version = olm.version; } - await sendToClient(olmId, { - type: "olm/wg/peer/update", - data: { - siteId: peer.siteId, - publicKey: peer.publicKey, - endpoint: peer.endpoint, - relayEndpoint: peer.relayEndpoint, - serverIP: peer.serverIP, - serverPort: peer.serverPort, - remoteSubnets: peer.remoteSubnets, - aliases: peer.aliases - } - }).catch((error) => { + await sendToClient( + olmId, + { + type: "olm/wg/peer/update", + data: { + siteId: peer.siteId, + publicKey: peer.publicKey, + endpoint: peer.endpoint, + relayEndpoint: peer.relayEndpoint, + serverIP: peer.serverIP, + serverPort: peer.serverPort, + remoteSubnets: peer.remoteSubnets, + aliases: peer.aliases + } + }, + { incrementConfigVersion: true, compress: canCompress(version, "olm") } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); @@ -136,7 +156,8 @@ export async function initPeerAddHandshake( endpoint: string; }; }, - olmId?: string + olmId?: string, + chainId?: string ) { if (!olmId) { const [olm] = await db @@ -150,19 +171,36 @@ export async function initPeerAddHandshake( olmId = olm.olmId; } - await sendToClient(olmId, { - type: "olm/wg/peer/holepunch/site/add", - data: { - siteId: peer.siteId, - exitNode: { - publicKey: peer.exitNode.publicKey, - endpoint: peer.exitNode.endpoint + await sendToClient( + olmId, + { + type: "olm/wg/peer/holepunch/site/add", + data: { + siteId: peer.siteId, + exitNode: { + publicKey: peer.exitNode.publicKey, + relayPort: config.getRawConfig().gerbil.clients_start_port, + endpoint: peer.exitNode.endpoint + }, + chainId } - } - }).catch((error) => { + }, + { incrementConfigVersion: true } + ).catch((error) => { logger.warn(`Error sending message:`, error); }); + // update the clientSiteAssociationsCache to make the isJitMode flag false so that JIT mode is disabled for this site if it restarts or something after the connection + await db + .update(clientSitesAssociationsCache) + .set({ isJitMode: false }) + .where( + and( + eq(clientSitesAssociationsCache.clientId, clientId), + eq(clientSitesAssociationsCache.siteId, peer.siteId) + ) + ); + logger.info( `Initiated peer add handshake for site ${peer.siteId} to olm ${olmId}` ); diff --git a/server/routers/olm/recoverOlmWithFingerprint.ts b/server/routers/olm/recoverOlmWithFingerprint.ts new file mode 100644 index 000000000..d0d8b681d --- /dev/null +++ b/server/routers/olm/recoverOlmWithFingerprint.ts @@ -0,0 +1,125 @@ +import { db, currentFingerprint, olms } from "@server/db"; +import logger from "@server/logger"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import response from "@server/lib/response"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import { generateId } from "@server/auth/sessions/app"; +import { hashPassword } from "@server/auth/password"; + +const paramsSchema = z + .object({ + userId: z.string() + }) + .strict(); + +const bodySchema = z + .object({ + platformFingerprint: z.string() + }) + .strict(); + +export async function recoverOlmWithFingerprint( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId } = parsedParams.data; + + const parsedBody = bodySchema.safeParse(req.body); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { platformFingerprint } = parsedBody.data; + + const result = await db + .select({ + olm: olms, + fingerprint: currentFingerprint + }) + .from(olms) + .innerJoin( + currentFingerprint, + eq(currentFingerprint.olmId, olms.olmId) + ) + .where( + and( + eq(olms.userId, userId), + eq( + currentFingerprint.platformFingerprint, + platformFingerprint + ) + ) + ) + .orderBy(currentFingerprint.lastSeen); + + if (!result || result.length == 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "corresponding olm with this fingerprint not found" + ) + ); + } + + if (result.length > 1) { + return next( + createHttpError( + HttpCode.CONFLICT, + "multiple matching fingerprints found, not resetting secrets" + ) + ); + } + + const [{ olm: foundOlm }] = result; + + const newSecret = generateId(48); + const newSecretHash = await hashPassword(newSecret); + + await db + .update(olms) + .set({ + secretHash: newSecretHash + }) + .where(eq(olms.olmId, foundOlm.olmId)); + + return response(res, { + data: { + olmId: foundOlm.olmId, + secret: newSecret + }, + success: true, + error: false, + message: "Successfully retrieved olm", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to recover olm using provided fingerprint input" + ) + ); + } +} diff --git a/server/routers/olm/sync.ts b/server/routers/olm/sync.ts new file mode 100644 index 000000000..c994b2c73 --- /dev/null +++ b/server/routers/olm/sync.ts @@ -0,0 +1,92 @@ +import { + Client, + db, + exitNodes, + Olm, + sites, + clientSitesAssociationsCache +} from "@server/db"; +import { buildSiteConfigurationForOlmClient } from "./buildConfiguration"; +import { sendToClient } from "#dynamic/routers/ws"; +import logger from "@server/logger"; +import { eq, inArray } from "drizzle-orm"; +import config from "@server/lib/config"; +import { canCompress } from "@server/lib/clientVersionChecks"; + +export async function sendOlmSyncMessage(olm: Olm, client: Client) { + // NOTE: WE ARE HARDCODING THE RELAY PARAMETER TO FALSE HERE BUT IN THE REGISTER MESSAGE ITS DEFINED BY THE CLIENT + const siteConfigurations = await buildSiteConfigurationForOlmClient( + client, + client.pubKey, + false + ); + + // Get all exit nodes from sites where the client has peers + const clientSites = await db + .select() + .from(clientSitesAssociationsCache) + .innerJoin(sites, eq(sites.siteId, clientSitesAssociationsCache.siteId)) + .where(eq(clientSitesAssociationsCache.clientId, client.clientId)); + + // Extract unique exit node IDs + const exitNodeIds = Array.from( + new Set( + clientSites + .map(({ sites: site }) => site.exitNodeId) + .filter((id): id is number => id !== null) + ) + ); + + let exitNodesData: { + publicKey: string; + relayPort: number; + endpoint: string; + siteIds: number[]; + }[] = []; + + if (exitNodeIds.length > 0) { + const allExitNodes = await db + .select() + .from(exitNodes) + .where(inArray(exitNodes.exitNodeId, exitNodeIds)); + + // Map exitNodeId to siteIds + const exitNodeIdToSiteIds: Record = {}; + for (const { sites: site } of clientSites) { + if (site.exitNodeId !== null) { + if (!exitNodeIdToSiteIds[site.exitNodeId]) { + exitNodeIdToSiteIds[site.exitNodeId] = []; + } + exitNodeIdToSiteIds[site.exitNodeId].push(site.siteId); + } + } + + exitNodesData = allExitNodes.map((exitNode) => { + return { + publicKey: exitNode.publicKey, + relayPort: config.getRawConfig().gerbil.clients_start_port, + endpoint: exitNode.endpoint, + siteIds: exitNodeIdToSiteIds[exitNode.exitNodeId] ?? [] + }; + }); + } + + logger.debug("sendOlmSyncMessage: sending sync message"); + + await sendToClient( + olm.olmId, + { + type: "olm/sync", + data: { + sites: siteConfigurations, + exitNodes: exitNodesData + } + }, + + { + compress: canCompress(olm.version, "olm") + } + ).catch((error) => { + logger.warn(`Error sending olm sync message:`, error); + }); +} diff --git a/server/routers/olm/unarchiveUserOlm.ts b/server/routers/olm/unarchiveUserOlm.ts new file mode 100644 index 000000000..28d540b82 --- /dev/null +++ b/server/routers/olm/unarchiveUserOlm.ts @@ -0,0 +1,84 @@ +import { NextFunction, Request, Response } from "express"; +import { db } from "@server/db"; +import { olms } from "@server/db"; +import { eq } from "drizzle-orm"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import response from "@server/lib/response"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; +import logger from "@server/logger"; + +const paramsSchema = z + .object({ + userId: z.string(), + olmId: z.string() + }) + .strict(); + +export async function unarchiveUserOlm( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = paramsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { olmId } = parsedParams.data; + + // Check if OLM exists and is archived + const [olm] = await db + .select() + .from(olms) + .where(eq(olms.olmId, olmId)) + .limit(1); + + if (!olm) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `OLM with ID ${olmId} not found` + ) + ); + } + + if (!olm.archived) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `OLM with ID ${olmId} is not archived` + ) + ); + } + + // Unarchive the OLM (set archived to false) + await db + .update(olms) + .set({ archived: false }) + .where(eq(olms.olmId, olmId)); + + return response(res, { + data: null, + success: true, + error: false, + message: "Device unarchived successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to unarchive device" + ) + ); + } +} diff --git a/server/routers/org/checkOrgUserAccess.ts b/server/routers/org/checkOrgUserAccess.ts index d9f0364e3..19e39c4fe 100644 --- a/server/routers/org/checkOrgUserAccess.ts +++ b/server/routers/org/checkOrgUserAccess.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idp, idpOidcConfig } from "@server/db"; -import { roles, userOrgs, users } from "@server/db"; +import { roles, userOrgRoles, userOrgs, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -14,7 +14,7 @@ import { checkOrgAccessPolicy } from "#dynamic/lib/checkOrgAccessPolicy"; import { CheckOrgAccessPolicyResult } from "@server/lib/checkOrgAccessPolicy"; async function queryUser(orgId: string, userId: string) { - const [user] = await db + const [userRow] = await db .select({ orgId: userOrgs.orgId, userId: users.userId, @@ -22,10 +22,7 @@ async function queryUser(orgId: string, userId: string) { username: users.username, name: users.name, type: users.type, - roleId: userOrgs.roleId, - roleName: roles.name, isOwner: userOrgs.isOwner, - isAdmin: roles.isAdmin, twoFactorEnabled: users.twoFactorEnabled, autoProvisioned: userOrgs.autoProvisioned, idpId: users.idpId, @@ -35,13 +32,40 @@ async function queryUser(orgId: string, userId: string) { idpAutoProvision: idp.autoProvision }) .from(userOrgs) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(users, eq(userOrgs.userId, users.userId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); - return user; + + if (!userRow) return undefined; + + const roleRows = await db + .select({ + roleId: userOrgRoles.roleId, + roleName: roles.name, + isAdmin: roles.isAdmin + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + + const isAdmin = roleRows.some((r) => r.isAdmin); + + return { + ...userRow, + isAdmin, + roleIds: roleRows.map((r) => r.roleId), + roles: roleRows.map((r) => ({ + roleId: r.roleId, + name: r.roleName ?? "" + })) + }; } export type CheckOrgUserAccessResponse = CheckOrgAccessPolicyResult; diff --git a/server/routers/org/createOrg.ts b/server/routers/org/createOrg.ts index f1d065665..88f76c29c 100644 --- a/server/routers/org/createOrg.ts +++ b/server/routers/org/createOrg.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { eq } from "drizzle-orm"; +import { and, count, eq } from "drizzle-orm"; import { domains, Org, @@ -9,6 +9,7 @@ import { orgs, roleActions, roles, + userOrgRoles, userOrgs, users, actions @@ -24,18 +25,35 @@ import { OpenAPITags, registry } from "@server/openApi"; import { isValidCIDR } from "@server/lib/validators"; import { createCustomer } from "#dynamic/lib/billing"; import { usageService } from "@server/lib/billing/usageService"; -import { FeatureId } from "@server/lib/billing"; +import { FeatureId, limitsService, freeLimitSet } from "@server/lib/billing"; import { build } from "@server/build"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; +import { doCidrsOverlap } from "@server/lib/ip"; +import { generateCA } from "@server/lib/sshCA"; +import { encrypt } from "@server/lib/crypto"; + +const validOrgIdRegex = /^[a-z0-9_]+(-[a-z0-9_]+)*$/; const createOrgSchema = z.strictObject({ - orgId: z.string(), + orgId: z + .string() + .min(1, "Organization ID is required") + .max(32, "Organization ID must be at most 32 characters") + .refine((val) => validOrgIdRegex.test(val), { + message: + "Organization ID must contain only lowercase letters, numbers, underscores, and single hyphens (no leading, trailing, or consecutive hyphens)" + }), name: z.string().min(1).max(255), 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" + }), + utilitySubnet: z + .union([z.cidrv4()]) // for now lets just do ipv4 until we verify ipv6 works everywhere + .refine((val) => isValidCIDR(val), { + message: "Invalid utility subnet CIDR" }) }); @@ -84,7 +102,7 @@ export async function createOrg( ); } - const { orgId, name, subnet } = parsedBody.data; + const { orgId, name, subnet, utilitySubnet } = parsedBody.data; // TODO: for now we are making all of the orgs the same subnet // make sure the subnet is unique @@ -102,6 +120,7 @@ export async function createOrg( // ) // ); // } + // // make sure the orgId is unique const orgExists = await db @@ -119,8 +138,83 @@ export async function createOrg( ); } + if (doCidrsOverlap(subnet, utilitySubnet)) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Subnet ${subnet} overlaps with utility subnet ${utilitySubnet}` + ) + ); + } + + let isFirstOrg: boolean | null = null; + let billingOrgIdForNewOrg: string | null = null; + if (build === "saas" && req.user) { + const ownedOrgs = await db + .select() + .from(userOrgs) + .where( + and( + eq(userOrgs.userId, req.user.userId), + eq(userOrgs.isOwner, true) + ) + ); + if (ownedOrgs.length === 0) { + isFirstOrg = true; + } else { + isFirstOrg = false; + const [billingOrg] = await db + .select({ orgId: orgs.orgId }) + .from(orgs) + .innerJoin(userOrgs, eq(orgs.orgId, userOrgs.orgId)) + .where( + and( + eq(userOrgs.userId, req.user.userId), + eq(userOrgs.isOwner, true), + eq(orgs.isBillingOrg, true) + ) + ) + .limit(1); + if (billingOrg) { + billingOrgIdForNewOrg = billingOrg.orgId; + } + } + } + + if (build == "saas" && billingOrgIdForNewOrg) { + const usage = await usageService.getUsage( + billingOrgIdForNewOrg, + FeatureId.ORGINIZATIONS + ); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectOrgs = await usageService.checkLimitSet( + billingOrgIdForNewOrg, + FeatureId.ORGINIZATIONS, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectOrgs) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Organization limit exceeded. Please upgrade your plan." + ) + ); + } + } + let error = ""; let org: Org | null = null; + let numOrgs: number | null = null; await db.transaction(async (trx) => { const allDomains = await trx @@ -128,8 +222,28 @@ export async function createOrg( .from(domains) .where(eq(domains.configManaged, true)); - const utilitySubnet = - config.getRawConfig().orgs.utility_subnet_group; + const saasBillingFields = + build === "saas" && req.user && isFirstOrg !== null + ? isFirstOrg + ? { isBillingOrg: true as const, billingOrgId: orgId } // if this is the first org, it becomes the billing org for itself + : { + isBillingOrg: false as const, + billingOrgId: billingOrgIdForNewOrg + } + : {}; + + const encryptionKey = config.getRawConfig().server.secret; + let sshCaFields: { + sshCaPrivateKey?: string; + sshCaPublicKey?: string; + } = {}; + if (encryptionKey) { + const ca = generateCA(`pangolin-ssh-ca-${orgId}`); + sshCaFields = { + sshCaPrivateKey: encrypt(ca.privateKeyPem, encryptionKey), + sshCaPublicKey: ca.publicKeyOpenSSH + }; + } const newOrg = await trx .insert(orgs) @@ -138,7 +252,9 @@ export async function createOrg( name, subnet, utilitySubnet, - createdAt: new Date().toISOString() + createdAt: new Date().toISOString(), + ...sshCaFields, + ...saasBillingFields }) .returning(); @@ -157,7 +273,8 @@ export async function createOrg( orgId: newOrg[0].orgId, isAdmin: true, name: "Admin", - description: "Admin role with the most permissions" + description: "Admin role with the most permissions", + sshSudoMode: "full" }) .returning({ roleId: roles.roleId }); @@ -196,9 +313,13 @@ export async function createOrg( await trx.insert(userOrgs).values({ userId: req.user!.userId, orgId: newOrg[0].orgId, - roleId: roleId, isOwner: true }); + await trx.insert(userOrgRoles).values({ + userId: req.user!.userId, + orgId: newOrg[0].orgId, + roleId + }); ownerUserId = req.user!.userId; } else { // if org created by root api key, set the server admin as the owner @@ -216,9 +337,13 @@ export async function createOrg( await trx.insert(userOrgs).values({ userId: serverAdmin.userId, orgId: newOrg[0].orgId, - roleId: roleId, isOwner: true }); + await trx.insert(userOrgRoles).values({ + userId: serverAdmin.userId, + orgId: newOrg[0].orgId, + roleId + }); ownerUserId = serverAdmin.userId; } @@ -240,6 +365,17 @@ export async function createOrg( ); await calculateUserClientsForOrgs(ownerUserId, trx); + + if (billingOrgIdForNewOrg) { + const [numOrgsResult] = await trx + .select({ count: count() }) + .from(orgs) + .where(eq(orgs.billingOrgId, billingOrgIdForNewOrg)); // all the billable orgs including the primary org that is the billing org itself + + numOrgs = numOrgsResult.count; + } else { + numOrgs = 1; // we only have one org if there is no billing org found out + } }); if (!org) { @@ -255,11 +391,11 @@ export async function createOrg( return next(createHttpError(HttpCode.INTERNAL_SERVER_ERROR, error)); } - if (build == "saas") { - // make sure we have the stripe customer + if (build === "saas" && isFirstOrg === true) { + await limitsService.applyLimitSetToOrg(orgId, freeLimitSet); const customerId = await createCustomer(orgId, req.user?.email); if (customerId) { - await usageService.updateDaily( + await usageService.updateCount( orgId, FeatureId.USERS, 1, @@ -268,6 +404,14 @@ export async function createOrg( } } + if (numOrgs) { + usageService.updateCount( + billingOrgIdForNewOrg || orgId, + FeatureId.ORGINIZATIONS, + numOrgs + ); + } + return response(res, { data: org, success: true, diff --git a/server/routers/org/deleteOrg.ts b/server/routers/org/deleteOrg.ts index 35dc7503c..7de021622 100644 --- a/server/routers/org/deleteOrg.ts +++ b/server/routers/org/deleteOrg.ts @@ -1,26 +1,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { - clients, - clientSiteResourcesAssociationsCache, - clientSitesAssociationsCache, - db, - domains, - olms, - orgDomains, - resources -} from "@server/db"; -import { newts, newtSessions, orgs, sites, userActions } from "@server/db"; -import { eq, and, inArray, sql } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; -import { sendToClient } from "#dynamic/routers/ws"; -import { deletePeer } from "../gerbil/peers"; import { OpenAPITags, registry } from "@server/openApi"; +import { deleteOrgById, sendTerminationMessages } from "@server/lib/deleteOrg"; +import { db, userOrgs, orgs } from "@server/db"; +import { eq, and } from "drizzle-orm"; const deleteOrgSchema = z.strictObject({ orgId: z.string() @@ -54,16 +42,23 @@ export async function deleteOrg( ) ); } - const { orgId } = parsedParams.data; - const [org] = await db + const [data] = await db .select() - .from(orgs) - .where(eq(orgs.orgId, orgId)) - .limit(1); + .from(userOrgs) + .innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId)) + .where( + and( + eq(userOrgs.orgId, orgId), + eq(userOrgs.userId, req.user!.userId) + ) + ); - if (!org) { + const org = data?.orgs; + const userOrg = data?.userOrgs; + + if (!org || !userOrg) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -71,152 +66,27 @@ export async function deleteOrg( ) ); } - // we need to handle deleting each site - const orgSites = await db - .select() - .from(sites) - .where(eq(sites.orgId, orgId)) - .limit(1); - const orgClients = await db - .select() - .from(clients) - .where(eq(clients.orgId, orgId)); - - const deletedNewtIds: string[] = []; - const olmsToTerminate: string[] = []; - - await db.transaction(async (trx) => { - for (const site of orgSites) { - if (site.pubKey) { - if (site.type == "wireguard") { - await deletePeer(site.exitNodeId!, site.pubKey); - } else if (site.type == "newt") { - // get the newt on the site by querying the newt table for siteId - const [deletedNewt] = await trx - .delete(newts) - .where(eq(newts.siteId, site.siteId)) - .returning(); - if (deletedNewt) { - deletedNewtIds.push(deletedNewt.newtId); - - // delete all of the sessions for the newt - await trx - .delete(newtSessions) - .where( - eq(newtSessions.newtId, deletedNewt.newtId) - ); - } - } - } - - logger.info(`Deleting site ${site.siteId}`); - await trx.delete(sites).where(eq(sites.siteId, site.siteId)); - } - for (const client of orgClients) { - const [olm] = await trx - .select() - .from(olms) - .where(eq(olms.clientId, client.clientId)) - .limit(1); - - if (olm) { - olmsToTerminate.push(olm.olmId); - } - - logger.info(`Deleting client ${client.clientId}`); - await trx - .delete(clients) - .where(eq(clients.clientId, client.clientId)); - - // also delete the associations - await trx - .delete(clientSiteResourcesAssociationsCache) - .where( - eq( - clientSiteResourcesAssociationsCache.clientId, - client.clientId - ) - ); - - await trx - .delete(clientSitesAssociationsCache) - .where( - eq( - clientSitesAssociationsCache.clientId, - client.clientId - ) - ); - } - - const allOrgDomains = await trx - .select() - .from(orgDomains) - .innerJoin(domains, eq(domains.domainId, orgDomains.domainId)) - .where( - and( - eq(orgDomains.orgId, orgId), - eq(domains.configManaged, false) - ) - ); - - // For each domain, check if it belongs to multiple organizations - const domainIdsToDelete: string[] = []; - for (const orgDomain of allOrgDomains) { - const domainId = orgDomain.domains.domainId; - - // Count how many organizations this domain belongs to - const orgCount = await trx - .select({ count: sql`count(*)` }) - .from(orgDomains) - .where(eq(orgDomains.domainId, domainId)); - - // Only delete the domain if it belongs to exactly 1 organization (the one being deleted) - if (orgCount[0].count === 1) { - domainIdsToDelete.push(domainId); - } - } - - // Delete domains that belong exclusively to this organization - if (domainIdsToDelete.length > 0) { - await trx - .delete(domains) - .where(inArray(domains.domainId, domainIdsToDelete)); - } - - // Delete resources - await trx.delete(resources).where(eq(resources.orgId, orgId)); - - await trx.delete(orgs).where(eq(orgs.orgId, orgId)); - }); - - // Send termination messages outside of transaction to prevent blocking - for (const newtId of deletedNewtIds) { - const payload = { - type: `newt/wg/terminate`, - data: {} - }; - // Don't await this to prevent blocking the response - sendToClient(newtId, payload).catch((error) => { - logger.error( - "Failed to send termination message to newt:", - error - ); - }); + if (!userOrg.isOwner) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Only organization owners can delete the organization" + ) + ); } - for (const olmId of olmsToTerminate) { - sendToClient(olmId, { - type: "olm/terminate", - data: {} - }).catch((error) => { - logger.error( - "Failed to send termination message to olm:", - error - ); - }); + if (org.isBillingOrg) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Cannot delete a primary organization" + ) + ); } + const result = await deleteOrgById(orgId); + sendTerminationMessages(result); return response(res, { data: null, success: true, @@ -225,6 +95,9 @@ export async function deleteOrg( status: HttpCode.OK }); } catch (error) { + if (createHttpError.isHttpError(error)) { + return next(error); + } logger.error(error); return next( createHttpError( diff --git a/server/routers/org/getOrgOverview.ts b/server/routers/org/getOrgOverview.ts index d368d1b3c..fcdd7c0ed 100644 --- a/server/routers/org/getOrgOverview.ts +++ b/server/routers/org/getOrgOverview.ts @@ -117,20 +117,26 @@ export async function getOrgOverview( .from(userOrgs) .where(eq(userOrgs.orgId, orgId)); - const [role] = await db - .select() - .from(roles) - .where(eq(roles.roleId, req.userOrg.roleId)); + const roleIds = req.userOrgRoleIds ?? []; + const roleRows = + roleIds.length > 0 + ? await db + .select({ name: roles.name, isAdmin: roles.isAdmin }) + .from(roles) + .where(inArray(roles.roleId, roleIds)) + : []; + const userRoleName = roleRows.map((r) => r.name ?? "").join(", ") ?? ""; + const isAdmin = roleRows.some((r) => r.isAdmin === true); return response(res, { data: { orgName: org[0].name, orgId: org[0].orgId, - userRoleName: role.name, + userRoleName, numSites, numUsers, numResources, - isAdmin: role.isAdmin || false, + isAdmin, isOwner: req.userOrg?.isOwner || false }, success: true, diff --git a/server/routers/org/index.ts b/server/routers/org/index.ts index b0db28d14..c1aee7b33 100644 --- a/server/routers/org/index.ts +++ b/server/routers/org/index.ts @@ -8,3 +8,4 @@ export * from "./getOrgOverview"; export * from "./listOrgs"; export * from "./pickOrgDefaults"; export * from "./checkOrgUserAccess"; +export * from "./resetOrgBandwidth"; diff --git a/server/routers/org/listUserOrgs.ts b/server/routers/org/listUserOrgs.ts index 103b1023d..8e6ce649d 100644 --- a/server/routers/org/listUserOrgs.ts +++ b/server/routers/org/listUserOrgs.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, roles } from "@server/db"; -import { Org, orgs, userOrgs } from "@server/db"; +import { Org, orgs, userOrgRoles, userOrgs } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -40,7 +40,11 @@ const listOrgsSchema = z.object({ // responses: {} // }); -type ResponseOrg = Org & { isOwner?: boolean; isAdmin?: boolean }; +type ResponseOrg = Org & { + isOwner?: boolean; + isAdmin?: boolean; + isPrimaryOrg?: boolean; +}; export type ListUserOrgsResponse = { orgs: ResponseOrg[]; @@ -78,10 +82,7 @@ export async function listUserOrgs( const { userId } = parsedParams.data; const userOrganizations = await db - .select({ - orgId: userOrgs.orgId, - roleId: userOrgs.roleId - }) + .select({ orgId: userOrgs.orgId }) .from(userOrgs) .where(eq(userOrgs.userId, userId)); @@ -112,10 +113,27 @@ export async function listUserOrgs( userOrgs, and(eq(userOrgs.orgId, orgs.orgId), eq(userOrgs.userId, userId)) ) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .limit(limit) .offset(offset); + const roleRows = await db + .select({ + orgId: userOrgRoles.orgId, + isAdmin: roles.isAdmin + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.userId, userId), + inArray(userOrgRoles.orgId, userOrgIds) + ) + ); + + const orgHasAdmin = new Set( + roleRows.filter((r) => r.isAdmin).map((r) => r.orgId) + ); + const totalCountResult = await db .select({ count: sql`cast(count(*) as integer)` }) .from(orgs) @@ -129,8 +147,11 @@ export async function listUserOrgs( if (val.userOrgs && val.userOrgs.isOwner) { res.isOwner = val.userOrgs.isOwner; } - if (val.roles && val.roles.isAdmin) { - res.isAdmin = val.roles.isAdmin; + if (val.orgs && orgHasAdmin.has(val.orgs.orgId)) { + res.isAdmin = true; + } + if (val.userOrgs?.isOwner && val.orgs?.isBillingOrg) { + res.isPrimaryOrg = val.orgs.isBillingOrg; } return res; }); diff --git a/server/routers/org/pickOrgDefaults.ts b/server/routers/org/pickOrgDefaults.ts index 771b0d992..e4b9f2b25 100644 --- a/server/routers/org/pickOrgDefaults.ts +++ b/server/routers/org/pickOrgDefaults.ts @@ -8,6 +8,7 @@ import config from "@server/lib/config"; export type PickOrgDefaultsResponse = { subnet: string; + utilitySubnet: string; }; export async function pickOrgDefaults( @@ -20,10 +21,12 @@ export async function pickOrgDefaults( // const subnet = await getNextAvailableOrgSubnet(); // Just hard code the subnet for now for everyone const subnet = config.getRawConfig().orgs.subnet_group; + const utilitySubnet = config.getRawConfig().orgs.utility_subnet_group; return response(res, { data: { - subnet: subnet + subnet: subnet, + utilitySubnet: utilitySubnet }, success: true, error: false, diff --git a/server/routers/org/resetOrgBandwidth.ts b/server/routers/org/resetOrgBandwidth.ts new file mode 100644 index 000000000..b98e2e406 --- /dev/null +++ b/server/routers/org/resetOrgBandwidth.ts @@ -0,0 +1,83 @@ +import { NextFunction, Request, Response } from "express"; +import { z } from "zod"; +import { db, sites } from "@server/db"; +import { eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; + +const resetOrgBandwidthParamsSchema = z.strictObject({ + orgId: z.string() +}); + +registry.registerPath({ + method: "post", + path: "/org/{orgId}/reset-bandwidth", + description: "Reset all sites in selected organization bandwidth counters.", + tags: [OpenAPITags.Org, OpenAPITags.Site], + request: { + params: resetOrgBandwidthParamsSchema + }, + responses: {} +}); + +export async function resetOrgBandwidth( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = resetOrgBandwidthParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + + const [site] = await db + .select({ siteId: sites.siteId }) + .from(sites) + .where(eq(sites.orgId, orgId)) + .limit(1); + + if (!site) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `No sites found in org ${orgId}` + ) + ); + } + + await db + .update(sites) + .set({ + megabytesIn: 0, + megabytesOut: 0 + }) + .where(eq(sites.orgId, orgId)); + + return response(res, { + data: {}, + success: true, + error: false, + message: "Sites bandwidth reset successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/org/updateOrg.ts b/server/routers/org/updateOrg.ts index aa9e2151a..4eca9a9a6 100644 --- a/server/routers/org/updateOrg.ts +++ b/server/routers/org/updateOrg.ts @@ -10,10 +10,10 @@ import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; import { build } from "@server/build"; -import license from "#dynamic/license/license"; +import { cache } from "#dynamic/lib/cache"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { getOrgTierData } from "#dynamic/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; -import { cache } from "@server/lib/cache"; const updateOrgParamsSchema = z.strictObject({ orgId: z.string() @@ -34,6 +34,10 @@ const updateOrgBodySchema = z .min(build === "saas" ? 0 : -1) .optional(), settingsLogRetentionDaysAction: z + .number() + .min(build === "saas" ? 0 : -1) + .optional(), + settingsLogRetentionDaysConnection: z .number() .min(build === "saas" ? 0 : -1) .optional() @@ -88,26 +92,94 @@ export async function updateOrg( const { orgId } = parsedParams.data; - const isLicensed = await isLicensedOrSubscribed(orgId); - if (build == "enterprise" && !isLicensed) { + // Check 2FA enforcement feature + const has2FAFeature = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.TwoFactorEnforcement] + ); + if (!has2FAFeature) { parsedBody.data.requireTwoFactor = undefined; - parsedBody.data.maxSessionLengthHours = undefined; - parsedBody.data.passwordExpiryDays = undefined; } - const { tier } = await getOrgTierData(orgId); - if ( - build == "saas" && - tier != TierId.STANDARD && - parsedBody.data.settingsLogRetentionDaysRequest && - parsedBody.data.settingsLogRetentionDaysRequest > 30 - ) { - return next( - createHttpError( - HttpCode.FORBIDDEN, - "You are not allowed to set log retention days greater than 30 with your current subscription" - ) - ); + // Check session duration policies feature + const hasSessionDurationFeature = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.SessionDurationPolicies] + ); + if (!hasSessionDurationFeature) { + parsedBody.data.maxSessionLengthHours = undefined; + } + + // Check password expiration policies feature + const hasPasswordExpirationFeature = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.PasswordExpirationPolicies] + ); + if (!hasPasswordExpirationFeature) { + parsedBody.data.passwordExpiryDays = undefined; + } + if (build == "saas") { + const { tier } = await getOrgTierData(orgId); + + // Determine max allowed retention days based on tier + let maxRetentionDays: number | null = null; + if (!tier) { + maxRetentionDays = 3; + } else if (tier === "tier1") { + maxRetentionDays = 7; + } else if (tier === "tier2") { + maxRetentionDays = 30; + } else if (tier === "tier3") { + maxRetentionDays = 90; + } + // For enterprise tier, no check (maxRetentionDays remains null) + + if (maxRetentionDays !== null) { + if ( + parsedBody.data.settingsLogRetentionDaysRequest !== undefined && + parsedBody.data.settingsLogRetentionDaysRequest > maxRetentionDays + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `You are not allowed to set log retention days greater than ${maxRetentionDays} with your current subscription` + ) + ); + } + if ( + parsedBody.data.settingsLogRetentionDaysAccess !== undefined && + parsedBody.data.settingsLogRetentionDaysAccess > maxRetentionDays + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `You are not allowed to set log retention days greater than ${maxRetentionDays} with your current subscription` + ) + ); + } + if ( + parsedBody.data.settingsLogRetentionDaysAction !== undefined && + parsedBody.data.settingsLogRetentionDaysAction > maxRetentionDays + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `You are not allowed to set log retention days greater than ${maxRetentionDays} with your current subscription` + ) + ); + } + if ( + parsedBody.data.settingsLogRetentionDaysConnection !== undefined && + parsedBody.data.settingsLogRetentionDaysConnection > maxRetentionDays + ) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + `You are not allowed to set log retention days greater than ${maxRetentionDays} with your current subscription` + ) + ); + } + } } const updatedOrg = await db @@ -122,7 +194,9 @@ export async function updateOrg( settingsLogRetentionDaysAccess: parsedBody.data.settingsLogRetentionDaysAccess, settingsLogRetentionDaysAction: - parsedBody.data.settingsLogRetentionDaysAction + parsedBody.data.settingsLogRetentionDaysAction, + settingsLogRetentionDaysConnection: + parsedBody.data.settingsLogRetentionDaysConnection }) .where(eq(orgs.orgId, orgId)) .returning(); @@ -137,9 +211,10 @@ export async function updateOrg( } // invalidate the cache for all of the orgs retention days - cache.del(`org_${orgId}_retentionDays`); - cache.del(`org_${orgId}_actionDays`); - cache.del(`org_${orgId}_accessDays`); + await cache.del(`org_${orgId}_retentionDays`); + await cache.del(`org_${orgId}_actionDays`); + await cache.del(`org_${orgId}_accessDays`); + await cache.del(`org_${orgId}_connectionDays`); return response(res, { data: updatedOrg[0], @@ -155,22 +230,3 @@ export async function updateOrg( ); } } - -async function isLicensedOrSubscribed(orgId: string): Promise { - if (build === "enterprise") { - const isUnlocked = await license.isUnlocked(); - if (!isUnlocked) { - return false; - } - } - - if (build === "saas") { - const { tier } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; - if (!subscribed) { - return false; - } - } - - return true; -} diff --git a/server/routers/resource/addEmailToResourceWhitelist.ts b/server/routers/resource/addEmailToResourceWhitelist.ts index 53828b44c..27ba34699 100644 --- a/server/routers/resource/addEmailToResourceWhitelist.ts +++ b/server/routers/resource/addEmailToResourceWhitelist.ts @@ -29,7 +29,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/whitelist/add", description: "Add a single email to the resource whitelist.", - tags: [OpenAPITags.Resource], + tags: [OpenAPITags.PublicResource], request: { params: addEmailToResourceWhitelistParamsSchema, body: { diff --git a/server/routers/resource/addRoleToResource.ts b/server/routers/resource/addRoleToResource.ts index ba344c6c0..7a5c8fb63 100644 --- a/server/routers/resource/addRoleToResource.ts +++ b/server/routers/resource/addRoleToResource.ts @@ -29,7 +29,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/roles/add", description: "Add a single role to a resource.", - tags: [OpenAPITags.Resource, OpenAPITags.Role], + tags: [OpenAPITags.PublicResource, OpenAPITags.Role], request: { params: addRoleToResourceParamsSchema, body: { diff --git a/server/routers/resource/addUserToResource.ts b/server/routers/resource/addUserToResource.ts index ee6081ff8..9880d9c27 100644 --- a/server/routers/resource/addUserToResource.ts +++ b/server/routers/resource/addUserToResource.ts @@ -29,7 +29,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/users/add", description: "Add a single user to a resource.", - tags: [OpenAPITags.Resource, OpenAPITags.User], + tags: [OpenAPITags.PublicResource, OpenAPITags.User], request: { params: addUserToResourceParamsSchema, body: { diff --git a/server/routers/resource/authWithAccessToken.ts b/server/routers/resource/authWithAccessToken.ts index 53f72cb21..a580ee40b 100644 --- a/server/routers/resource/authWithAccessToken.ts +++ b/server/routers/resource/authWithAccessToken.ts @@ -14,6 +14,7 @@ import { verifyResourceAccessToken } from "@server/auth/verifyResourceAccessToke import config from "@server/lib/config"; import stoi from "@server/lib/stoi"; import { logAccessAudit } from "#dynamic/lib/logAccessAudit"; +import { normalizePostAuthPath } from "@server/lib/normalizePostAuthPath"; const authWithAccessTokenBodySchema = z.strictObject({ accessToken: z.string(), @@ -164,10 +165,16 @@ export async function authWithAccessToken( requestIp: req.ip }); + let redirectUrl = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; + const postAuthPath = normalizePostAuthPath(resource.postAuthPath); + if (postAuthPath) { + redirectUrl = redirectUrl + postAuthPath; + } + return response(res, { data: { session: token, - redirectUrl: `${resource.ssl ? "https" : "http"}://${resource.fullDomain}` + redirectUrl }, success: true, error: false, diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index ba1fdba23..6cff4d23a 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -36,7 +36,8 @@ const createHttpResourceSchema = z http: z.boolean(), protocol: z.enum(["tcp", "udp"]), domainId: z.string(), - stickySession: z.boolean().optional() + stickySession: z.boolean().optional(), + postAuthPath: z.string().nullable().optional() }) .refine( (data) => { @@ -78,7 +79,7 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/resource", description: "Create a resource.", - tags: [OpenAPITags.Org, OpenAPITags.Resource], + tags: [OpenAPITags.PublicResource], request: { params: createResourceParamsSchema, body: { @@ -111,7 +112,7 @@ export async function createResource( const { orgId } = parsedParams.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -188,7 +189,7 @@ async function createHttpResource( ); } - const { name, domainId } = parsedBody.data; + const { name, domainId, postAuthPath } = parsedBody.data; const subdomain = parsedBody.data.subdomain; const stickySession = parsedBody.data.stickySession; @@ -222,6 +223,20 @@ async function createHttpResource( ); } + // Prevent creating resource with same domain as dashboard + const dashboardUrl = config.getRawConfig().app.dashboard_url; + if (dashboardUrl) { + const dashboardHost = new URL(dashboardUrl).hostname; + if (fullDomain === dashboardHost) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource domain cannot be the same as the dashboard domain" + ) + ); + } + } + if (build != "oss") { const existingLoginPages = await db .select() @@ -255,7 +270,8 @@ async function createHttpResource( http: true, protocol: "tcp", ssl: true, - stickySession: stickySession + stickySession: stickySession, + postAuthPath: postAuthPath }) .returning(); @@ -276,7 +292,7 @@ async function createHttpResource( resourceId: newResource[0].resourceId }); - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) { // make sure the user can access the resource await trx.insert(userResources).values({ userId: req.user?.userId!, @@ -369,7 +385,7 @@ async function createRawResource( resourceId: newResource[0].resourceId }); - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) { // make sure the user can access the resource await trx.insert(userResources).values({ userId: req.user?.userId!, diff --git a/server/routers/resource/createResourceRule.ts b/server/routers/resource/createResourceRule.ts index 0796191b1..200ee07d4 100644 --- a/server/routers/resource/createResourceRule.ts +++ b/server/routers/resource/createResourceRule.ts @@ -32,7 +32,7 @@ registry.registerPath({ method: "put", path: "/resource/{resourceId}/rule", description: "Create a resource rule.", - tags: [OpenAPITags.Resource, OpenAPITags.Rule], + tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], request: { params: createResourceRuleParamsSchema, body: { diff --git a/server/routers/resource/deleteResource.ts b/server/routers/resource/deleteResource.ts index d8891d75d..e63301867 100644 --- a/server/routers/resource/deleteResource.ts +++ b/server/routers/resource/deleteResource.ts @@ -22,7 +22,7 @@ registry.registerPath({ method: "delete", path: "/resource/{resourceId}", description: "Delete a resource.", - tags: [OpenAPITags.Resource], + tags: [OpenAPITags.PublicResource], request: { params: deleteResourceSchema }, diff --git a/server/routers/resource/deleteResourceRule.ts b/server/routers/resource/deleteResourceRule.ts index 638f2e1de..0fe9007f8 100644 --- a/server/routers/resource/deleteResourceRule.ts +++ b/server/routers/resource/deleteResourceRule.ts @@ -19,7 +19,7 @@ registry.registerPath({ method: "delete", path: "/resource/{resourceId}/rule/{ruleId}", description: "Delete a resource rule.", - tags: [OpenAPITags.Resource, OpenAPITags.Rule], + tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], request: { params: deleteResourceRuleSchema }, diff --git a/server/routers/resource/getResource.ts b/server/routers/resource/getResource.ts index 7f3e8a0ea..7a52c0a85 100644 --- a/server/routers/resource/getResource.ts +++ b/server/routers/resource/getResource.ts @@ -1,15 +1,14 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db } from "@server/db"; -import { Resource, resources, sites } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { db, resources } from "@server/db"; import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import { fromError } from "zod-validation-error"; -import logger from "@server/logger"; import stoi from "@server/lib/stoi"; +import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; const getResourceSchema = z.strictObject({ resourceId: z @@ -54,7 +53,7 @@ registry.registerPath({ path: "/org/{orgId}/resource/{niceId}", description: "Get a resource by orgId and niceId. NiceId is a readable ID for the resource and unique on a per org basis.", - tags: [OpenAPITags.Org, OpenAPITags.Resource], + tags: [OpenAPITags.PublicResource], request: { params: z.object({ orgId: z.string(), @@ -68,7 +67,7 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}", description: "Get a resource by resourceId.", - tags: [OpenAPITags.Resource], + tags: [OpenAPITags.PublicResource], request: { params: z.object({ resourceId: z.number() diff --git a/server/routers/resource/getResourceAuthInfo.ts b/server/routers/resource/getResourceAuthInfo.ts index fe0a38c81..7def75d5b 100644 --- a/server/routers/resource/getResourceAuthInfo.ts +++ b/server/routers/resource/getResourceAuthInfo.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { db, resourceHeaderAuth, + resourceHeaderAuthExtendedCompatibility, resourcePassword, resourcePincode, resources @@ -27,12 +28,14 @@ export type GetResourceAuthInfoResponse = { password: boolean; pincode: boolean; headerAuth: boolean; + headerAuthExtendedCompatibility: boolean; sso: boolean; blockAccess: boolean; url: string; whitelist: boolean; skipToIdpId: number | null; orgId: string; + postAuthPath: string | null; }; export async function getResourceAuthInfo( @@ -76,6 +79,13 @@ export async function getResourceAuthInfo( resources.resourceId ) ) + .leftJoin( + resourceHeaderAuthExtendedCompatibility, + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + resources.resourceId + ) + ) .where(eq(resources.resourceId, Number(resourceGuid))) .limit(1) : await db @@ -97,6 +107,13 @@ export async function getResourceAuthInfo( resources.resourceId ) ) + .leftJoin( + resourceHeaderAuthExtendedCompatibility, + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + resources.resourceId + ) + ) .where(eq(resources.resourceGuid, resourceGuid)) .limit(1); @@ -110,6 +127,8 @@ export async function getResourceAuthInfo( const pincode = result?.resourcePincode; const password = result?.resourcePassword; const headerAuth = result?.resourceHeaderAuth; + const headerAuthExtendedCompatibility = + result?.resourceHeaderAuthExtendedCompatibility; const url = `${resource.ssl ? "https" : "http"}://${resource.fullDomain}`; @@ -122,12 +141,15 @@ export async function getResourceAuthInfo( password: password !== null, pincode: pincode !== null, headerAuth: headerAuth !== null, + headerAuthExtendedCompatibility: + headerAuthExtendedCompatibility !== null, sso: resource.sso, blockAccess: resource.blockAccess, url, whitelist: resource.emailWhitelistEnabled, skipToIdpId: resource.skipToIdpId, - orgId: resource.orgId + orgId: resource.orgId, + postAuthPath: resource.postAuthPath ?? null }, success: true, error: false, diff --git a/server/routers/resource/getResourceWhitelist.ts b/server/routers/resource/getResourceWhitelist.ts index 52cff0c72..5eb05184f 100644 --- a/server/routers/resource/getResourceWhitelist.ts +++ b/server/routers/resource/getResourceWhitelist.ts @@ -31,7 +31,7 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/whitelist", description: "Get the whitelist of emails for a specific resource.", - tags: [OpenAPITags.Resource], + tags: [OpenAPITags.PublicResource], request: { params: getResourceWhitelistSchema }, diff --git a/server/routers/resource/getUserResources.ts b/server/routers/resource/getUserResources.ts index 3d28da6f3..9afd6b4f3 100644 --- a/server/routers/resource/getUserResources.ts +++ b/server/routers/resource/getUserResources.ts @@ -5,10 +5,14 @@ import { resources, userResources, roleResources, + userOrgRoles, userOrgs, resourcePassword, resourcePincode, - resourceWhitelist + resourceWhitelist, + siteResources, + userSiteResources, + roleSiteResources } from "@server/db"; import createHttpError from "http-errors"; import HttpCode from "@server/types/HttpCode"; @@ -29,22 +33,29 @@ export async function getUserResources( ); } - // First get the user's role in the organization - const userOrgResult = await db - .select({ - roleId: userOrgs.roleId - }) + // Check user is in organization and get their role IDs + const [userOrg] = await db + .select() .from(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); - if (userOrgResult.length === 0) { + if (!userOrg) { return next( createHttpError(HttpCode.FORBIDDEN, "User not in organization") ); } - const userRoleId = userOrgResult[0].roleId; + const userRoleIds = await db + .select({ roleId: userOrgRoles.roleId }) + .from(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ) + .then((rows) => rows.map((r) => r.roleId)); // Get resources accessible through direct assignment or role assignment const directResourcesQuery = db @@ -52,14 +63,34 @@ export async function getUserResources( .from(userResources) .where(eq(userResources.userId, userId)); - const roleResourcesQuery = db - .select({ resourceId: roleResources.resourceId }) - .from(roleResources) - .where(eq(roleResources.roleId, userRoleId)); + const roleResourcesQuery = + userRoleIds.length > 0 + ? db + .select({ resourceId: roleResources.resourceId }) + .from(roleResources) + .where(inArray(roleResources.roleId, userRoleIds)) + : Promise.resolve([]); - const [directResources, roleResourceResults] = await Promise.all([ + const directSiteResourcesQuery = db + .select({ siteResourceId: userSiteResources.siteResourceId }) + .from(userSiteResources) + .where(eq(userSiteResources.userId, userId)); + + const roleSiteResourcesQuery = + userRoleIds.length > 0 + ? db + .select({ + siteResourceId: roleSiteResources.siteResourceId + }) + .from(roleSiteResources) + .where(inArray(roleSiteResources.roleId, userRoleIds)) + : Promise.resolve([]); + + const [directResources, roleResourceResults, directSiteResourceResults, roleSiteResourceResults] = await Promise.all([ directResourcesQuery, - roleResourcesQuery + roleResourcesQuery, + directSiteResourcesQuery, + roleSiteResourcesQuery ]); // Combine all accessible resource IDs @@ -68,18 +99,25 @@ export async function getUserResources( ...roleResourceResults.map((r) => r.resourceId) ]; - if (accessibleResourceIds.length === 0) { - return response(res, { - data: { resources: [] }, - success: true, - error: false, - message: "No resources found", - status: HttpCode.OK - }); - } + // Combine all accessible site resource IDs + const accessibleSiteResourceIds = [ + ...directSiteResourceResults.map((r) => r.siteResourceId), + ...roleSiteResourceResults.map((r) => r.siteResourceId) + ]; // Get resource details for accessible resources - const resourcesData = await db + let resourcesData: Array<{ + resourceId: number; + name: string; + fullDomain: string | null; + ssl: boolean; + enabled: boolean; + sso: boolean; + protocol: string; + emailWhitelistEnabled: boolean; + }> = []; + if (accessibleResourceIds.length > 0) { + resourcesData = await db .select({ resourceId: resources.resourceId, name: resources.name, @@ -98,6 +136,40 @@ export async function getUserResources( eq(resources.enabled, true) ) ); + } + + // Get site resource details for accessible site resources + let siteResourcesData: Array<{ + siteResourceId: number; + name: string; + destination: string; + mode: string; + protocol: string | null; + enabled: boolean; + alias: string | null; + aliasAddress: string | null; + }> = []; + if (accessibleSiteResourceIds.length > 0) { + siteResourcesData = await db + .select({ + siteResourceId: siteResources.siteResourceId, + name: siteResources.name, + destination: siteResources.destination, + mode: siteResources.mode, + protocol: siteResources.protocol, + enabled: siteResources.enabled, + alias: siteResources.alias, + aliasAddress: siteResources.aliasAddress + }) + .from(siteResources) + .where( + and( + inArray(siteResources.siteResourceId, accessibleSiteResourceIds), + eq(siteResources.orgId, orgId), + eq(siteResources.enabled, true) + ) + ); + } // Check for password, pincode, and whitelist protection for each resource const resourcesWithAuth = await Promise.all( @@ -161,8 +233,26 @@ export async function getUserResources( }) ); + // Format site resources + const siteResourcesFormatted = siteResourcesData.map((siteResource) => { + return { + siteResourceId: siteResource.siteResourceId, + name: siteResource.name, + destination: siteResource.destination, + mode: siteResource.mode, + protocol: siteResource.protocol, + enabled: siteResource.enabled, + alias: siteResource.alias, + aliasAddress: siteResource.aliasAddress, + type: 'site' as const + }; + }); + return response(res, { - data: { resources: resourcesWithAuth }, + data: { + resources: resourcesWithAuth, + siteResources: siteResourcesFormatted + }, success: true, error: false, message: "User resources retrieved successfully", @@ -190,5 +280,16 @@ export type GetUserResourcesResponse = { protected: boolean; protocol: string; }>; + siteResources: Array<{ + siteResourceId: number; + name: string; + destination: string; + mode: string; + protocol: string | null; + enabled: boolean; + alias: string | null; + aliasAddress: string | null; + type: 'site'; + }>; }; }; diff --git a/server/routers/resource/index.ts b/server/routers/resource/index.ts index e85d30f51..3ada13d85 100644 --- a/server/routers/resource/index.ts +++ b/server/routers/resource/index.ts @@ -30,3 +30,4 @@ export * from "./removeRoleFromResource"; export * from "./addUserToResource"; export * from "./removeUserFromResource"; export * from "./listAllResourceNames"; +export * from "./removeEmailFromResourceWhitelist"; diff --git a/server/routers/resource/listAllResourceNames.ts b/server/routers/resource/listAllResourceNames.ts index df78e2640..37ae945fd 100644 --- a/server/routers/resource/listAllResourceNames.ts +++ b/server/routers/resource/listAllResourceNames.ts @@ -33,7 +33,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/resources-names", description: "List all resource names for an organization.", - tags: [OpenAPITags.Org, OpenAPITags.Resource], + tags: [OpenAPITags.PublicResource], request: { params: z.object({ orgId: z.string() diff --git a/server/routers/resource/listResourceRoles.ts b/server/routers/resource/listResourceRoles.ts index 68dc58a21..4af631191 100644 --- a/server/routers/resource/listResourceRoles.ts +++ b/server/routers/resource/listResourceRoles.ts @@ -35,7 +35,7 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/roles", description: "List all roles for a resource.", - tags: [OpenAPITags.Resource, OpenAPITags.Role], + tags: [OpenAPITags.PublicResource, OpenAPITags.Role], request: { params: listResourceRolesSchema }, diff --git a/server/routers/resource/listResourceRules.ts b/server/routers/resource/listResourceRules.ts index dae7922d9..92d738cbb 100644 --- a/server/routers/resource/listResourceRules.ts +++ b/server/routers/resource/listResourceRules.ts @@ -56,7 +56,7 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/rules", description: "List rules for a resource.", - tags: [OpenAPITags.Resource, OpenAPITags.Rule], + tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], request: { params: listResourceRulesParamsSchema, query: listResourceRulesSchema diff --git a/server/routers/resource/listResourceUsers.ts b/server/routers/resource/listResourceUsers.ts index e7f73287e..2802ac827 100644 --- a/server/routers/resource/listResourceUsers.ts +++ b/server/routers/resource/listResourceUsers.ts @@ -38,7 +38,7 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/users", description: "List all users for a resource.", - tags: [OpenAPITags.Resource, OpenAPITags.User], + tags: [OpenAPITags.PublicResource, OpenAPITags.User], request: { params: listResourceUsersSchema }, diff --git a/server/routers/resource/listResources.ts b/server/routers/resource/listResources.ts index 1c8f08645..fa7ec8a48 100644 --- a/server/routers/resource/listResources.ts +++ b/server/routers/resource/listResources.ts @@ -1,70 +1,120 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db, resourceHeaderAuth } from "@server/db"; import { - resources, - userResources, - roleResources, + db, + resourceHeaderAuth, + resourceHeaderAuthExtendedCompatibility, resourcePassword, resourcePincode, + resources, + roleResources, + targetHealthCheck, targets, - targetHealthCheck + userResources } from "@server/db"; import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import { sql, eq, or, inArray, and, count } from "drizzle-orm"; import logger from "@server/logger"; -import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import { + and, + asc, + count, + desc, + eq, + inArray, + isNull, + like, + not, + or, + sql, + type SQL +} from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromZodError } from "zod-validation-error"; const listResourcesParamsSchema = z.strictObject({ orgId: z.string() }); const listResourcesSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().nonnegative()), - - offset: z - .string() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional(), + sort_by: z + .enum(["name"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["name"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }), + enabled: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined) + .openapi({ + type: "boolean", + description: "Filter resources based on enabled status" + }), + authState: z + .enum(["protected", "not_protected", "none"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["protected", "not_protected", "none"], + description: + "Filter resources based on authentication state. `protected` means the resource has at least one auth mechanism (password, pincode, header auth, SSO, or email whitelist). `not_protected` means the resource has no auth mechanisms. `none` means the resource is not protected by HTTP (i.e. it has no auth mechanisms and http is false)." + }), + healthStatus: z + .enum(["no_targets", "healthy", "degraded", "offline", "unknown"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["no_targets", "healthy", "degraded", "offline", "unknown"], + description: + "Filter resources based on health status of their targets. `healthy` means all targets are healthy. `degraded` means at least one target is unhealthy, but not all are unhealthy. `offline` means all targets are unhealthy. `unknown` means all targets have unknown health status. `no_targets` means the resource has no targets." + }) }); -// (resource fields + a single joined target) -type JoinedRow = { - resourceId: number; - niceId: string; - name: string; - ssl: boolean; - fullDomain: string | null; - passwordId: number | null; - sso: boolean; - pincodeId: number | null; - whitelist: boolean; - http: boolean; - protocol: string; - proxyPort: number | null; - enabled: boolean; - domainId: string | null; - headerAuthId: number | null; - - targetId: number | null; - targetIp: string | null; - targetPort: number | null; - targetEnabled: boolean | null; - - hcHealth: string | null; - hcEnabled: boolean | null; -}; - // grouped by resource with targets[]) export type ResourceWithTargets = { resourceId: number; @@ -87,11 +137,32 @@ export type ResourceWithTargets = { ip: string; port: number; enabled: boolean; - healthStatus?: "healthy" | "unhealthy" | "unknown"; + healthStatus: "healthy" | "unhealthy" | "unknown" | null; }>; }; -function queryResources(accessibleResourceIds: number[], orgId: string) { +// Aggregate filters +const total_targets = count(targets.targetId); +const healthy_targets = sql`SUM( + CASE + WHEN ${targetHealthCheck.hcHealth} = 'healthy' THEN 1 + ELSE 0 + END + ) `; +const unknown_targets = sql`SUM( + CASE + WHEN ${targetHealthCheck.hcHealth} = 'unknown' THEN 1 + ELSE 0 + END + ) `; +const unhealthy_targets = sql`SUM( + CASE + WHEN ${targetHealthCheck.hcHealth} = 'unhealthy' THEN 1 + ELSE 0 + END + ) `; + +function queryResourcesBase() { return db .select({ resourceId: resources.resourceId, @@ -109,14 +180,8 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { domainId: resources.domainId, niceId: resources.niceId, headerAuthId: resourceHeaderAuth.headerAuthId, - - targetId: targets.targetId, - targetIp: targets.ip, - targetPort: targets.port, - targetEnabled: targets.enabled, - - hcHealth: targetHealthCheck.hcHealth, - hcEnabled: targetHealthCheck.hcEnabled + headerAuthExtendedCompatibilityId: + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId }) .from(resources) .leftJoin( @@ -131,29 +196,36 @@ function queryResources(accessibleResourceIds: number[], orgId: string) { resourceHeaderAuth, eq(resourceHeaderAuth.resourceId, resources.resourceId) ) + .leftJoin( + resourceHeaderAuthExtendedCompatibility, + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + resources.resourceId + ) + ) .leftJoin(targets, eq(targets.resourceId, resources.resourceId)) .leftJoin( targetHealthCheck, eq(targetHealthCheck.targetId, targets.targetId) ) - .where( - and( - inArray(resources.resourceId, accessibleResourceIds), - eq(resources.orgId, orgId) - ) + .groupBy( + resources.resourceId, + resourcePassword.passwordId, + resourcePincode.pincodeId, + resourceHeaderAuth.headerAuthId, + resourceHeaderAuthExtendedCompatibility.headerAuthExtendedCompatibilityId ); } -export type ListResourcesResponse = { +export type ListResourcesResponse = PaginatedResponse<{ resources: ResourceWithTargets[]; - pagination: { total: number; limit: number; offset: number }; -}; +}>; registry.registerPath({ method: "get", path: "/org/{orgId}/resources", description: "List resources for an organization.", - tags: [OpenAPITags.Org, OpenAPITags.Resource], + tags: [OpenAPITags.PublicResource], request: { params: z.object({ orgId: z.string() @@ -178,7 +250,16 @@ export async function listResources( ) ); } - const { limit, offset } = parsedQuery.data; + const { + page, + pageSize, + authState, + enabled, + query, + healthStatus, + sort_by, + order + } = parsedQuery.data; const parsedParams = listResourcesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -224,7 +305,7 @@ export async function listResources( .where( or( eq(userResources.userId, req.user!.userId), - eq(roleResources.roleId, req.userOrgRoleId!) + inArray(roleResources.roleId, req.userOrgRoleIds!) ) ); } else { @@ -240,14 +321,139 @@ export async function listResources( (resource) => resource.resourceId ); - const countQuery: any = db - .select({ count: count() }) - .from(resources) - .where(inArray(resources.resourceId, accessibleResourceIds)); + const conditions = [ + and( + inArray(resources.resourceId, accessibleResourceIds), + eq(resources.orgId, orgId) + ) + ]; - const baseQuery = queryResources(accessibleResourceIds, orgId); + if (query) { + conditions.push( + or( + like( + sql`LOWER(${resources.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${resources.niceId})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${resources.fullDomain})`, + "%" + query.toLowerCase() + "%" + ) + ) + ); + } + if (typeof enabled !== "undefined") { + conditions.push(eq(resources.enabled, enabled)); + } - const rows: JoinedRow[] = await baseQuery.limit(limit).offset(offset); + if (typeof authState !== "undefined") { + switch (authState) { + case "none": + conditions.push(eq(resources.http, false)); + break; + case "protected": + conditions.push( + or( + eq(resources.sso, true), + eq(resources.emailWhitelistEnabled, true), + not(isNull(resourceHeaderAuth.headerAuthId)), + not(isNull(resourcePincode.pincodeId)), + not(isNull(resourcePassword.passwordId)) + ) + ); + break; + case "not_protected": + conditions.push( + not(eq(resources.sso, true)), + not(eq(resources.emailWhitelistEnabled, true)), + isNull(resourceHeaderAuth.headerAuthId), + isNull(resourcePincode.pincodeId), + isNull(resourcePassword.passwordId) + ); + break; + } + } + + let aggregateFilters: SQL | undefined = sql`1 = 1`; + + if (typeof healthStatus !== "undefined") { + switch (healthStatus) { + case "healthy": + aggregateFilters = and( + sql`${total_targets} > 0`, + sql`${healthy_targets} = ${total_targets}` + ); + break; + case "degraded": + aggregateFilters = and( + sql`${total_targets} > 0`, + sql`${unhealthy_targets} > 0` + ); + break; + case "no_targets": + aggregateFilters = sql`${total_targets} = 0`; + break; + case "offline": + aggregateFilters = and( + sql`${total_targets} > 0`, + sql`${healthy_targets} = 0`, + sql`${unhealthy_targets} = ${total_targets}` + ); + break; + case "unknown": + aggregateFilters = and( + sql`${total_targets} > 0`, + sql`${unknown_targets} = ${total_targets}` + ); + break; + } + } + + const baseQuery = queryResourcesBase() + .where(and(...conditions)) + .having(aggregateFilters); + + // we need to add `as` so that drizzle filters the result as a subquery + const countQuery = db.$count(baseQuery.as("filtered_resources")); + + const [rows, totalCount] = await Promise.all([ + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(resources[sort_by]) + : desc(resources[sort_by]) + : asc(resources.name) + ), + countQuery + ]); + + const resourceIdList = rows.map((row) => row.resourceId); + const allResourceTargets = + resourceIdList.length === 0 + ? [] + : await db + .select({ + targetId: targets.targetId, + resourceId: targets.resourceId, + ip: targets.ip, + port: targets.port, + enabled: targets.enabled, + healthStatus: targetHealthCheck.hcHealth, + hcEnabled: targetHealthCheck.hcEnabled + }) + .from(targets) + .where(inArray(targets.resourceId, resourceIdList)) + .leftJoin( + targetHealthCheck, + eq(targetHealthCheck.targetId, targets.targetId) + ); // avoids TS issues with reduce/never[] const map = new Map(); @@ -276,44 +482,20 @@ export async function listResources( map.set(row.resourceId, entry); } - if ( - row.targetId != null && - row.targetIp && - row.targetPort != null && - row.targetEnabled != null - ) { - let healthStatus: "healthy" | "unhealthy" | "unknown" = - "unknown"; - - if (row.hcEnabled && row.hcHealth) { - healthStatus = row.hcHealth as - | "healthy" - | "unhealthy" - | "unknown"; - } - - entry.targets.push({ - targetId: row.targetId, - ip: row.targetIp, - port: row.targetPort, - enabled: row.targetEnabled, - healthStatus: healthStatus - }); - } + entry.targets = allResourceTargets.filter( + (t) => t.resourceId === entry.resourceId + ); } const resourcesList: ResourceWithTargets[] = Array.from(map.values()); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0]?.count ?? 0; - return response(res, { data: { resources: resourcesList, pagination: { total: totalCount, - limit, - offset + pageSize, + page } }, success: true, diff --git a/server/routers/resource/removeEmailFromResourceWhitelist.ts b/server/routers/resource/removeEmailFromResourceWhitelist.ts index d60133b85..f419c4136 100644 --- a/server/routers/resource/removeEmailFromResourceWhitelist.ts +++ b/server/routers/resource/removeEmailFromResourceWhitelist.ts @@ -29,7 +29,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/whitelist/remove", description: "Remove a single email from the resource whitelist.", - tags: [OpenAPITags.Resource], + tags: [OpenAPITags.PublicResource], request: { params: removeEmailFromResourceWhitelistParamsSchema, body: { diff --git a/server/routers/resource/removeRoleFromResource.ts b/server/routers/resource/removeRoleFromResource.ts index eab7660c3..eef55277b 100644 --- a/server/routers/resource/removeRoleFromResource.ts +++ b/server/routers/resource/removeRoleFromResource.ts @@ -29,7 +29,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/roles/remove", description: "Remove a single role from a resource.", - tags: [OpenAPITags.Resource, OpenAPITags.Role], + tags: [OpenAPITags.PublicResource, OpenAPITags.Role], request: { params: removeRoleFromResourceParamsSchema, body: { diff --git a/server/routers/resource/removeUserFromResource.ts b/server/routers/resource/removeUserFromResource.ts index 9da96d3c8..152316e62 100644 --- a/server/routers/resource/removeUserFromResource.ts +++ b/server/routers/resource/removeUserFromResource.ts @@ -29,7 +29,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/users/remove", description: "Remove a single user from a resource.", - tags: [OpenAPITags.Resource, OpenAPITags.User], + tags: [OpenAPITags.PublicResource, OpenAPITags.User], request: { params: removeUserFromResourceParamsSchema, body: { diff --git a/server/routers/resource/setResourceHeaderAuth.ts b/server/routers/resource/setResourceHeaderAuth.ts index b89179ae4..9c28bf9f0 100644 --- a/server/routers/resource/setResourceHeaderAuth.ts +++ b/server/routers/resource/setResourceHeaderAuth.ts @@ -1,6 +1,10 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, resourceHeaderAuth } from "@server/db"; +import { + db, + resourceHeaderAuth, + resourceHeaderAuthExtendedCompatibility +} from "@server/db"; import { eq } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -16,7 +20,8 @@ const setResourceAuthMethodsParamsSchema = z.object({ const setResourceAuthMethodsBodySchema = z.strictObject({ user: z.string().min(4).max(100).nullable(), - password: z.string().min(4).max(100).nullable() + password: z.string().min(4).max(100).nullable(), + extendedCompatibility: z.boolean().nullable() }); registry.registerPath({ @@ -24,7 +29,7 @@ registry.registerPath({ path: "/resource/{resourceId}/header-auth", description: "Set or update the header authentication for a resource. If user and password is not provided, it will remove the header authentication.", - tags: [OpenAPITags.Resource], + tags: [OpenAPITags.PublicResource], request: { params: setResourceAuthMethodsParamsSchema, body: { @@ -67,21 +72,38 @@ export async function setResourceHeaderAuth( } const { resourceId } = parsedParams.data; - const { user, password } = parsedBody.data; + const { user, password, extendedCompatibility } = parsedBody.data; await db.transaction(async (trx) => { await trx .delete(resourceHeaderAuth) .where(eq(resourceHeaderAuth.resourceId, resourceId)); + await trx + .delete(resourceHeaderAuthExtendedCompatibility) + .where( + eq( + resourceHeaderAuthExtendedCompatibility.resourceId, + resourceId + ) + ); - if (user && password) { + if (user && password && extendedCompatibility !== null) { const headerAuthHash = await hashPassword( Buffer.from(`${user}:${password}`).toString("base64") ); - await trx - .insert(resourceHeaderAuth) - .values({ resourceId, headerAuthHash }); + await Promise.all([ + trx + .insert(resourceHeaderAuth) + .values({ resourceId, headerAuthHash }), + trx + .insert(resourceHeaderAuthExtendedCompatibility) + .values({ + resourceId, + extendedCompatibilityIsActivated: + extendedCompatibility + }) + ]); } }); diff --git a/server/routers/resource/setResourcePassword.ts b/server/routers/resource/setResourcePassword.ts index 9bd845a4e..d9fcb5f0b 100644 --- a/server/routers/resource/setResourcePassword.ts +++ b/server/routers/resource/setResourcePassword.ts @@ -25,7 +25,7 @@ registry.registerPath({ path: "/resource/{resourceId}/password", description: "Set the password for a resource. Setting the password to null will remove it.", - tags: [OpenAPITags.Resource], + tags: [OpenAPITags.PublicResource], request: { params: setResourceAuthMethodsParamsSchema, body: { diff --git a/server/routers/resource/setResourcePincode.ts b/server/routers/resource/setResourcePincode.ts index 0d5272731..54057ba08 100644 --- a/server/routers/resource/setResourcePincode.ts +++ b/server/routers/resource/setResourcePincode.ts @@ -29,7 +29,7 @@ registry.registerPath({ path: "/resource/{resourceId}/pincode", description: "Set the PIN code for a resource. Setting the PIN code to null will remove it.", - tags: [OpenAPITags.Resource], + tags: [OpenAPITags.PublicResource], request: { params: setResourceAuthMethodsParamsSchema, body: { diff --git a/server/routers/resource/setResourceRoles.ts b/server/routers/resource/setResourceRoles.ts index 751fe4f91..ff3cd7377 100644 --- a/server/routers/resource/setResourceRoles.ts +++ b/server/routers/resource/setResourceRoles.ts @@ -23,7 +23,7 @@ registry.registerPath({ path: "/resource/{resourceId}/roles", description: "Set roles for a resource. This will replace all existing roles.", - tags: [OpenAPITags.Resource, OpenAPITags.Role], + tags: [OpenAPITags.PublicResource, OpenAPITags.Role], request: { params: setResourceRolesParamsSchema, body: { diff --git a/server/routers/resource/setResourceUsers.ts b/server/routers/resource/setResourceUsers.ts index 5ddceb8f0..46b5d1523 100644 --- a/server/routers/resource/setResourceUsers.ts +++ b/server/routers/resource/setResourceUsers.ts @@ -23,7 +23,7 @@ registry.registerPath({ path: "/resource/{resourceId}/users", description: "Set users for a resource. This will replace all existing users.", - tags: [OpenAPITags.Resource, OpenAPITags.User], + tags: [OpenAPITags.PublicResource, OpenAPITags.User], request: { params: setUserResourcesParamsSchema, body: { diff --git a/server/routers/resource/setResourceWhitelist.ts b/server/routers/resource/setResourceWhitelist.ts index 18f612f24..aa5dc8cdb 100644 --- a/server/routers/resource/setResourceWhitelist.ts +++ b/server/routers/resource/setResourceWhitelist.ts @@ -32,7 +32,7 @@ registry.registerPath({ path: "/resource/{resourceId}/whitelist", description: "Set email whitelist for a resource. This will replace all existing emails.", - tags: [OpenAPITags.Resource], + tags: [OpenAPITags.PublicResource], request: { params: setResourceWhitelistParamsSchema, body: { diff --git a/server/routers/resource/types.ts b/server/routers/resource/types.ts new file mode 100644 index 000000000..9dcdcd086 --- /dev/null +++ b/server/routers/resource/types.ts @@ -0,0 +1,10 @@ +export type GetMaintenanceInfoResponse = { + resourceId: number; + name: string; + fullDomain: string | null; + maintenanceModeEnabled: boolean; + maintenanceModeType: "forced" | "automatic" | null; + maintenanceTitle: string | null; + maintenanceMessage: string | null; + maintenanceEstimatedTime: string | null; +}; diff --git a/server/routers/resource/updateResource.ts b/server/routers/resource/updateResource.ts index 1dff9757f..01f3e79ff 100644 --- a/server/routers/resource/updateResource.ts +++ b/server/routers/resource/updateResource.ts @@ -9,7 +9,7 @@ import { Resource, resources } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { eq, and, ne } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -22,8 +22,9 @@ import { registry } from "@server/openApi"; import { OpenAPITags } from "@server/openApi"; import { createCertificate } from "#dynamic/routers/certificates/createCertificate"; import { validateAndConstructDomain } from "@server/lib/domainUtils"; -import { validateHeaders } from "@server/lib/validators"; import { build } from "@server/build"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const updateResourceParamsSchema = z.strictObject({ resourceId: z.string().transform(Number).pipe(z.int().positive()) @@ -32,7 +33,15 @@ const updateResourceParamsSchema = z.strictObject({ const updateHttpResourceBodySchema = z .strictObject({ name: z.string().min(1).max(255).optional(), - niceId: z.string().min(1).max(255).optional(), + niceId: z + .string() + .min(1) + .max(255) + .regex( + /^[a-zA-Z0-9-]+$/, + "niceId can only contain letters, numbers, and dashes" + ) + .optional(), subdomain: subdomainSchema.nullable().optional(), ssl: z.boolean().optional(), sso: z.boolean().optional(), @@ -48,7 +57,14 @@ const updateHttpResourceBodySchema = z headers: z .array(z.strictObject({ name: z.string(), value: z.string() })) .nullable() - .optional() + .optional(), + // Maintenance mode fields + maintenanceModeEnabled: z.boolean().optional(), + maintenanceModeType: z.enum(["forced", "automatic"]).optional(), + maintenanceTitle: z.string().max(255).nullable().optional(), + maintenanceMessage: z.string().max(2000).nullable().optional(), + maintenanceEstimatedTime: z.string().max(100).nullable().optional(), + postAuthPath: z.string().nullable().optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" @@ -85,6 +101,49 @@ const updateHttpResourceBodySchema = z { error: "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header." } + ) + .refine( + (data) => { + if (data.headers) { + // HTTP header names must be valid token characters (RFC 7230) + const validHeaderName = /^[a-zA-Z0-9!#$%&'*+\-.^_`|~]+$/; + return data.headers.every((h) => validHeaderName.test(h.name)); + } + return true; + }, + { + error: "Header names may only contain valid HTTP token characters (letters, digits, and !#$%&'*+-.^_`|~)." + } + ) + .refine( + (data) => { + if (data.headers) { + // HTTP header values must be visible ASCII or horizontal whitespace, no control chars (RFC 7230) + const validHeaderValue = /^[\t\x20-\x7E]*$/; + return data.headers.every((h) => validHeaderValue.test(h.value)); + } + return true; + }, + { + error: "Header values may only contain printable ASCII characters and horizontal whitespace." + } + ) + .refine( + (data) => { + if (data.headers) { + // Reject Traefik template syntax {{word}} in names or values + const templatePattern = /\{\{[^}]+\}\}/; + return data.headers.every( + (h) => + !templatePattern.test(h.name) && + !templatePattern.test(h.value) + ); + } + return true; + }, + { + error: "Header names and values must not contain template expressions such as {{value}}." + } ); export type UpdateResourceResponse = Resource; @@ -120,7 +179,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}", description: "Update a resource.", - tags: [OpenAPITags.Resource], + tags: [OpenAPITags.PublicResource], request: { params: updateResourceParamsSchema, body: { @@ -240,14 +299,13 @@ async function updateHttpResource( .where( and( eq(resources.niceId, updateData.niceId), - eq(resources.orgId, resource.orgId) + eq(resources.orgId, resource.orgId), + ne(resources.resourceId, resource.resourceId) // exclude the current resource from the search ) - ); + ) + .limit(1); - if ( - existingResource && - existingResource.resourceId !== resource.resourceId - ) { + if (existingResource) { return next( createHttpError( HttpCode.CONFLICT, @@ -295,6 +353,20 @@ async function updateHttpResource( ); } + // Prevent updating resource with same domain as dashboard + const dashboardUrl = config.getRawConfig().app.dashboard_url; + if (dashboardUrl) { + const dashboardHost = new URL(dashboardUrl).hostname; + if (fullDomain === dashboardHost) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Resource domain cannot be the same as the dashboard domain" + ) + ); + } + } + if (build != "oss") { const existingLoginPages = await db .select() @@ -335,6 +407,18 @@ async function updateHttpResource( headers = null; } + const isLicensed = await isLicensedOrSubscribed( + resource.orgId, + tierMatrix.maintencePage + ); + if (!isLicensed) { + updateData.maintenanceModeEnabled = undefined; + updateData.maintenanceModeType = undefined; + updateData.maintenanceTitle = undefined; + updateData.maintenanceMessage = undefined; + updateData.maintenanceEstimatedTime = undefined; + } + const updatedResource = await db .update(resources) .set({ ...updateData, headers }) diff --git a/server/routers/resource/updateResourceRule.ts b/server/routers/resource/updateResourceRule.ts index fe717bb5e..4074fd93a 100644 --- a/server/routers/resource/updateResourceRule.ts +++ b/server/routers/resource/updateResourceRule.ts @@ -39,7 +39,7 @@ registry.registerPath({ method: "post", path: "/resource/{resourceId}/rule/{ruleId}", description: "Update a resource rule.", - tags: [OpenAPITags.Resource, OpenAPITags.Rule], + tags: [OpenAPITags.PublicResource, OpenAPITags.Rule], request: { params: updateResourceRuleParamsSchema, body: { diff --git a/server/routers/role/createRole.ts b/server/routers/role/createRole.ts index 16696af49..1fad18d72 100644 --- a/server/routers/role/createRole.ts +++ b/server/routers/role/createRole.ts @@ -10,14 +10,25 @@ import { fromError } from "zod-validation-error"; import { ActionsEnum } from "@server/auth/actions"; import { eq, and } from "drizzle-orm"; import { OpenAPITags, registry } from "@server/openApi"; +import { build } from "@server/build"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const createRoleParamsSchema = z.strictObject({ orgId: z.string() }); +const sshSudoModeSchema = z.enum(["none", "full", "commands"]); + const createRoleSchema = z.strictObject({ name: z.string().min(1).max(255), - description: z.string().optional() + description: z.string().optional(), + requireDeviceApproval: z.boolean().optional(), + allowSsh: z.boolean().optional(), + sshSudoMode: sshSudoModeSchema.optional(), + sshSudoCommands: z.array(z.string()).optional(), + sshCreateHomeDir: z.boolean().optional(), + sshUnixGroups: z.array(z.string()).optional() }); export const defaultRoleAllowedActions: ActionsEnum[] = [ @@ -34,7 +45,7 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/role", description: "Create a role.", - tags: [OpenAPITags.Org, OpenAPITags.Role], + tags: [OpenAPITags.Role], request: { params: createRoleParamsSchema, body: { @@ -97,19 +108,40 @@ export async function createRole( ); } + const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); + if (!isLicensedDeviceApprovals) { + roleData.requireDeviceApproval = undefined; + } + + const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam); + const roleInsertValues: Record = { + name: roleData.name, + orgId + }; + if (roleData.description !== undefined) roleInsertValues.description = roleData.description; + if (roleData.requireDeviceApproval !== undefined) roleInsertValues.requireDeviceApproval = roleData.requireDeviceApproval; + if (isLicensedSshPam) { + if (roleData.sshSudoMode !== undefined) roleInsertValues.sshSudoMode = roleData.sshSudoMode; + if (roleData.sshSudoCommands !== undefined) roleInsertValues.sshSudoCommands = JSON.stringify(roleData.sshSudoCommands); + if (roleData.sshCreateHomeDir !== undefined) roleInsertValues.sshCreateHomeDir = roleData.sshCreateHomeDir; + if (roleData.sshUnixGroups !== undefined) roleInsertValues.sshUnixGroups = JSON.stringify(roleData.sshUnixGroups); + } + await db.transaction(async (trx) => { const newRole = await trx .insert(roles) - .values({ - ...roleData, - orgId - }) + .values(roleInsertValues as typeof roles.$inferInsert) .returning(); + const actionsToInsert = [...defaultRoleAllowedActions]; + if (roleData.allowSsh) { + actionsToInsert.push(ActionsEnum.signSshKey); + } + await trx .insert(roleActions) .values( - defaultRoleAllowedActions.map((action) => ({ + actionsToInsert.map((action) => ({ roleId: newRole[0].roleId, actionId: action, orgId diff --git a/server/routers/role/deleteRole.ts b/server/routers/role/deleteRole.ts index 490fe91cc..4d2797250 100644 --- a/server/routers/role/deleteRole.ts +++ b/server/routers/role/deleteRole.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { roles, userOrgs } from "@server/db"; -import { eq } from "drizzle-orm"; +import { roles, userOrgRoles } from "@server/db"; +import { and, eq, exists, aliasedTable } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -114,13 +114,32 @@ export async function deleteRole( } await db.transaction(async (trx) => { - // move all users from the userOrgs table with roleId to newRoleId - await trx - .update(userOrgs) - .set({ roleId: newRoleId }) - .where(eq(userOrgs.roleId, roleId)); + const uorNewRole = aliasedTable(userOrgRoles, "user_org_roles_new"); + + // Users who already have newRoleId: drop the old assignment only (unique on userId+orgId+roleId). + await trx.delete(userOrgRoles).where( + and( + eq(userOrgRoles.roleId, roleId), + exists( + trx + .select() + .from(uorNewRole) + .where( + and( + eq(uorNewRole.userId, userOrgRoles.userId), + eq(uorNewRole.orgId, userOrgRoles.orgId), + eq(uorNewRole.roleId, newRoleId) + ) + ) + ) + ) + ); + + await trx + .update(userOrgRoles) + .set({ roleId: newRoleId }) + .where(eq(userOrgRoles.roleId, roleId)); - // delete the old role await trx.delete(roles).where(eq(roles.roleId, roleId)); }); diff --git a/server/routers/role/listRoles.ts b/server/routers/role/listRoles.ts index 288a540d1..f1b057a11 100644 --- a/server/routers/role/listRoles.ts +++ b/server/routers/role/listRoles.ts @@ -1,15 +1,14 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db } from "@server/db"; -import { roles, orgs } from "@server/db"; +import { db, orgs, roleActions, roles } from "@server/db"; import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import { sql, eq } from "drizzle-orm"; import logger from "@server/logger"; -import { fromError } from "zod-validation-error"; -import stoi from "@server/lib/stoi"; import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { ActionsEnum } from "@server/auth/actions"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { object, z } from "zod"; +import { fromError } from "zod-validation-error"; const listRolesParamsSchema = z.strictObject({ orgId: z.string() @@ -38,7 +37,12 @@ async function queryRoles(orgId: string, limit: number, offset: number) { isAdmin: roles.isAdmin, name: roles.name, description: roles.description, - orgName: orgs.name + orgName: orgs.name, + requireDeviceApproval: roles.requireDeviceApproval, + sshSudoMode: roles.sshSudoMode, + sshSudoCommands: roles.sshSudoCommands, + sshCreateHomeDir: roles.sshCreateHomeDir, + sshUnixGroups: roles.sshUnixGroups }) .from(roles) .leftJoin(orgs, eq(roles.orgId, orgs.orgId)) @@ -60,7 +64,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/roles", description: "List roles.", - tags: [OpenAPITags.Org, OpenAPITags.Role], + tags: [OpenAPITags.Role], request: { params: listRolesParamsSchema, query: listRolesSchema @@ -107,9 +111,28 @@ export async function listRoles( const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; + let rolesWithAllowSsh = rolesList; + if (rolesList.length > 0) { + const roleIds = rolesList.map((r) => r.roleId); + const signSshKeyRows = await db + .select({ roleId: roleActions.roleId }) + .from(roleActions) + .where( + and( + inArray(roleActions.roleId, roleIds), + eq(roleActions.actionId, ActionsEnum.signSshKey) + ) + ); + const roleIdsWithSsh = new Set(signSshKeyRows.map((r) => r.roleId)); + rolesWithAllowSsh = rolesList.map((r) => ({ + ...r, + allowSsh: roleIdsWithSsh.has(r.roleId) + })); + } + return response(res, { data: { - roles: rolesList, + roles: rolesWithAllowSsh, pagination: { total: totalCount, limit, diff --git a/server/routers/role/updateRole.ts b/server/routers/role/updateRole.ts index c9f63a7b8..7400e5823 100644 --- a/server/routers/role/updateRole.ts +++ b/server/routers/role/updateRole.ts @@ -1,27 +1,61 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; -import { roles } from "@server/db"; -import { eq } from "drizzle-orm"; +import { db, type Role } from "@server/db"; +import { roleActions, roles } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import { ActionsEnum } from "@server/auth/actions"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { OpenAPITags, registry } from "@server/openApi"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; const updateRoleParamsSchema = z.strictObject({ roleId: z.string().transform(Number).pipe(z.int().positive()) }); +const sshSudoModeSchema = z.enum(["none", "full", "commands"]); + const updateRoleBodySchema = z .strictObject({ name: z.string().min(1).max(255).optional(), - description: z.string().optional() + description: z.string().optional(), + requireDeviceApproval: z.boolean().optional(), + allowSsh: z.boolean().optional(), + sshSudoMode: sshSudoModeSchema.optional(), + sshSudoCommands: z.array(z.string()).optional(), + sshCreateHomeDir: z.boolean().optional(), + sshUnixGroups: z.array(z.string()).optional() }) .refine((data) => Object.keys(data).length > 0, { error: "At least one field must be provided for update" }); +export type UpdateRoleBody = z.infer; + +export type UpdateRoleResponse = Role; + +registry.registerPath({ + method: "post", + path: "/role/{roleId}", + description: "Update a role.", + tags: [OpenAPITags.Role], + request: { + params: updateRoleParamsSchema, + body: { + content: { + "application/json": { + schema: updateRoleBodySchema + } + } + } + }, + responses: {} +}); + export async function updateRole( req: Request, res: Response, @@ -49,7 +83,9 @@ export async function updateRole( } const { roleId } = parsedParams.data; - const updateData = parsedBody.data; + const body = parsedBody.data; + const { allowSsh, ...restBody } = body; + const updateData: Record = { ...restBody }; const role = await db .select() @@ -66,22 +102,87 @@ export async function updateRole( ); } - if (role[0].isAdmin) { + const orgId = role[0].orgId; + const isAdminRole = role[0].isAdmin; + + if (isAdminRole) { + delete updateData.name; + delete updateData.description; + } + + if (!orgId) { return next( createHttpError( - HttpCode.FORBIDDEN, - `Cannot update a Admin role` + HttpCode.BAD_REQUEST, + "Role does not have an organization ID" ) ); } - const updatedRole = await db - .update(roles) - .set(updateData) - .where(eq(roles.roleId, roleId)) - .returning(); + const isLicensedDeviceApprovals = await isLicensedOrSubscribed(orgId, tierMatrix.deviceApprovals); + if (!isLicensedDeviceApprovals) { + updateData.requireDeviceApproval = undefined; + } - if (updatedRole.length === 0) { + const isLicensedSshPam = await isLicensedOrSubscribed(orgId, tierMatrix.sshPam); + if (!isLicensedSshPam) { + delete updateData.sshSudoMode; + delete updateData.sshSudoCommands; + delete updateData.sshCreateHomeDir; + delete updateData.sshUnixGroups; + } else { + if (Array.isArray(updateData.sshSudoCommands)) { + updateData.sshSudoCommands = JSON.stringify(updateData.sshSudoCommands); + } + if (Array.isArray(updateData.sshUnixGroups)) { + updateData.sshUnixGroups = JSON.stringify(updateData.sshUnixGroups); + } + } + + const updatedRole = await db.transaction(async (trx) => { + const result = await trx + .update(roles) + .set(updateData as typeof roles.$inferInsert) + .where(eq(roles.roleId, roleId)) + .returning(); + + if (result.length === 0) { + return null; + } + + if (allowSsh === true) { + const existing = await trx + .select() + .from(roleActions) + .where( + and( + eq(roleActions.roleId, roleId), + eq(roleActions.actionId, ActionsEnum.signSshKey) + ) + ) + .limit(1); + if (existing.length === 0) { + await trx.insert(roleActions).values({ + roleId, + actionId: ActionsEnum.signSshKey, + orgId: orgId! + }); + } + } else if (allowSsh === false) { + await trx + .delete(roleActions) + .where( + and( + eq(roleActions.roleId, roleId), + eq(roleActions.actionId, ActionsEnum.signSshKey) + ) + ); + } + + return result[0]; + }); + + if (!updatedRole) { return next( createHttpError( HttpCode.NOT_FOUND, @@ -91,7 +192,7 @@ export async function updateRole( } return response(res, { - data: updatedRole[0], + data: updatedRole, success: true, error: false, message: "Role updated successfully", diff --git a/server/routers/serverInfo/getServerInfo.ts b/server/routers/serverInfo/getServerInfo.ts new file mode 100644 index 000000000..fa71cedc4 --- /dev/null +++ b/server/routers/serverInfo/getServerInfo.ts @@ -0,0 +1,60 @@ +import { Request, Response, NextFunction } from "express"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { response as sendResponse } from "@server/lib/response"; +import config from "@server/lib/config"; +import { build } from "@server/build"; +import { APP_VERSION } from "@server/lib/consts"; +import license from "#dynamic/license/license"; + +export type GetServerInfoResponse = { + version: string; + supporterStatusValid: boolean; + build: "oss" | "enterprise" | "saas"; + enterpriseLicenseValid: boolean; + enterpriseLicenseType: string | null; +}; + +export async function getServerInfo( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const supporterData = config.getSupporterData(); + const supporterStatusValid = supporterData?.valid || false; + + let enterpriseLicenseValid = false; + let enterpriseLicenseType: string | null = null; + + if (build === "enterprise") { + try { + const licenseStatus = await license.check(); + enterpriseLicenseValid = licenseStatus.isLicenseValid; + enterpriseLicenseType = licenseStatus.tier || null; + } catch (error) { + logger.warn("Failed to check enterprise license status:", error); + } + } + + return sendResponse(res, { + data: { + version: APP_VERSION, + supporterStatusValid, + build, + enterpriseLicenseValid, + enterpriseLicenseType + }, + success: true, + error: false, + message: "Server info retrieved", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/serverInfo/index.ts b/server/routers/serverInfo/index.ts new file mode 100644 index 000000000..1bbfbdba5 --- /dev/null +++ b/server/routers/serverInfo/index.ts @@ -0,0 +1 @@ +export * from "./getServerInfo"; diff --git a/server/routers/site/createSite.ts b/server/routers/site/createSite.ts index c798ea30c..4edebb080 100644 --- a/server/routers/site/createSite.ts +++ b/server/routers/site/createSite.ts @@ -6,7 +6,7 @@ import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; -import { eq, and } from "drizzle-orm"; +import { eq, and, count } from "drizzle-orm"; import { getUniqueSiteName } from "../../db/names"; import { addPeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; @@ -18,6 +18,8 @@ import { isValidIP } from "@server/lib/validators"; import { isIpInCidr } from "@server/lib/ip"; import { verifyExitNodeOrgAccess } from "#dynamic/lib/exitNodes"; import { build } from "@server/build"; +import { usageService } from "@server/lib/billing/usageService"; +import { FeatureId } from "@server/lib/billing"; const createSiteParamsSchema = z.strictObject({ orgId: z.string() @@ -56,7 +58,7 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/site", description: "Create a new site.", - tags: [OpenAPITags.Site, OpenAPITags.Org], + tags: [OpenAPITags.Site], request: { params: createSiteParamsSchema, body: { @@ -109,7 +111,7 @@ export async function createSite( const { orgId } = parsedParams.data; - if (req.user && !req.userOrgRoleId) { + if (req.user && (!req.userOrgRoleIds || req.userOrgRoleIds.length === 0)) { return next( createHttpError(HttpCode.FORBIDDEN, "User does not have a role") ); @@ -126,6 +128,35 @@ export async function createSite( ); } + if (build == "saas") { + const usage = await usageService.getUsage(orgId, FeatureId.SITES); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectSites = await usageService.checkLimitSet( + orgId, + + FeatureId.SITES, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectSites) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Site limit exceeded. Please upgrade your plan." + ) + ); + } + } + let updatedAddress = null; if (address) { if (!org.subnet) { @@ -256,10 +287,21 @@ export async function createSite( const niceId = await getUniqueSiteName(orgId); - let newSite: Site; - + let newSite: Site | undefined; await db.transaction(async (trx) => { - if (type == "wireguard" || type == "newt") { + if (type == "newt") { + [newSite] = await trx + .insert(sites) + .values({ // NOTE: NO SUBNET OR EXIT NODE ID PASSED IN HERE BECAUSE ITS NOW CHOSEN ON CONNECT + orgId, + name, + niceId, + address: updatedAddress || null, + type, + dockerSocketEnabled: true + }) + .returning(); + } else if (type == "wireguard") { // we are creating a site with an exit node (tunneled) if (!subnet) { return next( @@ -311,11 +353,9 @@ export async function createSite( exitNodeId, name, niceId, - address: updatedAddress || null, subnet, type, - dockerSocketEnabled: type == "newt", - ...(pubKey && type == "wireguard" && { pubKey }) + pubKey: pubKey || null }) .returning(); } else if (type == "local") { @@ -359,7 +399,7 @@ export async function createSite( siteId: newSite.siteId }); - if (req.user && req.userOrgRoleId != adminRole[0].roleId) { + if (req.user && !req.userOrgRoleIds?.includes(adminRole[0].roleId)) { // make sure the user can access the site trx.insert(userSites).values({ userId: req.user?.userId!, @@ -402,13 +442,24 @@ export async function createSite( }); } - return response(res, { - data: newSite, - success: true, - error: false, - message: "Site created successfully", - status: HttpCode.CREATED - }); + await usageService.add(orgId, FeatureId.SITES, 1, trx); + }); + + if (!newSite) { + return next( + createHttpError( + HttpCode.INTERNAL_SERVER_ERROR, + "Failed to create site" + ) + ); + } + + return response(res, { + data: newSite, + success: true, + error: false, + message: "Site created successfully", + status: HttpCode.CREATED }); } catch (error) { logger.error(error); diff --git a/server/routers/site/deleteSite.ts b/server/routers/site/deleteSite.ts index 09750c31b..587572535 100644 --- a/server/routers/site/deleteSite.ts +++ b/server/routers/site/deleteSite.ts @@ -1,6 +1,6 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db } from "@server/db"; +import { db, Site, siteResources } from "@server/db"; import { newts, newtSessions, sites } from "@server/db"; import { eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -11,6 +11,9 @@ import { deletePeer } from "../gerbil/peers"; import { fromError } from "zod-validation-error"; import { sendToClient } from "#dynamic/routers/ws"; import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import { usageService } from "@server/lib/billing/usageService"; +import { FeatureId } from "@server/lib/billing"; const deleteSiteSchema = z.strictObject({ siteId: z.string().transform(Number).pipe(z.int().positive()) @@ -63,33 +66,49 @@ export async function deleteSite( let deletedNewtId: string | null = null; await db.transaction(async (trx) => { - if (site.pubKey) { - if (site.type == "wireguard") { + if (site.type == "wireguard") { + if (site.pubKey) { await deletePeer(site.exitNodeId!, site.pubKey); - } else if (site.type == "newt") { - // get the newt on the site by querying the newt table for siteId - const [deletedNewt] = await trx - .delete(newts) - .where(eq(newts.siteId, siteId)) - .returning(); - if (deletedNewt) { - deletedNewtId = deletedNewt.newtId; + } + } else if (site.type == "newt") { + // delete all of the site resources on this site + const siteResourcesOnSite = trx + .delete(siteResources) + .where(eq(siteResources.siteId, siteId)) + .returning(); - // delete all of the sessions for the newt - await trx - .delete(newtSessions) - .where(eq(newtSessions.newtId, deletedNewt.newtId)); - } + // loop through them + for (const removedSiteResource of await siteResourcesOnSite) { + await rebuildClientAssociationsFromSiteResource( + removedSiteResource, + trx + ); + } + + // get the newt on the site by querying the newt table for siteId + const [deletedNewt] = await trx + .delete(newts) + .where(eq(newts.siteId, siteId)) + .returning(); + if (deletedNewt) { + deletedNewtId = deletedNewt.newtId; + + // delete all of the sessions for the newt + await trx + .delete(newtSessions) + .where(eq(newtSessions.newtId, deletedNewt.newtId)); } } await trx.delete(sites).where(eq(sites.siteId, siteId)); + + await usageService.add(site.orgId, FeatureId.SITES, -1, trx); }); // Send termination message outside of transaction to prevent blocking if (deletedNewtId) { const payload = { - type: `newt/terminate`, + type: `newt/wg/terminate`, data: {} }; // Don't await this to prevent blocking the response diff --git a/server/routers/site/getSite.ts b/server/routers/site/getSite.ts index c82bf1999..45d49abe6 100644 --- a/server/routers/site/getSite.ts +++ b/server/routers/site/getSite.ts @@ -51,7 +51,7 @@ registry.registerPath({ path: "/org/{orgId}/site/{niceId}", description: "Get a site by orgId and niceId. NiceId is a readable ID for the site and unique on a per org basis.", - tags: [OpenAPITags.Org, OpenAPITags.Site], + tags: [OpenAPITags.Site], request: { params: z.object({ orgId: z.string(), diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 37ca8fe48..a244c650c 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -1,20 +1,29 @@ -import { db, exitNodes, newts } from "@server/db"; -import { orgs, roleSites, sites, userSites } from "@server/db"; -import logger from "@server/logger"; -import HttpCode from "@server/types/HttpCode"; +import { + db, + exitNodes, + newts, + orgs, + remoteExitNodes, + roleSites, + sites, + userSites +} from "@server/db"; +import cache from "#dynamic/lib/cache"; import response from "@server/lib/response"; -import { and, count, eq, inArray, or, sql } from "drizzle-orm"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; +import semver from "semver"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { OpenAPITags, registry } from "@server/openApi"; -import semver from "semver"; -import cache from "@server/lib/cache"; async function getLatestNewtVersion(): Promise { try { - const cachedVersion = cache.get("latestNewtVersion"); + const cachedVersion = await cache.get("latestNewtVersion"); if (cachedVersion) { return cachedVersion; } @@ -38,15 +47,15 @@ async function getLatestNewtVersion(): Promise { return null; } - const tags = await response.json(); + let tags = await response.json(); if (!Array.isArray(tags) || tags.length === 0) { logger.warn("No tags found for Newt repository"); return null; } - + tags = tags.filter((version) => !version.name.includes("rc")); const latestVersion = tags[0].name; - cache.set("latestNewtVersion", latestVersion); + await cache.set("latestNewtVersion", latestVersion, 3600); return latestVersion; } catch (error: any) { @@ -73,21 +82,63 @@ const listSitesParamsSchema = z.strictObject({ }); const listSitesSchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().positive()), - offset: z - .string() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional(), + sort_by: z + .enum(["name", "megabytesIn", "megabytesOut"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["name", "megabytesIn", "megabytesOut"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }), + online: z + .enum(["true", "false"]) + .transform((v) => v === "true") + .optional() + .catch(undefined) + .openapi({ + type: "boolean", + description: "Filter by online status" + }) }); -function querySites(orgId: string, accessibleSiteIds: number[]) { +function querySitesBase() { return db .select({ siteId: sites.siteId, @@ -104,28 +155,26 @@ function querySites(orgId: string, accessibleSiteIds: number[]) { newtVersion: newts.version, exitNodeId: sites.exitNodeId, exitNodeName: exitNodes.name, - exitNodeEndpoint: exitNodes.endpoint + exitNodeEndpoint: exitNodes.endpoint, + remoteExitNodeId: remoteExitNodes.remoteExitNodeId }) .from(sites) .leftJoin(orgs, eq(sites.orgId, orgs.orgId)) .leftJoin(newts, eq(newts.siteId, sites.siteId)) .leftJoin(exitNodes, eq(exitNodes.exitNodeId, sites.exitNodeId)) - .where( - and( - inArray(sites.siteId, accessibleSiteIds), - eq(sites.orgId, orgId) - ) + .leftJoin( + remoteExitNodes, + eq(remoteExitNodes.exitNodeId, sites.exitNodeId) ); } -type SiteWithUpdateAvailable = Awaited>[0] & { +type SiteWithUpdateAvailable = Awaited>[0] & { newtUpdateAvailable?: boolean; }; -export type ListSitesResponse = { +export type ListSitesResponse = PaginatedResponse<{ sites: SiteWithUpdateAvailable[]; - pagination: { total: number; limit: number; offset: number }; -}; +}>; registry.registerPath({ method: "get", @@ -154,7 +203,6 @@ export async function listSites( ) ); } - const { limit, offset } = parsedQuery.data; const parsedParams = listSitesParamsSchema.safeParse(req.params); if (!parsedParams.success) { @@ -187,7 +235,7 @@ export async function listSites( .where( or( eq(userSites.userId, req.user!.userId), - eq(roleSites.roleId, req.userOrgRoleId!) + inArray(roleSites.roleId, req.userOrgRoleIds!) ) ); } else { @@ -197,34 +245,67 @@ export async function listSites( .where(eq(sites.orgId, orgId)); } - const accessibleSiteIds = accessibleSites.map((site) => site.siteId); - const baseQuery = querySites(orgId, accessibleSiteIds); + const { pageSize, page, query, sort_by, order, online } = + parsedQuery.data; - const countQuery = db - .select({ count: count() }) - .from(sites) - .where( - and( - inArray(sites.siteId, accessibleSiteIds), - eq(sites.orgId, orgId) + const accessibleSiteIds = accessibleSites.map((site) => site.siteId); + + const conditions = [ + and( + inArray(sites.siteId, accessibleSiteIds), + eq(sites.orgId, orgId) + ) + ]; + if (query) { + conditions.push( + or( + like( + sql`LOWER(${sites.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${sites.niceId})`, + "%" + query.toLowerCase() + "%" + ) ) ); + } + if (typeof online !== "undefined") { + conditions.push(eq(sites.online, online)); + } - const sitesList = await baseQuery.limit(limit).offset(offset); - const totalCountResult = await countQuery; - const totalCount = totalCountResult[0].count; + const baseQuery = querySitesBase().where(and(...conditions)); + + // we need to add `as` so that drizzle filters the result as a subquery + const countQuery = db.$count( + querySitesBase().where(and(...conditions)).as("filtered_sites") + ); + + const siteListQuery = baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(sites[sort_by]) + : desc(sites[sort_by]) + : asc(sites.name) + ); + + const [totalCount, rows] = await Promise.all([ + countQuery, + siteListQuery + ]); // Get latest version asynchronously without blocking the response const latestNewtVersionPromise = getLatestNewtVersion(); - const sitesWithUpdates: SiteWithUpdateAvailable[] = sitesList.map( - (site) => { - const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; - // Initially set to false, will be updated if version check succeeds - siteWithUpdate.newtUpdateAvailable = false; - return siteWithUpdate; - } - ); + const sitesWithUpdates: SiteWithUpdateAvailable[] = rows.map((site) => { + const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; + // Initially set to false, will be updated if version check succeeds + siteWithUpdate.newtUpdateAvailable = false; + return siteWithUpdate; + }); // Try to get the latest version, but don't block if it fails try { @@ -261,8 +342,8 @@ export async function listSites( sites: sitesWithUpdates, pagination: { total: totalCount, - limit, - offset + pageSize, + page } }, success: true, diff --git a/server/routers/site/pickSiteDefaults.ts b/server/routers/site/pickSiteDefaults.ts index 69ed76886..f5e95ca10 100644 --- a/server/routers/site/pickSiteDefaults.ts +++ b/server/routers/site/pickSiteDefaults.ts @@ -35,7 +35,7 @@ registry.registerPath({ path: "/org/{orgId}/pick-site-defaults", description: "Return pre-requisite data for creating a site, such as the exit node, subnet, Newt credentials, etc.", - tags: [OpenAPITags.Org, OpenAPITags.Site], + tags: [OpenAPITags.Site], request: { params: z.object({ orgId: z.string() diff --git a/server/routers/site/socketIntegration.ts b/server/routers/site/socketIntegration.ts index e0ad09d1e..fe6e7b95e 100644 --- a/server/routers/site/socketIntegration.ts +++ b/server/routers/site/socketIntegration.ts @@ -11,7 +11,7 @@ import { fromError } from "zod-validation-error"; import stoi from "@server/lib/stoi"; import { sendToClient } from "#dynamic/routers/ws"; import { fetchContainers, dockerSocket } from "../newt/dockerSocket"; -import cache from "@server/lib/cache"; +import cache from "#dynamic/lib/cache"; export interface ContainerNetwork { networkId: string; @@ -150,7 +150,7 @@ async function triggerFetch(siteId: number) { // clear the cache for this Newt ID so that the site has to keep asking for the containers // this is to ensure that the site always gets the latest data - cache.del(`${newt.newtId}:dockerContainers`); + await cache.del(`${newt.newtId}:dockerContainers`); return { siteId, newtId: newt.newtId }; } @@ -158,7 +158,7 @@ async function triggerFetch(siteId: number) { async function queryContainers(siteId: number) { const { newt } = await getSiteAndNewt(siteId); - const result = cache.get(`${newt.newtId}:dockerContainers`) as Container[]; + const result = await cache.get(`${newt.newtId}:dockerContainers`); if (!result) { throw createHttpError( HttpCode.TOO_EARLY, @@ -173,7 +173,7 @@ async function isDockerAvailable(siteId: number): Promise { const { newt } = await getSiteAndNewt(siteId); const key = `${newt.newtId}:isAvailable`; - const isAvailable = cache.get(key); + const isAvailable = await cache.get(key); return !!isAvailable; } @@ -186,9 +186,11 @@ async function getDockerStatus( const keys = ["isAvailable", "socketPath"]; const mappedKeys = keys.map((x) => `${newt.newtId}:${x}`); + const values = await cache.mget(mappedKeys); + const result = { - isAvailable: cache.get(mappedKeys[0]) as boolean, - socketPath: cache.get(mappedKeys[1]) as string | undefined + isAvailable: values[0] as boolean, + socketPath: values[1] as string | undefined }; return result; diff --git a/server/routers/site/updateSite.ts b/server/routers/site/updateSite.ts index 447643628..ca0f76783 100644 --- a/server/routers/site/updateSite.ts +++ b/server/routers/site/updateSite.ts @@ -2,7 +2,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; import { sites } from "@server/db"; -import { eq, and } from "drizzle-orm"; +import { eq, and, ne } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -19,8 +19,8 @@ const updateSiteBodySchema = z .strictObject({ name: z.string().min(1).max(255).optional(), niceId: z.string().min(1).max(255).optional(), - dockerSocketEnabled: z.boolean().optional(), - remoteSubnets: z.string().optional() + dockerSocketEnabled: z.boolean().optional() + // remoteSubnets: z.string().optional() // subdomain: z // .string() // .min(1) @@ -86,18 +86,19 @@ export async function updateSite( // if niceId is provided, check if it's already in use by another site if (updateData.niceId) { - const existingSite = await db + const [existingSite] = await db .select() .from(sites) .where( and( eq(sites.niceId, updateData.niceId), - eq(sites.orgId, sites.orgId) + eq(sites.orgId, sites.orgId), + ne(sites.siteId, siteId) ) ) .limit(1); - if (existingSite.length > 0 && existingSite[0].siteId !== siteId) { + if (existingSite) { return next( createHttpError( HttpCode.CONFLICT, @@ -107,22 +108,22 @@ export async function updateSite( } } - // if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs - if (updateData.remoteSubnets) { - const subnets = updateData.remoteSubnets - .split(",") - .map((s) => s.trim()); - for (const subnet of subnets) { - if (!isValidCIDR(subnet)) { - return next( - createHttpError( - HttpCode.BAD_REQUEST, - `Invalid CIDR format: ${subnet}` - ) - ); - } - } - } + // // if remoteSubnets is provided, ensure it's a valid comma-separated list of cidrs + // if (updateData.remoteSubnets) { + // const subnets = updateData.remoteSubnets + // .split(",") + // .map((s) => s.trim()); + // for (const subnet of subnets) { + // if (!isValidCIDR(subnet)) { + // return next( + // createHttpError( + // HttpCode.BAD_REQUEST, + // `Invalid CIDR format: ${subnet}` + // ) + // ); + // } + // } + // } const updatedSite = await db .update(sites) diff --git a/server/routers/siteProvisioning/types.ts b/server/routers/siteProvisioning/types.ts new file mode 100644 index 000000000..d06c1fe26 --- /dev/null +++ b/server/routers/siteProvisioning/types.ts @@ -0,0 +1,41 @@ +export type SiteProvisioningKeyListItem = { + siteProvisioningKeyId: string; + orgId: string; + lastChars: string; + createdAt: string; + name: string; + lastUsed: string | null; + maxBatchSize: number | null; + numUsed: number; + validUntil: string | null; +}; + +export type ListSiteProvisioningKeysResponse = { + siteProvisioningKeys: SiteProvisioningKeyListItem[]; + pagination: { total: number; limit: number; offset: number }; +}; + +export type CreateSiteProvisioningKeyResponse = { + siteProvisioningKeyId: string; + orgId: string; + name: string; + siteProvisioningKey: string; + lastChars: string; + createdAt: string; + lastUsed: string | null; + maxBatchSize: number | null; + numUsed: number; + validUntil: string | null; +}; + +export type UpdateSiteProvisioningKeyResponse = { + siteProvisioningKeyId: string; + orgId: string; + name: string; + lastChars: string; + createdAt: string; + lastUsed: string | null; + maxBatchSize: number | null; + numUsed: number; + validUntil: string | null; +}; diff --git a/server/routers/siteResource/addClientToSiteResource.ts b/server/routers/siteResource/addClientToSiteResource.ts index 27d7f0573..4a67df94f 100644 --- a/server/routers/siteResource/addClientToSiteResource.ts +++ b/server/routers/siteResource/addClientToSiteResource.ts @@ -30,7 +30,7 @@ registry.registerPath({ path: "/site-resource/{siteResourceId}/clients/add", description: "Add a single client to a site resource. Clients with a userId cannot be added.", - tags: [OpenAPITags.Resource, OpenAPITags.Client], + tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], request: { params: addClientToSiteResourceParamsSchema, body: { diff --git a/server/routers/siteResource/addRoleToSiteResource.ts b/server/routers/siteResource/addRoleToSiteResource.ts index abc2d221e..f6501463b 100644 --- a/server/routers/siteResource/addRoleToSiteResource.ts +++ b/server/routers/siteResource/addRoleToSiteResource.ts @@ -30,7 +30,7 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/roles/add", description: "Add a single role to a site resource.", - tags: [OpenAPITags.Resource, OpenAPITags.Role], + tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], request: { params: addRoleToSiteResourceParamsSchema, body: { diff --git a/server/routers/siteResource/addUserToSiteResource.ts b/server/routers/siteResource/addUserToSiteResource.ts index 4edf741cd..68151077c 100644 --- a/server/routers/siteResource/addUserToSiteResource.ts +++ b/server/routers/siteResource/addUserToSiteResource.ts @@ -30,7 +30,7 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/users/add", description: "Add a single user to a site resource.", - tags: [OpenAPITags.Resource, OpenAPITags.User], + tags: [OpenAPITags.PrivateResource, OpenAPITags.User], request: { params: addUserToSiteResourceParamsSchema, body: { diff --git a/server/routers/siteResource/batchAddClientToSiteResources.ts b/server/routers/siteResource/batchAddClientToSiteResources.ts new file mode 100644 index 000000000..c3ad3859a --- /dev/null +++ b/server/routers/siteResource/batchAddClientToSiteResources.ts @@ -0,0 +1,247 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { + db, + clients, + clientSiteResources, + siteResources, + apiKeyOrg +} from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq, and, inArray } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; +import { + rebuildClientAssociationsFromClient, + rebuildClientAssociationsFromSiteResource +} from "@server/lib/rebuildClientAssociations"; + +const batchAddClientToSiteResourcesParamsSchema = z + .object({ + clientId: z.string().transform(Number).pipe(z.number().int().positive()) + }) + .strict(); + +const batchAddClientToSiteResourcesBodySchema = z + .object({ + siteResourceIds: z + .array(z.number().int().positive()) + .min(1, "At least one siteResourceId is required") + }) + .strict(); + +registry.registerPath({ + method: "post", + path: "/client/{clientId}/site-resources", + description: "Add a machine client to multiple site resources at once.", + tags: [OpenAPITags.Client], + request: { + params: batchAddClientToSiteResourcesParamsSchema, + body: { + content: { + "application/json": { + schema: batchAddClientToSiteResourcesBodySchema + } + } + } + }, + responses: {} +}); + +export async function batchAddClientToSiteResources( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const apiKey = req.apiKey; + if (!apiKey) { + return next( + createHttpError(HttpCode.UNAUTHORIZED, "Key not authenticated") + ); + } + + const parsedParams = + batchAddClientToSiteResourcesParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedBody = batchAddClientToSiteResourcesBodySchema.safeParse( + req.body + ); + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { clientId } = parsedParams.data; + const { siteResourceIds } = parsedBody.data; + const uniqueSiteResourceIds = [...new Set(siteResourceIds)]; + + const batchSiteResources = await db + .select() + .from(siteResources) + .where( + inArray(siteResources.siteResourceId, uniqueSiteResourceIds) + ); + + if (batchSiteResources.length !== uniqueSiteResourceIds.length) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "One or more site resources not found" + ) + ); + } + + if (!apiKey.isRoot) { + const orgIds = [ + ...new Set(batchSiteResources.map((sr) => sr.orgId)) + ]; + if (orgIds.length > 1) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "All site resources must belong to the same organization" + ) + ); + } + const orgId = orgIds[0]; + const [apiKeyOrgRow] = await db + .select() + .from(apiKeyOrg) + .where( + and( + eq(apiKeyOrg.apiKeyId, apiKey.apiKeyId), + eq(apiKeyOrg.orgId, orgId) + ) + ) + .limit(1); + + if (!apiKeyOrgRow) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to the organization of the specified site resources" + ) + ); + } + + const [clientInOrg] = await db + .select() + .from(clients) + .where( + and( + eq(clients.clientId, clientId), + eq(clients.orgId, orgId) + ) + ) + .limit(1); + + if (!clientInOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Key does not have access to the specified client" + ) + ); + } + } + + const [client] = await db + .select() + .from(clients) + .where(eq(clients.clientId, clientId)) + .limit(1); + + if (!client) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Client not found") + ); + } + + if (client.userId !== null) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "This endpoint only supports machine (non-user) clients; the specified client is associated with a user" + ) + ); + } + + const existingEntries = await db + .select({ + siteResourceId: clientSiteResources.siteResourceId + }) + .from(clientSiteResources) + .where( + and( + eq(clientSiteResources.clientId, clientId), + inArray( + clientSiteResources.siteResourceId, + batchSiteResources.map((sr) => sr.siteResourceId) + ) + ) + ); + + const existingSiteResourceIds = new Set( + existingEntries.map((e) => e.siteResourceId) + ); + const siteResourcesToAdd = batchSiteResources.filter( + (sr) => !existingSiteResourceIds.has(sr.siteResourceId) + ); + + if (siteResourcesToAdd.length === 0) { + return next( + createHttpError( + HttpCode.CONFLICT, + "Client is already assigned to all specified site resources" + ) + ); + } + + await db.transaction(async (trx) => { + for (const siteResource of siteResourcesToAdd) { + await trx.insert(clientSiteResources).values({ + clientId, + siteResourceId: siteResource.siteResourceId + }); + } + + await rebuildClientAssociationsFromClient(client, trx); + }); + + return response(res, { + data: { + addedCount: siteResourcesToAdd.length, + skippedCount: + batchSiteResources.length - siteResourcesToAdd.length, + siteResourceIds: siteResourcesToAdd.map( + (sr) => sr.siteResourceId + ) + }, + success: true, + error: false, + message: `Client added to ${siteResourcesToAdd.length} site resource(s) successfully`, + status: HttpCode.CREATED + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/siteResource/createSiteResource.ts b/server/routers/siteResource/createSiteResource.ts index e9ce8e04f..1485a4192 100644 --- a/server/routers/siteResource/createSiteResource.ts +++ b/server/routers/siteResource/createSiteResource.ts @@ -2,6 +2,7 @@ import { clientSiteResources, db, newts, + orgs, roles, roleSiteResources, SiteResource, @@ -10,7 +11,13 @@ import { userSiteResources } from "@server/db"; import { getUniqueSiteResourceName } from "@server/db/names"; -import { getNextAvailableAliasAddress } from "@server/lib/ip"; +import { + getNextAvailableAliasAddress, + isIpInCidr, + portRangeStringSchema +} from "@server/lib/ip"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; import response from "@server/lib/response"; import logger from "@server/logger"; @@ -23,7 +30,6 @@ import { z } from "zod"; import { fromError } from "zod-validation-error"; const createSiteResourceParamsSchema = z.strictObject({ - siteId: z.string().transform(Number).pipe(z.int().positive()), orgId: z.string() }); @@ -31,6 +37,7 @@ const createSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255), mode: z.enum(["host", "cidr", "port"]), + siteId: z.int(), // protocol: z.enum(["tcp", "udp"]).optional(), // proxyPort: z.int().positive().optional(), // destinationPort: z.int().positive().optional(), @@ -39,13 +46,18 @@ const createSiteResourceSchema = z alias: z .string() .regex( - /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, - "Alias must be a fully qualified domain name (e.g., example.com)" + /^(?:[a-zA-Z0-9*?](?:[a-zA-Z0-9*?-]{0,61}[a-zA-Z0-9*?])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, + "Alias must be a fully qualified domain name with optional wildcards (e.g., example.com, *.example.com, host-0?.example.internal)" ) .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(), + authDaemonPort: z.int().positive().optional(), + authDaemonMode: z.enum(["site", "remote"]).optional() }) .strict() .refine( @@ -65,7 +77,10 @@ const createSiteResourceSchema = z const domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; const isValidDomain = domainRegex.test(data.destination); - const isValidAlias = data.alias && domainRegex.test(data.alias); + const isValidAlias = + data.alias !== undefined && + data.alias !== null && + data.alias.trim() !== ""; return isValidDomain && isValidAlias; // require the alias to be set in the case of domain } @@ -73,7 +88,7 @@ const createSiteResourceSchema = z }, { message: - "Destination must be a valid IP address or valid domain AND alias is required" + "Destination must be a valid IPV4 address or valid domain AND alias is required" } ) .refine( @@ -81,8 +96,7 @@ 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()]) // for now lets just do ipv4 until we verify ipv6 works everywhere + .union([z.cidrv4(), z.cidrv6()]) .safeParse(data.destination).success; return isValidCIDR; } @@ -98,9 +112,9 @@ export type CreateSiteResourceResponse = SiteResource; registry.registerPath({ method: "put", - path: "/org/{orgId}/site/{siteId}/resource", + path: "/org/{orgId}/site-resource", description: "Create a new site resource.", - tags: [OpenAPITags.Client, OpenAPITags.Org], + tags: [OpenAPITags.PrivateResource], request: { params: createSiteResourceParamsSchema, body: { @@ -142,9 +156,10 @@ export async function createSiteResource( ); } - const { siteId, orgId } = parsedParams.data; + const { orgId } = parsedParams.data; const { name, + siteId, mode, // protocol, // proxyPort, @@ -154,7 +169,12 @@ export async function createSiteResource( alias, userIds, roleIds, - clientIds + clientIds, + tcpPortRangeString, + udpPortRangeString, + disableIcmp, + authDaemonPort, + authDaemonMode } = parsedBody.data; // Verify the site exists and belongs to the org @@ -168,6 +188,44 @@ export async function createSiteResource( return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); } + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Organization not found") + ); + } + + if (!org.subnet || !org.utilitySubnet) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Organization with ID ${orgId} has no subnet or utilitySubnet defined defined` + ) + ); + } + + // Only check if destination is an IP address + const isIp = z + .union([z.ipv4(), z.ipv6()]) + .safeParse(destination).success; + if ( + isIp && + (isIpInCidr(destination, org.subnet) || + isIpInCidr(destination, org.utilitySubnet)) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IP can not be in the CIDR range of the organization's subnet or utility subnet" + ) + ); + } + // // check if resource with same protocol and proxy port already exists (only for port mode) // if (mode === "port" && protocol && proxyPort) { // const [existingResource] = await db @@ -215,6 +273,11 @@ export async function createSiteResource( } } + const isLicensedSshPam = await isLicensedOrSubscribed( + orgId, + tierMatrix.sshPam + ); + const niceId = await getUniqueSiteResourceName(orgId); let aliasAddress: string | null = null; if (mode == "host") { @@ -225,22 +288,29 @@ export async function createSiteResource( let newSiteResource: SiteResource | undefined; await db.transaction(async (trx) => { // Create the site resource + const insertValues: typeof siteResources.$inferInsert = { + siteId, + niceId, + orgId, + name, + mode: mode as "host" | "cidr", + destination, + enabled, + alias, + aliasAddress, + tcpPortRangeString, + udpPortRangeString, + disableIcmp + }; + if (isLicensedSshPam) { + if (authDaemonPort !== undefined) + insertValues.authDaemonPort = authDaemonPort; + if (authDaemonMode !== undefined) + insertValues.authDaemonMode = authDaemonMode; + } [newSiteResource] = await trx .insert(siteResources) - .values({ - siteId, - niceId, - orgId, - name, - mode, - // protocol: mode === "port" ? protocol : null, - // proxyPort: mode === "port" ? proxyPort : null, - // destinationPort: mode === "port" ? destinationPort : null, - destination, - enabled, - alias, - aliasAddress - }) + .values(insertValues) .returning(); const siteResourceId = newSiteResource.siteResourceId; diff --git a/server/routers/siteResource/deleteSiteResource.ts b/server/routers/siteResource/deleteSiteResource.ts index 3d1e70cc7..5b50b0ea3 100644 --- a/server/routers/siteResource/deleteSiteResource.ts +++ b/server/routers/siteResource/deleteSiteResource.ts @@ -12,9 +12,7 @@ import { OpenAPITags, registry } from "@server/openApi"; import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; const deleteSiteResourceParamsSchema = z.strictObject({ - siteResourceId: z.string().transform(Number).pipe(z.int().positive()), - siteId: z.string().transform(Number).pipe(z.int().positive()), - orgId: z.string() + siteResourceId: z.string().transform(Number).pipe(z.int().positive()) }); export type DeleteSiteResourceResponse = { @@ -23,9 +21,9 @@ export type DeleteSiteResourceResponse = { registry.registerPath({ method: "delete", - path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}", + path: "/site-resource/{siteResourceId}", description: "Delete a site resource.", - tags: [OpenAPITags.Client, OpenAPITags.Org], + tags: [OpenAPITags.PrivateResource], request: { params: deleteSiteResourceParamsSchema }, @@ -50,29 +48,13 @@ export async function deleteSiteResource( ); } - const { siteResourceId, siteId, orgId } = parsedParams.data; - - const [site] = await db - .select() - .from(sites) - .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) - .limit(1); - - if (!site) { - return next(createHttpError(HttpCode.NOT_FOUND, "Site not found")); - } + const { siteResourceId } = parsedParams.data; // Check if site resource exists const [existingSiteResource] = await db .select() .from(siteResources) - .where( - and( - eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.siteId, siteId), - eq(siteResources.orgId, orgId) - ) - ) + .where(and(eq(siteResources.siteResourceId, siteResourceId))) .limit(1); if (!existingSiteResource) { @@ -85,19 +67,13 @@ export async function deleteSiteResource( // Delete the site resource const [removedSiteResource] = await trx .delete(siteResources) - .where( - and( - eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.siteId, siteId), - eq(siteResources.orgId, orgId) - ) - ) + .where(and(eq(siteResources.siteResourceId, siteResourceId))) .returning(); const [newt] = await trx .select() .from(newts) - .where(eq(newts.siteId, site.siteId)) + .where(eq(newts.siteId, removedSiteResource.siteId)) .limit(1); if (!newt) { @@ -112,9 +88,7 @@ export async function deleteSiteResource( ); }); - logger.info( - `Deleted site resource ${siteResourceId} for site ${siteId}` - ); + logger.info(`Deleted site resource ${siteResourceId}`); return response(res, { data: { message: "Site resource deleted successfully" }, diff --git a/server/routers/siteResource/getSiteResource.ts b/server/routers/siteResource/getSiteResource.ts index 7cb9e620f..be28d36e4 100644 --- a/server/routers/siteResource/getSiteResource.ts +++ b/server/routers/siteResource/getSiteResource.ts @@ -63,9 +63,9 @@ export type GetSiteResourceResponse = NonNullable< registry.registerPath({ method: "get", - path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}", + path: "/site-resource/{siteResourceId}", description: "Get a specific site resource by siteResourceId.", - tags: [OpenAPITags.Client, OpenAPITags.Org], + tags: [OpenAPITags.PrivateResource], request: { params: z.object({ siteResourceId: z.number(), @@ -80,7 +80,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/site/{siteId}/resource/nice/{niceId}", description: "Get a specific site resource by niceId.", - tags: [OpenAPITags.Client, OpenAPITags.Org], + tags: [OpenAPITags.PrivateResource], request: { params: z.object({ niceId: z.string(), diff --git a/server/routers/siteResource/index.ts b/server/routers/siteResource/index.ts index 9494843bf..5c09d3883 100644 --- a/server/routers/siteResource/index.ts +++ b/server/routers/siteResource/index.ts @@ -15,4 +15,5 @@ export * from "./addUserToSiteResource"; export * from "./removeUserFromSiteResource"; export * from "./setSiteResourceClients"; export * from "./addClientToSiteResource"; +export * from "./batchAddClientToSiteResources"; export * from "./removeClientFromSiteResource"; diff --git a/server/routers/siteResource/listAllSiteResourcesByOrg.ts b/server/routers/siteResource/listAllSiteResourcesByOrg.ts index f6975cd24..3320aa3b7 100644 --- a/server/routers/siteResource/listAllSiteResourcesByOrg.ts +++ b/server/routers/siteResource/listAllSiteResourcesByOrg.ts @@ -1,47 +1,118 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; -import { db } from "@server/db"; -import { siteResources, sites, SiteResource } from "@server/db"; +import { db, SiteResource, siteResources, sites } from "@server/db"; import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import { eq, and } from "drizzle-orm"; -import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; +import HttpCode from "@server/types/HttpCode"; +import type { PaginatedResponse } from "@server/types/Pagination"; +import { and, asc, desc, eq, like, or, sql } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; const listAllSiteResourcesByOrgParamsSchema = z.strictObject({ orgId: z.string() }); const listAllSiteResourcesByOrgQuerySchema = z.object({ - limit: z - .string() + pageSize: z.coerce + .number() // for prettier formatting + .int() + .positive() .optional() - .default("1000") - .transform(Number) - .pipe(z.int().positive()), - offset: z - .string() + .catch(20) + .default(20) + .openapi({ + type: "integer", + default: 20, + description: "Number of items per page" + }), + page: z.coerce + .number() // for prettier formatting + .int() + .min(0) .optional() - .default("0") - .transform(Number) - .pipe(z.int().nonnegative()) + .catch(1) + .default(1) + .openapi({ + type: "integer", + default: 1, + description: "Page number to retrieve" + }), + query: z.string().optional(), + mode: z + .enum(["host", "cidr"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["host", "cidr"], + description: "Filter site resources by mode" + }), + sort_by: z + .enum(["name"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["name"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }) }); -export type ListAllSiteResourcesByOrgResponse = { +export type ListAllSiteResourcesByOrgResponse = PaginatedResponse<{ siteResources: (SiteResource & { siteName: string; siteNiceId: string; siteAddress: string | null; })[]; -}; +}>; + +function querySiteResourcesBase() { + return db + .select({ + siteResourceId: siteResources.siteResourceId, + siteId: siteResources.siteId, + orgId: siteResources.orgId, + niceId: siteResources.niceId, + name: siteResources.name, + mode: siteResources.mode, + protocol: siteResources.protocol, + proxyPort: siteResources.proxyPort, + destinationPort: siteResources.destinationPort, + destination: siteResources.destination, + enabled: siteResources.enabled, + alias: siteResources.alias, + aliasAddress: siteResources.aliasAddress, + tcpPortRangeString: siteResources.tcpPortRangeString, + udpPortRangeString: siteResources.udpPortRangeString, + disableIcmp: siteResources.disableIcmp, + authDaemonMode: siteResources.authDaemonMode, + authDaemonPort: siteResources.authDaemonPort, + siteName: sites.name, + siteNiceId: sites.niceId, + siteAddress: sites.address + }) + .from(siteResources) + .innerJoin(sites, eq(siteResources.siteId, sites.siteId)); +} registry.registerPath({ method: "get", path: "/org/{orgId}/site-resources", description: "List all site resources for an organization.", - tags: [OpenAPITags.Client, OpenAPITags.Org], + tags: [OpenAPITags.PrivateResource], request: { params: listAllSiteResourcesByOrgParamsSchema, query: listAllSiteResourcesByOrgQuerySchema @@ -80,35 +151,74 @@ export async function listAllSiteResourcesByOrg( } const { orgId } = parsedParams.data; - const { limit, offset } = parsedQuery.data; + const { page, pageSize, query, mode, sort_by, order } = + parsedQuery.data; - // Get all site resources for the org with site names - const siteResourcesList = await db - .select({ - siteResourceId: siteResources.siteResourceId, - siteId: siteResources.siteId, - orgId: siteResources.orgId, - niceId: siteResources.niceId, - name: siteResources.name, - mode: siteResources.mode, - protocol: siteResources.protocol, - proxyPort: siteResources.proxyPort, - destinationPort: siteResources.destinationPort, - destination: siteResources.destination, - enabled: siteResources.enabled, - alias: siteResources.alias, - siteName: sites.name, - siteNiceId: sites.niceId, - siteAddress: sites.address - }) - .from(siteResources) - .innerJoin(sites, eq(siteResources.siteId, sites.siteId)) - .where(eq(siteResources.orgId, orgId)) - .limit(limit) - .offset(offset); + const conditions = [and(eq(siteResources.orgId, orgId))]; + if (query) { + conditions.push( + or( + like( + sql`LOWER(${siteResources.name})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${siteResources.niceId})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${siteResources.destination})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${siteResources.alias})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${siteResources.aliasAddress})`, + "%" + query.toLowerCase() + "%" + ), + like( + sql`LOWER(${sites.name})`, + "%" + query.toLowerCase() + "%" + ) + ) + ); + } - return response(res, { - data: { siteResources: siteResourcesList }, + if (mode) { + conditions.push(eq(siteResources.mode, mode)); + } + + const baseQuery = querySiteResourcesBase().where(and(...conditions)); + + const countQuery = db.$count( + querySiteResourcesBase().where(and(...conditions)).as("filtered_site_resources") + ); + + const [siteResourcesList, totalCount] = await Promise.all([ + baseQuery + .limit(pageSize) + .offset(pageSize * (page - 1)) + .orderBy( + sort_by + ? order === "asc" + ? asc(siteResources[sort_by]) + : desc(siteResources[sort_by]) + : asc(siteResources.name) + ), + countQuery + ]); + + return response(res, { + data: { + siteResources: siteResourcesList, + pagination: { + total: totalCount, + pageSize, + page + } + }, success: true, error: false, message: "Site resources retrieved successfully", diff --git a/server/routers/siteResource/listSiteResourceClients.ts b/server/routers/siteResource/listSiteResourceClients.ts index 772750d16..867e66b49 100644 --- a/server/routers/siteResource/listSiteResourceClients.ts +++ b/server/routers/siteResource/listSiteResourceClients.ts @@ -39,7 +39,7 @@ registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}/clients", description: "List all clients for a site resource.", - tags: [OpenAPITags.Resource, OpenAPITags.Client], + tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], request: { params: listSiteResourceClientsSchema }, diff --git a/server/routers/siteResource/listSiteResourceRoles.ts b/server/routers/siteResource/listSiteResourceRoles.ts index 0dc5913b6..679a93f7e 100644 --- a/server/routers/siteResource/listSiteResourceRoles.ts +++ b/server/routers/siteResource/listSiteResourceRoles.ts @@ -40,7 +40,7 @@ registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}/roles", description: "List all roles for a site resource.", - tags: [OpenAPITags.Resource, OpenAPITags.Role], + tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], request: { params: listSiteResourceRolesSchema }, diff --git a/server/routers/siteResource/listSiteResourceUsers.ts b/server/routers/siteResource/listSiteResourceUsers.ts index daf754801..e50d8684e 100644 --- a/server/routers/siteResource/listSiteResourceUsers.ts +++ b/server/routers/siteResource/listSiteResourceUsers.ts @@ -43,7 +43,7 @@ registry.registerPath({ method: "get", path: "/site-resource/{siteResourceId}/users", description: "List all users for a site resource.", - tags: [OpenAPITags.Resource, OpenAPITags.User], + tags: [OpenAPITags.PrivateResource, OpenAPITags.User], request: { params: listSiteResourceUsersSchema }, diff --git a/server/routers/siteResource/listSiteResources.ts b/server/routers/siteResource/listSiteResources.ts index 6ecda7c4c..358aa0497 100644 --- a/server/routers/siteResource/listSiteResources.ts +++ b/server/routers/siteResource/listSiteResources.ts @@ -5,7 +5,7 @@ import { siteResources, sites, SiteResource } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { eq, and } from "drizzle-orm"; +import { and, asc, desc, eq } from "drizzle-orm"; import { fromError } from "zod-validation-error"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -27,7 +27,27 @@ const listSiteResourcesQuerySchema = z.object({ .optional() .default("0") .transform(Number) - .pipe(z.int().nonnegative()) + .pipe(z.int().nonnegative()), + sort_by: z + .enum(["name"]) + .optional() + .catch(undefined) + .openapi({ + type: "string", + enum: ["name"], + description: "Field to sort by" + }), + order: z + .enum(["asc", "desc"]) + .optional() + .default("asc") + .catch("asc") + .openapi({ + type: "string", + enum: ["asc", "desc"], + default: "asc", + description: "Sort order" + }) }); export type ListSiteResourcesResponse = { @@ -38,7 +58,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/site/{siteId}/resources", description: "List site resources for a site.", - tags: [OpenAPITags.Client, OpenAPITags.Org], + tags: [OpenAPITags.PrivateResource], request: { params: listSiteResourcesParamsSchema, query: listSiteResourcesQuerySchema @@ -75,7 +95,7 @@ export async function listSiteResources( } const { siteId, orgId } = parsedParams.data; - const { limit, offset } = parsedQuery.data; + const { limit, offset, sort_by, order } = parsedQuery.data; // Verify the site exists and belongs to the org const site = await db @@ -98,6 +118,13 @@ export async function listSiteResources( eq(siteResources.orgId, orgId) ) ) + .orderBy( + sort_by + ? order === "asc" + ? asc(siteResources[sort_by]) + : desc(siteResources[sort_by]) + : asc(siteResources.name) + ) .limit(limit) .offset(offset); diff --git a/server/routers/siteResource/removeClientFromSiteResource.ts b/server/routers/siteResource/removeClientFromSiteResource.ts index 351128d18..51e54dd92 100644 --- a/server/routers/siteResource/removeClientFromSiteResource.ts +++ b/server/routers/siteResource/removeClientFromSiteResource.ts @@ -30,7 +30,7 @@ registry.registerPath({ path: "/site-resource/{siteResourceId}/clients/remove", description: "Remove a single client from a site resource. Clients with a userId cannot be removed.", - tags: [OpenAPITags.Resource, OpenAPITags.Client], + tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], request: { params: removeClientFromSiteResourceParamsSchema, body: { diff --git a/server/routers/siteResource/removeRoleFromSiteResource.ts b/server/routers/siteResource/removeRoleFromSiteResource.ts index c9857e841..19478ed0f 100644 --- a/server/routers/siteResource/removeRoleFromSiteResource.ts +++ b/server/routers/siteResource/removeRoleFromSiteResource.ts @@ -30,7 +30,7 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/roles/remove", description: "Remove a single role from a site resource.", - tags: [OpenAPITags.Resource, OpenAPITags.Role], + tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], request: { params: removeRoleFromSiteResourceParamsSchema, body: { diff --git a/server/routers/siteResource/removeUserFromSiteResource.ts b/server/routers/siteResource/removeUserFromSiteResource.ts index 84347b2f6..70bb5e22e 100644 --- a/server/routers/siteResource/removeUserFromSiteResource.ts +++ b/server/routers/siteResource/removeUserFromSiteResource.ts @@ -30,7 +30,7 @@ registry.registerPath({ method: "post", path: "/site-resource/{siteResourceId}/users/remove", description: "Remove a single user from a site resource.", - tags: [OpenAPITags.Resource, OpenAPITags.User], + tags: [OpenAPITags.PrivateResource, OpenAPITags.User], request: { params: removeUserFromSiteResourceParamsSchema, body: { diff --git a/server/routers/siteResource/setSiteResourceClients.ts b/server/routers/siteResource/setSiteResourceClients.ts index 5a8acbcf5..7aff3875f 100644 --- a/server/routers/siteResource/setSiteResourceClients.ts +++ b/server/routers/siteResource/setSiteResourceClients.ts @@ -30,7 +30,7 @@ registry.registerPath({ path: "/site-resource/{siteResourceId}/clients", description: "Set clients for a site resource. This will replace all existing clients. Clients with a userId cannot be added.", - tags: [OpenAPITags.Resource, OpenAPITags.Client], + tags: [OpenAPITags.PrivateResource, OpenAPITags.Client], request: { params: setSiteResourceClientsParamsSchema, body: { diff --git a/server/routers/siteResource/setSiteResourceRoles.ts b/server/routers/siteResource/setSiteResourceRoles.ts index bb71a16b6..a1ee80b40 100644 --- a/server/routers/siteResource/setSiteResourceRoles.ts +++ b/server/routers/siteResource/setSiteResourceRoles.ts @@ -31,7 +31,7 @@ registry.registerPath({ path: "/site-resource/{siteResourceId}/roles", description: "Set roles for a site resource. This will replace all existing roles.", - tags: [OpenAPITags.Resource, OpenAPITags.Role], + tags: [OpenAPITags.PrivateResource, OpenAPITags.Role], request: { params: setSiteResourceRolesParamsSchema, body: { diff --git a/server/routers/siteResource/setSiteResourceUsers.ts b/server/routers/siteResource/setSiteResourceUsers.ts index eacd826cc..109e8c429 100644 --- a/server/routers/siteResource/setSiteResourceUsers.ts +++ b/server/routers/siteResource/setSiteResourceUsers.ts @@ -31,7 +31,7 @@ registry.registerPath({ path: "/site-resource/{siteResourceId}/users", description: "Set users for a site resource. This will replace all existing users.", - tags: [OpenAPITags.Resource, OpenAPITags.User], + tags: [OpenAPITags.PrivateResource, OpenAPITags.User], request: { params: setSiteResourceUsersParamsSchema, body: { diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 92704adb4..8f56ece0f 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -1,44 +1,55 @@ -import { Request, Response, NextFunction } from "express"; -import { z } from "zod"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { clientSiteResources, clientSiteResourcesAssociationsCache, db, newts, + orgs, roles, roleSiteResources, + SiteResource, + siteResources, sites, Transaction, userSiteResources } from "@server/db"; -import { siteResources, SiteResource } from "@server/db"; -import response from "@server/lib/response"; -import HttpCode from "@server/types/HttpCode"; -import createHttpError from "http-errors"; -import { eq, and, ne } from "drizzle-orm"; -import { fromError } from "zod-validation-error"; -import logger from "@server/logger"; -import { OpenAPITags, registry } from "@server/openApi"; -import { updatePeerData, updateTargets } from "@server/routers/client/targets"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { generateAliasConfig, generateRemoteSubnets, - generateSubnetProxyTargets + generateSubnetProxyTargetV2, + isIpInCidr, + portRangeStringSchema } from "@server/lib/ip"; -import { - getClientSiteResourceAccess, - rebuildClientAssociationsFromSiteResource -} from "@server/lib/rebuildClientAssociations"; +import { rebuildClientAssociationsFromSiteResource } from "@server/lib/rebuildClientAssociations"; +import response from "@server/lib/response"; +import logger from "@server/logger"; +import { OpenAPITags, registry } from "@server/openApi"; +import { updatePeerData, updateTargets } from "@server/routers/client/targets"; +import HttpCode from "@server/types/HttpCode"; +import { and, eq, ne } from "drizzle-orm"; +import { NextFunction, Request, Response } from "express"; +import createHttpError from "http-errors"; +import { z } from "zod"; +import { fromError } from "zod-validation-error"; const updateSiteResourceParamsSchema = z.strictObject({ - siteResourceId: z.string().transform(Number).pipe(z.int().positive()), - siteId: z.string().transform(Number).pipe(z.int().positive()), - orgId: z.string() + siteResourceId: z.string().transform(Number).pipe(z.int().positive()) }); const updateSiteResourceSchema = z .strictObject({ name: z.string().min(1).max(255).optional(), + siteId: z.int(), + niceId: z + .string() + .min(1) + .max(255) + .regex( + /^[a-zA-Z0-9-]+$/, + "niceId can only contain letters, numbers, and dashes" + ) + .optional(), // mode: z.enum(["host", "cidr", "port"]).optional(), mode: z.enum(["host", "cidr"]).optional(), // protocol: z.enum(["tcp", "udp"]).nullish(), @@ -49,13 +60,18 @@ const updateSiteResourceSchema = z alias: z .string() .regex( - /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, - "Alias must be a fully qualified domain name (e.g., example.internal)" + /^(?:[a-zA-Z0-9*?](?:[a-zA-Z0-9*?-]{0,61}[a-zA-Z0-9*?])?\.)+[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/, + "Alias must be a fully qualified domain name with optional wildcards (e.g., example.internal, *.example.internal, host-0?.example.internal)" ) .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(), + authDaemonPort: z.int().positive().nullish(), + authDaemonMode: z.enum(["site", "remote"]).optional() }) .strict() .refine( @@ -74,7 +90,10 @@ const updateSiteResourceSchema = z const domainRegex = /^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; const isValidDomain = domainRegex.test(data.destination); - const isValidAlias = data.alias && domainRegex.test(data.alias); + const isValidAlias = + data.alias !== undefined && + data.alias !== null && + data.alias.trim() !== ""; return isValidDomain && isValidAlias; // require the alias to be set in the case of domain } @@ -90,8 +109,7 @@ 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()]) // for now lets just do ipv4 until we verify ipv6 works everywhere + .union([z.cidrv4(), z.cidrv6()]) .safeParse(data.destination).success; return isValidCIDR; } @@ -107,9 +125,9 @@ export type UpdateSiteResourceResponse = SiteResource; registry.registerPath({ method: "post", - path: "/org/{orgId}/site/{siteId}/resource/{siteResourceId}", + path: "/site-resource/{siteResourceId}", description: "Update a site resource.", - tags: [OpenAPITags.Client, OpenAPITags.Org], + tags: [OpenAPITags.PrivateResource], request: { params: updateSiteResourceParamsSchema, body: { @@ -151,22 +169,29 @@ export async function updateSiteResource( ); } - const { siteResourceId, siteId, orgId } = parsedParams.data; + const { siteResourceId } = parsedParams.data; const { name, + siteId, // because it can change + niceId, mode, destination, alias, enabled, userIds, roleIds, - clientIds + clientIds, + tcpPortRangeString, + udpPortRangeString, + disableIcmp, + authDaemonPort, + authDaemonMode } = parsedBody.data; const [site] = await db .select() .from(sites) - .where(and(eq(sites.siteId, siteId), eq(sites.orgId, orgId))) + .where(eq(sites.siteId, siteId)) .limit(1); if (!site) { @@ -177,13 +202,7 @@ export async function updateSiteResource( const [existingSiteResource] = await db .select() .from(siteResources) - .where( - and( - eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.siteId, siteId), - eq(siteResources.orgId, orgId) - ) - ) + .where(and(eq(siteResources.siteResourceId, siteResourceId))) .limit(1); if (!existingSiteResource) { @@ -192,6 +211,70 @@ export async function updateSiteResource( ); } + const isLicensedSshPam = await isLicensedOrSubscribed( + existingSiteResource.orgId, + tierMatrix.sshPam + ); + + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, existingSiteResource.orgId)) + .limit(1); + + if (!org) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Organization not found") + ); + } + + if (!org.subnet || !org.utilitySubnet) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + `Organization with ID ${existingSiteResource.orgId} has no subnet or utilitySubnet defined defined` + ) + ); + } + + // Only check if destination is an IP address + const isIp = z + .union([z.ipv4(), z.ipv6()]) + .safeParse(destination).success; + if ( + isIp && + (isIpInCidr(destination!, org.subnet) || + isIpInCidr(destination!, org.utilitySubnet)) + ) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "IP can not be in the CIDR range of the organization's subnet or utility subnet" + ) + ); + } + + let existingSite = site; + let siteChanged = false; + if (existingSiteResource.siteId !== siteId) { + siteChanged = true; + // get the existing site + [existingSite] = await db + .select() + .from(sites) + .where(eq(sites.siteId, existingSiteResource.siteId)) + .limit(1); + + if (!existingSite) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Existing site not found" + ) + ); + } + } + // make sure the alias is unique within the org if provided if (alias) { const [conflict] = await db @@ -199,7 +282,7 @@ export async function updateSiteResource( .from(siteResources) .where( and( - eq(siteResources.orgId, orgId), + eq(siteResources.orgId, existingSiteResource.orgId), eq(siteResources.alias, alias.trim()), ne(siteResources.siteResourceId, siteResourceId) // exclude self ) @@ -218,97 +301,249 @@ export async function updateSiteResource( let updatedSiteResource: SiteResource | undefined; await db.transaction(async (trx) => { - // Update the site resource - [updatedSiteResource] = await trx - .update(siteResources) - .set({ - name: name, - mode: mode, - destination: destination, - enabled: enabled, - alias: alias && alias.trim() ? alias : null - }) - .where( - and( - eq(siteResources.siteResourceId, siteResourceId), - eq(siteResources.siteId, siteId), - eq(siteResources.orgId, orgId) - ) - ) - .returning(); - - //////////////////// update the associations //////////////////// - - await trx - .delete(clientSiteResources) - .where(eq(clientSiteResources.siteResourceId, siteResourceId)); - - if (clientIds.length > 0) { - await trx.insert(clientSiteResources).values( - clientIds.map((clientId) => ({ - clientId, - siteResourceId - })) - ); - } - - await trx - .delete(userSiteResources) - .where(eq(userSiteResources.siteResourceId, siteResourceId)); - - if (userIds.length > 0) { + // if the site is changed we need to delete and recreate the resource to avoid complications with the rebuild function otherwise we can just update in place + if (siteChanged) { + // delete the existing site resource await trx - .insert(userSiteResources) - .values( - userIds.map((userId) => ({ userId, siteResourceId })) + .delete(siteResources) + .where( + and(eq(siteResources.siteResourceId, siteResourceId)) ); - } - // Get all admin role IDs for this org to exclude from deletion - const adminRoles = await trx - .select() - .from(roles) - .where( - and( - eq(roles.isAdmin, true), - eq(roles.orgId, updatedSiteResource.orgId) - ) + await rebuildClientAssociationsFromSiteResource( + existingSiteResource, + trx ); - const adminRoleIds = adminRoles.map((role) => role.roleId); - if (adminRoleIds.length > 0) { - await trx.delete(roleSiteResources).where( - and( - eq(roleSiteResources.siteResourceId, siteResourceId), - ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role + // create the new site resource from the removed one - the ID should stay the same + const [insertedSiteResource] = await trx + .insert(siteResources) + .values({ + ...existingSiteResource + }) + .returning(); + + // wait some time to allow for messages to be handled + await new Promise((resolve) => setTimeout(resolve, 750)); + + const sshPamSet = + isLicensedSshPam && + (authDaemonPort !== undefined || + authDaemonMode !== undefined) + ? { + ...(authDaemonPort !== undefined && { + authDaemonPort + }), + ...(authDaemonMode !== undefined && { + authDaemonMode + }) + } + : {}; + [updatedSiteResource] = await trx + .update(siteResources) + .set({ + name, + siteId, + niceId, + mode, + destination, + enabled, + alias: alias && alias.trim() ? alias : null, + tcpPortRangeString, + udpPortRangeString, + disableIcmp, + ...sshPamSet + }) + .where( + and( + eq( + siteResources.siteResourceId, + insertedSiteResource.siteResourceId + ) + ) ) + .returning(); + + if (!updatedSiteResource) { + throw new Error( + "Failed to create updated site resource after site change" + ); + } + + //////////////////// update the associations //////////////////// + + const [adminRole] = await trx + .select() + .from(roles) + .where( + and( + eq(roles.isAdmin, true), + eq(roles.orgId, updatedSiteResource.orgId) + ) + ) + .limit(1); + + if (!adminRole) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `Admin role not found` + ) + ); + } + + await trx.insert(roleSiteResources).values({ + roleId: adminRole.roleId, + siteResourceId: updatedSiteResource.siteResourceId + }); + + if (roleIds.length > 0) { + await trx.insert(roleSiteResources).values( + roleIds.map((roleId) => ({ + roleId, + siteResourceId: updatedSiteResource!.siteResourceId + })) + ); + } + + if (userIds.length > 0) { + await trx.insert(userSiteResources).values( + userIds.map((userId) => ({ + userId, + siteResourceId: updatedSiteResource!.siteResourceId + })) + ); + } + + if (clientIds.length > 0) { + await trx.insert(clientSiteResources).values( + clientIds.map((clientId) => ({ + clientId, + siteResourceId: updatedSiteResource!.siteResourceId + })) + ); + } + + await rebuildClientAssociationsFromSiteResource( + updatedSiteResource, + trx ); } else { - await trx - .delete(roleSiteResources) + // Update the site resource + const sshPamSet = + isLicensedSshPam && + (authDaemonPort !== undefined || + authDaemonMode !== undefined) + ? { + ...(authDaemonPort !== undefined && { + authDaemonPort + }), + ...(authDaemonMode !== undefined && { + authDaemonMode + }) + } + : {}; + [updatedSiteResource] = await trx + .update(siteResources) + .set({ + name: name, + siteId: siteId, + mode: mode, + destination: destination, + enabled: enabled, + alias: alias && alias.trim() ? alias : null, + tcpPortRangeString: tcpPortRangeString, + udpPortRangeString: udpPortRangeString, + disableIcmp: disableIcmp, + ...sshPamSet + }) .where( - eq(roleSiteResources.siteResourceId, siteResourceId) - ); - } + and(eq(siteResources.siteResourceId, siteResourceId)) + ) + .returning(); + + //////////////////// update the associations //////////////////// - if (roleIds.length > 0) { await trx - .insert(roleSiteResources) - .values( - roleIds.map((roleId) => ({ roleId, siteResourceId })) + .delete(clientSiteResources) + .where( + eq(clientSiteResources.siteResourceId, siteResourceId) ); + + if (clientIds.length > 0) { + await trx.insert(clientSiteResources).values( + clientIds.map((clientId) => ({ + clientId, + siteResourceId + })) + ); + } + + await trx + .delete(userSiteResources) + .where( + eq(userSiteResources.siteResourceId, siteResourceId) + ); + + if (userIds.length > 0) { + await trx.insert(userSiteResources).values( + userIds.map((userId) => ({ + userId, + siteResourceId + })) + ); + } + + // Get all admin role IDs for this org to exclude from deletion + const adminRoles = await trx + .select() + .from(roles) + .where( + and( + eq(roles.isAdmin, true), + eq(roles.orgId, updatedSiteResource.orgId) + ) + ); + const adminRoleIds = adminRoles.map((role) => role.roleId); + + if (adminRoleIds.length > 0) { + await trx.delete(roleSiteResources).where( + and( + eq( + roleSiteResources.siteResourceId, + siteResourceId + ), + ne(roleSiteResources.roleId, adminRoleIds[0]) // delete all but the admin role + ) + ); + } else { + await trx + .delete(roleSiteResources) + .where( + eq(roleSiteResources.siteResourceId, siteResourceId) + ); + } + + if (roleIds.length > 0) { + await trx.insert(roleSiteResources).values( + roleIds.map((roleId) => ({ + roleId, + siteResourceId + })) + ); + } + + logger.info( + `Updated site resource ${siteResourceId} for site ${siteId}` + ); + + await handleMessagingForUpdatedSiteResource( + existingSiteResource, + updatedSiteResource, + { siteId: site.siteId, orgId: site.orgId }, + trx + ); } - - logger.info( - `Updated site resource ${siteResourceId} for site ${siteId}` - ); - - await handleMessagingForUpdatedSiteResource( - existingSiteResource, - updatedSiteResource!, - { siteId: site.siteId, orgId: site.orgId }, - trx - ); }); return response(res, { @@ -335,6 +570,15 @@ export async function handleMessagingForUpdatedSiteResource( site: { siteId: number; orgId: string }, trx: Transaction ) { + logger.debug( + "handleMessagingForUpdatedSiteResource: existingSiteResource is: ", + existingSiteResource + ); + logger.debug( + "handleMessagingForUpdatedSiteResource: updatedSiteResource is: ", + updatedSiteResource + ); + const { mergedAllClients } = await rebuildClientAssociationsFromSiteResource( existingSiteResource || updatedSiteResource, // we want to rebuild based on the existing resource then we will apply the change to the destination below @@ -348,10 +592,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) @@ -365,20 +617,24 @@ export async function handleMessagingForUpdatedSiteResource( } // Only update targets on newt if destination changed - if (destinationChanged) { - const oldTargets = generateSubnetProxyTargets( + if (destinationChanged || portRangesChanged) { + const oldTarget = generateSubnetProxyTargetV2( existingSiteResource, mergedAllClients ); - const newTargets = generateSubnetProxyTargets( + const newTarget = generateSubnetProxyTargetV2( updatedSiteResource, mergedAllClients ); - await updateTargets(newt.newtId, { - oldTargets: oldTargets, - newTargets: newTargets - }); + await updateTargets( + newt.newtId, + { + oldTargets: oldTarget ? [oldTarget] : [], + newTargets: newTarget ? [newTarget] : [] + }, + newt.version + ); } const olmJobs: Promise[] = []; diff --git a/server/routers/target/createTarget.ts b/server/routers/target/createTarget.ts index 5d37f6173..ba52d85a1 100644 --- a/server/routers/target/createTarget.ts +++ b/server/routers/target/createTarget.ts @@ -58,7 +58,7 @@ registry.registerPath({ method: "put", path: "/resource/{resourceId}/target", description: "Create a target for a resource.", - tags: [OpenAPITags.Resource, OpenAPITags.Target], + tags: [OpenAPITags.PublicResource, OpenAPITags.Target], request: { params: createTargetParamsSchema, body: { @@ -264,7 +264,7 @@ export async function createTarget( newTarget, healthCheck, resource.protocol, - resource.proxyPort + newt.version ); } } diff --git a/server/routers/target/handleHealthcheckStatusMessage.ts b/server/routers/target/handleHealthcheckStatusMessage.ts index 2bfcff190..01cbdea81 100644 --- a/server/routers/target/handleHealthcheckStatusMessage.ts +++ b/server/routers/target/handleHealthcheckStatusMessage.ts @@ -105,7 +105,10 @@ export const handleHealthcheckStatusMessage: MessageHandler = async ( await db .update(targetHealthCheck) .set({ - hcHealth: healthStatus.status + hcHealth: healthStatus.status as + | "unknown" + | "healthy" + | "unhealthy" }) .where(eq(targetHealthCheck.targetId, targetIdNum)) .execute(); diff --git a/server/routers/target/listTargets.ts b/server/routers/target/listTargets.ts index 11a23f025..18e932afa 100644 --- a/server/routers/target/listTargets.ts +++ b/server/routers/target/listTargets.ts @@ -40,6 +40,7 @@ function queryTargets(resourceId: number) { resourceId: targets.resourceId, siteId: targets.siteId, siteType: sites.type, + siteName: sites.name, hcEnabled: targetHealthCheck.hcEnabled, hcPath: targetHealthCheck.hcPath, hcScheme: targetHealthCheck.hcScheme, @@ -88,7 +89,7 @@ registry.registerPath({ method: "get", path: "/resource/{resourceId}/targets", description: "List targets for a resource.", - tags: [OpenAPITags.Resource, OpenAPITags.Target], + tags: [OpenAPITags.PublicResource, OpenAPITags.Target], request: { params: listTargetsParamsSchema, query: listTargetsSchema diff --git a/server/routers/target/updateTarget.ts b/server/routers/target/updateTarget.ts index b00340eef..1f9eff716 100644 --- a/server/routers/target/updateTarget.ts +++ b/server/routers/target/updateTarget.ts @@ -188,6 +188,8 @@ export async function updateTarget( ); } + const pathMatchTypeRemoved = parsedBody.data.pathMatchType === null; + const [updatedTarget] = await db .update(targets) .set({ @@ -200,8 +202,8 @@ export async function updateTarget( path: parsedBody.data.path, pathMatchType: parsedBody.data.pathMatchType, priority: parsedBody.data.priority, - rewritePath: parsedBody.data.rewritePath, - rewritePathType: parsedBody.data.rewritePathType + rewritePath: pathMatchTypeRemoved ? null : parsedBody.data.rewritePath, + rewritePathType: pathMatchTypeRemoved ? null : parsedBody.data.rewritePathType }) .where(eq(targets.targetId, targetId)) .returning(); @@ -213,9 +215,11 @@ export async function updateTarget( // When health check is disabled, reset hcHealth to "unknown" // to prevent previously unhealthy targets from being excluded + // Also when the site is not a newt, set hcHealth to "unknown" const hcHealthValue = parsedBody.data.hcEnabled === false || - parsedBody.data.hcEnabled === null + parsedBody.data.hcEnabled === null || + site.type !== "newt" ? "unknown" : undefined; @@ -260,7 +264,7 @@ export async function updateTarget( [updatedTarget], [updatedHc], resource.protocol, - resource.proxyPort + newt.version ); } } diff --git a/server/routers/traefik/traefikConfigProvider.ts b/server/routers/traefik/traefikConfigProvider.ts index e8ac1621e..02f890604 100644 --- a/server/routers/traefik/traefikConfigProvider.ts +++ b/server/routers/traefik/traefikConfigProvider.ts @@ -30,20 +30,30 @@ export async function traefikConfigProvider( traefikConfig.http.middlewares[badgerMiddlewareName] = { plugin: { [badgerMiddlewareName]: { - apiBaseUrl: new URL( - "/api/v1", - `http://${ - config.getRawConfig().server.internal_hostname - }:${config.getRawConfig().server.internal_port}` - ).href, + apiBaseUrl: + config.getRawConfig().server.badger_override || + new URL( + "/api/v1", + `http://${ + config.getRawConfig().server + .internal_hostname + }:${config.getRawConfig().server.internal_port}` + ).href, userSessionCookieName: config.getRawConfig().server.session_cookie_name, - // deprecated accessTokenQueryParam: config.getRawConfig().server .resource_access_token_param, + accessTokenIdHeader: + config.getRawConfig().server + .resource_access_token_headers.id, + + accessTokenHeader: + config.getRawConfig().server + .resource_access_token_headers.token, + resourceSessionRequestParam: config.getRawConfig().server .resource_session_request_param @@ -54,7 +64,7 @@ export async function traefikConfigProvider( return res.status(HttpCode.OK).json(traefikConfig); } catch (e) { - logger.error(`Failed to build Traefik config: ${e}`); + logger.error(e); return res.status(HttpCode.INTERNAL_SERVER_ERROR).json({ error: "Failed to build Traefik config" }); diff --git a/server/routers/user/acceptInvite.ts b/server/routers/user/acceptInvite.ts index d64ccfb5b..88010e580 100644 --- a/server/routers/user/acceptInvite.ts +++ b/server/routers/user/acceptInvite.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, UserOrg } from "@server/db"; -import { roles, userInvites, userOrgs, users } from "@server/db"; -import { eq } from "drizzle-orm"; +import { db, orgs } from "@server/db"; +import { roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db"; +import { eq, and, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -13,6 +13,8 @@ import { verifySession } from "@server/auth/sessions/verifySession"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; +import { build } from "@server/build"; +import { assignUserToOrg } from "@server/lib/userOrg"; const acceptInviteBodySchema = z.strictObject({ token: z.string(), @@ -92,18 +94,81 @@ export async function acceptInvite( ); } - let roleId: number; - let totalUsers: UserOrg[] | undefined; - // get the role to make sure it exists - const existingRole = await db + if (build == "saas") { + const usage = await usageService.getUsage( + existingInvite.orgId, + FeatureId.USERS + ); + if (!usage) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "No usage data found for this organization" + ) + ); + } + const rejectUsers = await usageService.checkLimitSet( + existingInvite.orgId, + + FeatureId.USERS, + { + ...usage, + instantaneousValue: (usage.instantaneousValue || 0) + 1 + } // We need to add one to know if we are violating the limit + ); + if (rejectUsers) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Can not accept because this org's user limit is exceeded. Please contact your administrator to upgrade their plan." + ) + ); + } + } + + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, existingInvite.orgId)) + .limit(1); + + if (!org) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Organization does not exist. Please contact an admin." + ) + ); + } + + const inviteRoleRows = await db + .select({ roleId: userInviteRoles.roleId }) + .from(userInviteRoles) + .where(eq(userInviteRoles.inviteId, inviteId)); + + const inviteRoleIds = [ + ...new Set(inviteRoleRows.map((r) => r.roleId)) + ]; + if (inviteRoleIds.length === 0) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "This invitation has no roles. Please contact an admin." + ) + ); + } + + const existingRoles = await db .select() .from(roles) - .where(eq(roles.roleId, existingInvite.roleId)) - .limit(1); - if (existingRole.length) { - roleId = existingRole[0].roleId; - } else { - // TODO: use the default role on the org instead of failing + .where( + and( + eq(roles.orgId, existingInvite.orgId), + inArray(roles.roleId, inviteRoleIds) + ) + ); + + if (existingRoles.length !== inviteRoleIds.length) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -113,34 +178,27 @@ export async function acceptInvite( } await db.transaction(async (trx) => { - // add the user to the org - await trx.insert(userOrgs).values({ - userId: existingUser[0].userId, - orgId: existingInvite.orgId, - roleId: existingInvite.roleId - }); + await assignUserToOrg( + org, + { + userId: existingUser[0].userId, + orgId: existingInvite.orgId + }, + inviteRoleIds, + trx + ); // delete the invite await trx .delete(userInvites) .where(eq(userInvites.inviteId, inviteId)); - // Get the total number of users in the org now - totalUsers = await db - .select() - .from(userOrgs) - .where(eq(userOrgs.orgId, existingInvite.orgId)); - await calculateUserClientsForOrgs(existingUser[0].userId, trx); - }); - if (totalUsers) { - await usageService.updateDaily( - existingInvite.orgId, - FeatureId.USERS, - totalUsers.length + logger.debug( + `User ${existingUser[0].userId} accepted invite to org ${existingInvite.orgId}` ); - } + }); return response(res, { data: { accepted: true, orgId: existingInvite.orgId }, diff --git a/server/routers/user/addUserRoleLegacy.ts b/server/routers/user/addUserRoleLegacy.ts new file mode 100644 index 000000000..db0c6182f --- /dev/null +++ b/server/routers/user/addUserRoleLegacy.ts @@ -0,0 +1,159 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import stoi from "@server/lib/stoi"; +import { clients, db } from "@server/db"; +import { userOrgRoles, userOrgs, roles } from "@server/db"; +import { eq, and } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; + +/** Legacy path param order: /role/:roleId/add/:userId */ +const addUserRoleLegacyParamsSchema = z.strictObject({ + roleId: z.string().transform(stoi).pipe(z.number()), + userId: z.string() +}); + +registry.registerPath({ + method: "post", + path: "/role/{roleId}/add/{userId}", + description: + "Legacy: set exactly one role for the user (replaces any other roles the user has in the org).", + tags: [OpenAPITags.Role, OpenAPITags.User], + request: { + params: addUserRoleLegacyParamsSchema + }, + responses: {} +}); + +export async function addUserRoleLegacy( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = addUserRoleLegacyParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { userId, roleId } = parsedParams.data; + + if (req.user && !req.userOrg) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "You do not have access to this organization" + ) + ); + } + + const [role] = await db + .select() + .from(roles) + .where(eq(roles.roleId, roleId)) + .limit(1); + + if (!role) { + return next( + createHttpError(HttpCode.BAD_REQUEST, "Invalid role ID") + ); + } + + const [existingUser] = await db + .select() + .from(userOrgs) + .where( + and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, role.orgId)) + ) + .limit(1); + + if (!existingUser) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "User not found or does not belong to the specified organization" + ) + ); + } + + if (existingUser.isOwner) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Cannot change the role of the owner of the organization" + ) + ); + } + + const [roleInOrg] = await db + .select() + .from(roles) + .where(and(eq(roles.roleId, roleId), eq(roles.orgId, role.orgId))) + .limit(1); + + if (!roleInOrg) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Role not found or does not belong to the specified organization" + ) + ); + } + + await db.transaction(async (trx) => { + await trx + .delete(userOrgRoles) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, role.orgId) + ) + ); + + await trx.insert(userOrgRoles).values({ + userId, + orgId: role.orgId, + roleId + }); + + const orgClients = await trx + .select() + .from(clients) + .where( + and( + eq(clients.userId, userId), + eq(clients.orgId, role.orgId) + ) + ); + + for (const orgClient of orgClients) { + await rebuildClientAssociationsFromClient(orgClient, trx); + } + }); + + return response(res, { + data: { ...existingUser, roleId }, + success: true, + error: false, + message: "Role added to user successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/createOrgUser.ts b/server/routers/user/createOrgUser.ts index e19024770..ddc37d3a2 100644 --- a/server/routers/user/createOrgUser.ts +++ b/server/routers/user/createOrgUser.ts @@ -6,38 +6,52 @@ import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { db, UserOrg } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { db, orgs } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; import { idp, idpOidcConfig, roles, userOrgs, users } from "@server/db"; import { generateId } from "@server/auth/sessions/app"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; import { build } from "@server/build"; -import { getOrgTierData } from "#dynamic/lib/billing"; -import { TierId } from "@server/lib/billing/tiers"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; +import { isSubscribed } from "#dynamic/lib/isSubscribed"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; +import { assignUserToOrg } from "@server/lib/userOrg"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; const paramsSchema = z.strictObject({ orgId: z.string().nonempty() }); -const bodySchema = z.strictObject({ - email: z - .email() - .toLowerCase() - .optional() - .refine((data) => { - if (data) { - return z.email().safeParse(data).success; - } - return true; - }), - username: z.string().nonempty().toLowerCase(), - name: z.string().optional(), - type: z.enum(["internal", "oidc"]).optional(), - idpId: z.number().optional(), - roleId: z.number() -}); +const bodySchema = z + .strictObject({ + email: z.string().email().toLowerCase().optional(), + username: z.string().nonempty().toLowerCase(), + name: z.string().optional(), + type: z.enum(["internal", "oidc"]).optional(), + idpId: z.number().optional(), + roleIds: z.array(z.number().int().positive()).min(1).optional(), + roleId: z.number().int().positive().optional() + }) + .refine( + (d) => + (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null, + { message: "roleIds or roleId is required", path: ["roleIds"] } + ) + .transform((data) => ({ + email: data.email, + username: data.username, + name: data.name, + type: data.type, + idpId: data.idpId, + roleIds: [ + ...new Set( + data.roleIds && data.roleIds.length > 0 + ? data.roleIds + : [data.roleId!] + ) + ] + })); export type CreateOrgUserResponse = {}; @@ -45,7 +59,7 @@ registry.registerPath({ method: "put", path: "/org/{orgId}/user", description: "Create an organization user.", - tags: [OpenAPITags.User, OpenAPITags.Org], + tags: [OpenAPITags.User], request: { params: paramsSchema, body: { @@ -86,7 +100,8 @@ export async function createOrgUser( } const { orgId } = parsedParams.data; - const { username, email, name, type, idpId, roleId } = parsedBody.data; + const { username, email, name, type, idpId, roleIds: uniqueRoleIds } = + parsedBody.data; if (build == "saas") { const usage = await usageService.getUsage(orgId, FeatureId.USERS); @@ -100,7 +115,7 @@ export async function createOrgUser( } const rejectUsers = await usageService.checkLimitSet( orgId, - false, + FeatureId.USERS, { ...usage, @@ -117,17 +132,6 @@ export async function createOrgUser( } } - const [role] = await db - .select() - .from(roles) - .where(eq(roles.roleId, roleId)); - - if (!role) { - return next( - createHttpError(HttpCode.BAD_REQUEST, "Role ID not found") - ); - } - if (type === "internal") { return next( createHttpError( @@ -137,8 +141,10 @@ export async function createOrgUser( ); } else if (type === "oidc") { if (build === "saas") { - const { tier } = await getOrgTierData(orgId); - const subscribed = tier === TierId.STANDARD; + const subscribed = await isSubscribed( + orgId, + tierMatrix.orgOidc + ); if (!subscribed) { return next( createHttpError( @@ -158,6 +164,53 @@ export async function createOrgUser( ); } + const supportsMultiRole = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.FullRbac] + ); + if (!supportsMultiRole && uniqueRoleIds.length > 1) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Multiple roles per user require a subscription or license that includes full RBAC." + ) + ); + } + + const orgRoles = await db + .select({ roleId: roles.roleId }) + .from(roles) + .where( + and( + eq(roles.orgId, orgId), + inArray(roles.roleId, uniqueRoleIds) + ) + ); + + if (orgRoles.length !== uniqueRoleIds.length) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Invalid role ID or role does not belong to this organization" + ) + ); + } + + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Organization not found" + ) + ); + } + const [idpRes] = await db .select() .from(idp) @@ -179,8 +232,6 @@ export async function createOrgUser( ); } - let orgUsers: UserOrg[] | undefined; - await db.transaction(async (trx) => { const [existingUser] = await trx .select() @@ -214,15 +265,16 @@ export async function createOrgUser( ); } - await trx - .insert(userOrgs) - .values({ + await assignUserToOrg( + org, + { orgId, userId: existingUser.userId, - roleId: role.roleId, - autoProvisioned: false - }) - .returning(); + autoProvisioned: false, + }, + uniqueRoleIds, + trx + ); } else { userId = generateId(15); @@ -240,33 +292,20 @@ export async function createOrgUser( }) .returning(); - await trx - .insert(userOrgs) - .values({ - orgId, - userId: newUser.userId, - roleId: role.roleId, - autoProvisioned: false - }) - .returning(); + await assignUserToOrg( + org, + { + orgId, + userId: newUser.userId, + autoProvisioned: false, + }, + uniqueRoleIds, + trx + ); } - // List all of the users in the org - orgUsers = await trx - .select() - .from(userOrgs) - .where(eq(userOrgs.orgId, orgId)); - await calculateUserClientsForOrgs(userId, trx); }); - - if (orgUsers) { - await usageService.updateDaily( - orgId, - FeatureId.USERS, - orgUsers.length - ); - } } else { return next( createHttpError(HttpCode.BAD_REQUEST, "User type is required") diff --git a/server/routers/user/getOrgUser.ts b/server/routers/user/getOrgUser.ts index f22a29d37..c415e186c 100644 --- a/server/routers/user/getOrgUser.ts +++ b/server/routers/user/getOrgUser.ts @@ -1,7 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idp, idpOidcConfig } from "@server/db"; -import { roles, userOrgs, users } from "@server/db"; +import { roles, userOrgRoles, userOrgs, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; @@ -11,8 +11,8 @@ import { fromError } from "zod-validation-error"; import { ActionsEnum, checkUserActionPermission } from "@server/auth/actions"; import { OpenAPITags, registry } from "@server/openApi"; -async function queryUser(orgId: string, userId: string) { - const [user] = await db +export async function queryUser(orgId: string, userId: string) { + const [userRow] = await db .select({ orgId: userOrgs.orgId, userId: users.userId, @@ -20,10 +20,7 @@ async function queryUser(orgId: string, userId: string) { username: users.username, name: users.name, type: users.type, - roleId: userOrgs.roleId, - roleName: roles.name, isOwner: userOrgs.isOwner, - isAdmin: roles.isAdmin, twoFactorEnabled: users.twoFactorEnabled, autoProvisioned: userOrgs.autoProvisioned, idpId: users.idpId, @@ -33,13 +30,40 @@ async function queryUser(orgId: string, userId: string) { idpAutoProvision: idp.autoProvision }) .from(userOrgs) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(users, eq(userOrgs.userId, users.userId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idp.idpId, idpOidcConfig.idpId)) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) .limit(1); - return user; + + if (!userRow) return undefined; + + const roleRows = await db + .select({ + roleId: userOrgRoles.roleId, + roleName: roles.name, + isAdmin: roles.isAdmin + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.userId, userId), + eq(userOrgRoles.orgId, orgId) + ) + ); + + const isAdmin = roleRows.some((r) => r.isAdmin); + + return { + ...userRow, + isAdmin, + roleIds: roleRows.map((r) => r.roleId), + roles: roleRows.map((r) => ({ + roleId: r.roleId, + name: r.roleName ?? "" + })) + }; } export type GetOrgUserResponse = NonNullable< @@ -55,7 +79,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/user/{userId}", description: "Get a user in an organization.", - tags: [OpenAPITags.Org, OpenAPITags.User], + tags: [OpenAPITags.User], request: { params: getOrgUserParamsSchema }, diff --git a/server/routers/user/getOrgUserByUsername.ts b/server/routers/user/getOrgUserByUsername.ts new file mode 100644 index 000000000..a6a764a54 --- /dev/null +++ b/server/routers/user/getOrgUserByUsername.ts @@ -0,0 +1,136 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db } from "@server/db"; +import { userOrgs, users } from "@server/db"; +import { and, eq } from "drizzle-orm"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; +import { queryUser, type GetOrgUserResponse } from "./getOrgUser"; + +const getOrgUserByUsernameParamsSchema = z.strictObject({ + orgId: z.string() +}); + +const getOrgUserByUsernameQuerySchema = z.strictObject({ + username: z.string().min(1, "username is required"), + idpId: z + .string() + .optional() + .transform((v) => + v === undefined || v === "" ? undefined : parseInt(v, 10) + ) + .refine( + (v) => + v === undefined || (Number.isInteger(v) && (v as number) > 0), + { message: "idpId must be a positive integer" } + ) +}); + +registry.registerPath({ + method: "get", + path: "/org/{orgId}/user-by-username", + description: + "Get a user in an organization by username. When idpId is not passed, only internal users are searched (username is globally unique for them). For external (OIDC) users, pass idpId to search by username within that identity provider.", + tags: [OpenAPITags.User], + request: { + params: getOrgUserByUsernameParamsSchema, + query: getOrgUserByUsernameQuerySchema + }, + responses: {} +}); + +export async function getOrgUserByUsername( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = getOrgUserByUsernameParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const parsedQuery = getOrgUserByUsernameQuerySchema.safeParse( + req.query + ); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + + const { orgId } = parsedParams.data; + const { username, idpId } = parsedQuery.data; + + const conditions = [ + eq(userOrgs.orgId, orgId), + eq(users.username, username) + ]; + if (idpId !== undefined) { + conditions.push(eq(users.idpId, idpId)); + } else { + conditions.push(eq(users.type, "internal")); + } + + const candidates = await db + .select({ userId: users.userId }) + .from(userOrgs) + .innerJoin(users, eq(userOrgs.userId, users.userId)) + .where(and(...conditions)); + + if (candidates.length === 0) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with username '${username}' not found in organization` + ) + ); + } + + if (candidates.length > 1) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + "Multiple users with this username (external users from different identity providers). Specify idpId (identity provider ID) to disambiguate. When not specified, this searches for internal users only." + ) + ); + } + + const user = await queryUser(orgId, candidates[0].userId); + if (!user) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + `User with username '${username}' not found in organization` + ) + ); + } + + return response(res, { + data: user, + success: true, + error: false, + message: "User retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/user/index.ts b/server/routers/user/index.ts index 35c5c4a7c..e03676caa 100644 --- a/server/routers/user/index.ts +++ b/server/routers/user/index.ts @@ -1,10 +1,12 @@ export * from "./getUser"; export * from "./removeUserOrg"; export * from "./listUsers"; -export * from "./addUserRole"; +export * from "./types"; +export * from "./addUserRoleLegacy"; export * from "./inviteUser"; export * from "./acceptInvite"; export * from "./getOrgUser"; +export * from "./getOrgUserByUsername"; export * from "./adminListUsers"; export * from "./adminRemoveUser"; export * from "./adminGetUser"; diff --git a/server/routers/user/inviteUser.ts b/server/routers/user/inviteUser.ts index 6a778868a..7ac1849b9 100644 --- a/server/routers/user/inviteUser.ts +++ b/server/routers/user/inviteUser.ts @@ -1,8 +1,8 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { orgs, roles, userInvites, userOrgs, users } from "@server/db"; -import { and, eq } from "drizzle-orm"; +import { orgs, roles, userInviteRoles, userInvites, userOrgs, users } from "@server/db"; +import { and, eq, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -18,22 +18,44 @@ import { OpenAPITags, registry } from "@server/openApi"; import { UserType } from "@server/types/UserTypes"; import { usageService } from "@server/lib/billing/usageService"; import { FeatureId } from "@server/lib/billing"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; import { build } from "@server/build"; -import cache from "@server/lib/cache"; +import cache from "#dynamic/lib/cache"; +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; const inviteUserParamsSchema = z.strictObject({ orgId: z.string() }); -const inviteUserBodySchema = z.strictObject({ - email: z.email().toLowerCase(), - roleId: z.number(), - validHours: z.number().gt(0).lte(168), - sendEmail: z.boolean().optional(), - regenerate: z.boolean().optional() -}); +const inviteUserBodySchema = z + .strictObject({ + email: z.email().toLowerCase(), + roleIds: z.array(z.number().int().positive()).min(1).optional(), + roleId: z.number().int().positive().optional(), + validHours: z.number().gt(0).lte(168), + sendEmail: z.boolean().optional(), + regenerate: z.boolean().optional() + }) + .refine( + (d) => + (d.roleIds != null && d.roleIds.length > 0) || d.roleId != null, + { message: "roleIds or roleId is required", path: ["roleIds"] } + ) + .transform((data) => ({ + email: data.email, + validHours: data.validHours, + sendEmail: data.sendEmail, + regenerate: data.regenerate, + roleIds: [ + ...new Set( + data.roleIds && data.roleIds.length > 0 + ? data.roleIds + : [data.roleId!] + ) + ] + })); -export type InviteUserBody = z.infer; +export type InviteUserBody = z.input; export type InviteUserResponse = { inviteLink: string; @@ -44,7 +66,7 @@ registry.registerPath({ method: "post", path: "/org/{orgId}/create-invite", description: "Invite a user to join an organization.", - tags: [OpenAPITags.Org], + tags: [OpenAPITags.Invitation], request: { params: inviteUserParamsSchema, body: { @@ -88,7 +110,7 @@ export async function inviteUser( const { email, validHours, - roleId, + roleIds: uniqueRoleIds, sendEmail: doEmail, regenerate } = parsedBody.data; @@ -105,14 +127,30 @@ export async function inviteUser( ); } - // Validate that the roleId belongs to the target organization - const [role] = await db - .select() - .from(roles) - .where(and(eq(roles.roleId, roleId), eq(roles.orgId, orgId))) - .limit(1); + const supportsMultiRole = await isLicensedOrSubscribed( + orgId, + tierMatrix[TierFeature.FullRbac] + ); + if (!supportsMultiRole && uniqueRoleIds.length > 1) { + return next( + createHttpError( + HttpCode.FORBIDDEN, + "Multiple roles per user require a subscription or license that includes full RBAC." + ) + ); + } - if (!role) { + const orgRoles = await db + .select({ roleId: roles.roleId }) + .from(roles) + .where( + and( + eq(roles.orgId, orgId), + inArray(roles.roleId, uniqueRoleIds) + ) + ); + + if (orgRoles.length !== uniqueRoleIds.length) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -133,7 +171,6 @@ export async function inviteUser( } const rejectUsers = await usageService.checkLimitSet( orgId, - false, FeatureId.USERS, { ...usage, @@ -192,7 +229,8 @@ export async function inviteUser( } if (existingInvite.length) { - const attempts = cache.get(email) || 0; + const attempts = + (await cache.get("regenerateInvite:" + email)) || 0; if (attempts >= 3) { return next( createHttpError( @@ -202,7 +240,7 @@ export async function inviteUser( ); } - cache.set(email, attempts + 1); + await cache.set("regenerateInvite:" + email, attempts + 1, 3600); const inviteId = existingInvite[0].inviteId; // Retrieve the original inviteId const token = generateRandomString( @@ -274,9 +312,11 @@ export async function inviteUser( orgId, email, expiresAt, - tokenHash, - roleId + tokenHash }); + await trx.insert(userInviteRoles).values( + uniqueRoleIds.map((roleId) => ({ inviteId, roleId })) + ); }); const inviteLink = `${config.getRawConfig().app.dashboard_url}/invite?token=${inviteId}-${token}&email=${encodeURIComponent(email)}`; diff --git a/server/routers/user/listInvitations.ts b/server/routers/user/listInvitations.ts index 4289b877f..1f4bcc02c 100644 --- a/server/routers/user/listInvitations.ts +++ b/server/routers/user/listInvitations.ts @@ -1,11 +1,11 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db } from "@server/db"; -import { userInvites, roles } from "@server/db"; +import { userInvites, userInviteRoles, roles } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { sql } from "drizzle-orm"; +import { sql, eq, and, inArray } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; @@ -29,24 +29,66 @@ const listInvitationsQuerySchema = z.strictObject({ .pipe(z.int().nonnegative()) }); -async function queryInvitations(orgId: string, limit: number, offset: number) { - return await db +export type InvitationListRow = { + inviteId: string; + email: string; + expiresAt: number; + roles: { roleId: number; roleName: string | null }[]; +}; + +async function queryInvitations( + orgId: string, + limit: number, + offset: number +): Promise { + const inviteRows = await db .select({ inviteId: userInvites.inviteId, email: userInvites.email, - expiresAt: userInvites.expiresAt, - roleId: userInvites.roleId, - roleName: roles.name + expiresAt: userInvites.expiresAt }) .from(userInvites) - .leftJoin(roles, sql`${userInvites.roleId} = ${roles.roleId}`) - .where(sql`${userInvites.orgId} = ${orgId}`) + .where(eq(userInvites.orgId, orgId)) .limit(limit) .offset(offset); + + if (inviteRows.length === 0) { + return []; + } + + const inviteIds = inviteRows.map((r) => r.inviteId); + const roleRows = await db + .select({ + inviteId: userInviteRoles.inviteId, + roleId: userInviteRoles.roleId, + roleName: roles.name + }) + .from(userInviteRoles) + .innerJoin(roles, eq(userInviteRoles.roleId, roles.roleId)) + .where( + and(eq(roles.orgId, orgId), inArray(userInviteRoles.inviteId, inviteIds)) + ); + + const rolesByInvite = new Map< + string, + { roleId: number; roleName: string | null }[] + >(); + for (const row of roleRows) { + const list = rolesByInvite.get(row.inviteId) ?? []; + list.push({ roleId: row.roleId, roleName: row.roleName }); + rolesByInvite.set(row.inviteId, list); + } + + return inviteRows.map((inv) => ({ + inviteId: inv.inviteId, + email: inv.email, + expiresAt: inv.expiresAt, + roles: rolesByInvite.get(inv.inviteId) ?? [] + })); } export type ListInvitationsResponse = { - invitations: NonNullable>>; + invitations: InvitationListRow[]; pagination: { total: number; limit: number; offset: number }; }; @@ -54,7 +96,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/invitations", description: "List invitations in an organization.", - tags: [OpenAPITags.Org, OpenAPITags.Invitation], + tags: [OpenAPITags.Invitation], request: { params: listInvitationsParamsSchema, query: listInvitationsQuerySchema @@ -95,7 +137,7 @@ export async function listInvitations( const [{ count }] = await db .select({ count: sql`count(*)` }) .from(userInvites) - .where(sql`${userInvites.orgId} = ${orgId}`); + .where(eq(userInvites.orgId, orgId)); return response(res, { data: { diff --git a/server/routers/user/listUsers.ts b/server/routers/user/listUsers.ts index 401dcf58b..fe7f6b250 100644 --- a/server/routers/user/listUsers.ts +++ b/server/routers/user/listUsers.ts @@ -1,15 +1,14 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; import { db, idpOidcConfig } from "@server/db"; -import { idp, roles, userOrgs, users } from "@server/db"; +import { idp, roles, userOrgRoles, userOrgs, users } from "@server/db"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; -import { and, sql } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import logger from "@server/logger"; import { fromZodError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; -import { eq } from "drizzle-orm"; const listUsersParamsSchema = z.strictObject({ orgId: z.string() @@ -31,7 +30,7 @@ const listUsersSchema = z.strictObject({ }); async function queryUsers(orgId: string, limit: number, offset: number) { - return await db + const rows = await db .select({ id: users.userId, email: users.email, @@ -41,8 +40,6 @@ async function queryUsers(orgId: string, limit: number, offset: number) { username: users.username, name: users.name, type: users.type, - roleId: userOrgs.roleId, - roleName: roles.name, isOwner: userOrgs.isOwner, idpName: idp.name, idpId: users.idpId, @@ -52,12 +49,48 @@ async function queryUsers(orgId: string, limit: number, offset: number) { }) .from(users) .leftJoin(userOrgs, eq(users.userId, userOrgs.userId)) - .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .leftJoin(idp, eq(users.idpId, idp.idpId)) .leftJoin(idpOidcConfig, eq(idpOidcConfig.idpId, idp.idpId)) .where(eq(userOrgs.orgId, orgId)) .limit(limit) .offset(offset); + + const userIds = rows.map((r) => r.id); + const roleRows = + userIds.length === 0 + ? [] + : await db + .select({ + userId: userOrgRoles.userId, + roleId: userOrgRoles.roleId, + roleName: roles.name + }) + .from(userOrgRoles) + .leftJoin(roles, eq(userOrgRoles.roleId, roles.roleId)) + .where( + and( + eq(userOrgRoles.orgId, orgId), + inArray(userOrgRoles.userId, userIds) + ) + ); + + const rolesByUser = new Map< + string, + { roleId: number; roleName: string }[] + >(); + for (const r of roleRows) { + const list = rolesByUser.get(r.userId) ?? []; + list.push({ roleId: r.roleId, roleName: r.roleName ?? "" }); + rolesByUser.set(r.userId, list); + } + + return rows.map((row) => { + const userRoles = rolesByUser.get(row.id) ?? []; + return { + ...row, + roles: userRoles + }; + }); } export type ListUsersResponse = { @@ -69,7 +102,7 @@ registry.registerPath({ method: "get", path: "/org/{orgId}/users", description: "List users in an organization.", - tags: [OpenAPITags.Org, OpenAPITags.User], + tags: [OpenAPITags.User], request: { params: listUsersParamsSchema, query: listUsersSchema diff --git a/server/routers/user/myDevice.ts b/server/routers/user/myDevice.ts index 144108e11..3b991ca56 100644 --- a/server/routers/user/myDevice.ts +++ b/server/routers/user/myDevice.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import { db, Olm, olms, orgs, userOrgs } from "@server/db"; +import { db, Olm, olms, orgs, userOrgRoles, userOrgs } from "@server/db"; import { idp, users } from "@server/db"; import { and, eq } from "drizzle-orm"; import response from "@server/lib/response"; @@ -84,16 +84,31 @@ export async function myDevice( .from(olms) .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId))); - const userOrganizations = await db + const userOrgRows = await db .select({ orgId: userOrgs.orgId, - orgName: orgs.name, - roleId: userOrgs.roleId + orgName: orgs.name }) .from(userOrgs) .where(eq(userOrgs.userId, userId)) .innerJoin(orgs, eq(userOrgs.orgId, orgs.orgId)); + const roleRows = await db + .select({ + orgId: userOrgRoles.orgId, + roleId: userOrgRoles.roleId + }) + .from(userOrgRoles) + .where(eq(userOrgRoles.userId, userId)); + + const roleByOrg = new Map( + roleRows.map((r) => [r.orgId, r.roleId]) + ); + const userOrganizations = userOrgRows.map((row) => ({ + ...row, + roleId: roleByOrg.get(row.orgId) ?? 0 + })); + return response(res, { data: { user, diff --git a/server/routers/user/removeInvitation.ts b/server/routers/user/removeInvitation.ts index 6a000afcf..0f76fd30c 100644 --- a/server/routers/user/removeInvitation.ts +++ b/server/routers/user/removeInvitation.ts @@ -8,12 +8,24 @@ import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; import logger from "@server/logger"; import { fromError } from "zod-validation-error"; +import { OpenAPITags, registry } from "@server/openApi"; const removeInvitationParamsSchema = z.strictObject({ orgId: z.string(), inviteId: z.string() }); +registry.registerPath({ + method: "delete", + path: "/org/{orgId}/invitations/{inviteId}", + description: "Remove an open invitation from an organization", + tags: [OpenAPITags.Invitation], + request: { + params: removeInvitationParamsSchema + }, + responses: {} +}); + export async function removeInvitation( req: Request, res: Response, diff --git a/server/routers/user/removeUserOrg.ts b/server/routers/user/removeUserOrg.ts index 97045e924..3c86a03c5 100644 --- a/server/routers/user/removeUserOrg.ts +++ b/server/routers/user/removeUserOrg.ts @@ -1,8 +1,16 @@ import { Request, Response, NextFunction } from "express"; import { z } from "zod"; -import { db, resources, sites, UserOrg } from "@server/db"; +import { + db, + orgs, + resources, + siteResources, + sites, + UserOrg, + userSiteResources +} from "@server/db"; import { userOrgs, userResources, users, userSites } from "@server/db"; -import { and, count, eq, exists } from "drizzle-orm"; +import { and, count, eq, exists, inArray } from "drizzle-orm"; import response from "@server/lib/response"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -14,6 +22,7 @@ import { FeatureId } from "@server/lib/billing"; import { build } from "@server/build"; import { UserType } from "@server/types/UserTypes"; import { calculateUserClientsForOrgs } from "@server/lib/calculateUserClientsForOrgs"; +import { removeUserFromOrg } from "@server/lib/userOrg"; const removeUserSchema = z.strictObject({ userId: z.string(), @@ -24,7 +33,7 @@ registry.registerPath({ method: "delete", path: "/org/{orgId}/user/{userId}", description: "Remove a user from an organization.", - tags: [OpenAPITags.Org, OpenAPITags.User], + tags: [OpenAPITags.User], request: { params: removeUserSchema }, @@ -50,16 +59,16 @@ export async function removeUserOrg( const { userId, orgId } = parsedParams.data; // get the user first - const user = await db + const [user] = await db .select() .from(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))); - if (!user || user.length === 0) { + if (!user) { return next(createHttpError(HttpCode.NOT_FOUND, "User not found")); } - if (user[0].isOwner) { + if (user.isOwner) { return next( createHttpError( HttpCode.BAD_REQUEST, @@ -68,56 +77,20 @@ export async function removeUserOrg( ); } - let userCount: UserOrg[] | undefined; + const [org] = await db + .select() + .from(orgs) + .where(eq(orgs.orgId, orgId)) + .limit(1); + + if (!org) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Organization not found") + ); + } await db.transaction(async (trx) => { - await trx - .delete(userOrgs) - .where( - and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId)) - ); - - await db.delete(userResources).where( - and( - eq(userResources.userId, userId), - exists( - db - .select() - .from(resources) - .where( - and( - eq( - resources.resourceId, - userResources.resourceId - ), - eq(resources.orgId, orgId) - ) - ) - ) - ) - ); - - await db.delete(userSites).where( - and( - eq(userSites.userId, userId), - exists( - db - .select() - .from(sites) - .where( - and( - eq(sites.siteId, userSites.siteId), - eq(sites.orgId, orgId) - ) - ) - ) - ) - ); - - userCount = await trx - .select() - .from(userOrgs) - .where(eq(userOrgs.orgId, orgId)); + await removeUserFromOrg(org, userId, trx); // if (build === "saas") { // const [rootUser] = await trx @@ -139,14 +112,6 @@ export async function removeUserOrg( await calculateUserClientsForOrgs(userId, trx); }); - if (userCount) { - await usageService.updateDaily( - orgId, - FeatureId.USERS, - userCount.length - ); - } - return response(res, { data: null, success: true, diff --git a/server/routers/user/types.ts b/server/routers/user/types.ts new file mode 100644 index 000000000..bd5b54efa --- /dev/null +++ b/server/routers/user/types.ts @@ -0,0 +1,18 @@ +import type { UserOrg } from "@server/db"; + +export type AddUserRoleResponse = { + userId: string; + roleId: number; +}; + +/** Legacy POST /role/:roleId/add/:userId response shape (membership + effective role). */ +export type AddUserRoleLegacyResponse = UserOrg & { roleId: number }; + +export type SetUserOrgRolesParams = { + orgId: string; + userId: string; +}; + +export type SetUserOrgRolesBody = { + roleIds: number[]; +}; diff --git a/server/routers/user/updateOrgUser.ts b/server/routers/user/updateOrgUser.ts index 97bedb5f9..a95c3fb5e 100644 --- a/server/routers/user/updateOrgUser.ts +++ b/server/routers/user/updateOrgUser.ts @@ -26,7 +26,7 @@ registry.registerPath({ method: "post", path: "/org/{orgId}/user/{userId}", description: "Update a user in an org.", - tags: [OpenAPITags.Org, OpenAPITags.User], + tags: [OpenAPITags.Org], request: { params: paramsSchema, body: { diff --git a/server/routers/ws/checkRoundTripMessage.ts b/server/routers/ws/checkRoundTripMessage.ts new file mode 100644 index 000000000..9c832db5d --- /dev/null +++ b/server/routers/ws/checkRoundTripMessage.ts @@ -0,0 +1,85 @@ +import { Request, Response, NextFunction } from "express"; +import { z } from "zod"; +import { db, roundTripMessageTracker } from "@server/db"; +import response from "@server/lib/response"; +import HttpCode from "@server/types/HttpCode"; +import createHttpError from "http-errors"; +import logger from "@server/logger"; +import { fromError } from "zod-validation-error"; +import { eq } from "drizzle-orm"; +import { OpenAPITags, registry } from "@server/openApi"; + +const checkRoundTripMessageParamsSchema = z + .object({ + messageId: z + .string() + .transform(Number) + .pipe(z.number().int().positive()) + }) + .strict(); + +// registry.registerPath({ +// method: "get", +// path: "/ws/round-trip-message/{messageId}", +// description: +// "Check if a round trip message has been completed by checking the roundTripMessageTracker table", +// tags: [OpenAPITags.WebSocket], +// request: { +// params: checkRoundTripMessageParamsSchema +// }, +// responses: {} +// }); + +export async function checkRoundTripMessage( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = checkRoundTripMessageParamsSchema.safeParse( + req.params + ); + if (!parsedParams.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedParams.error).toString() + ) + ); + } + + const { messageId } = parsedParams.data; + + // Get the round trip message from the tracker + const [message] = await db + .select() + .from(roundTripMessageTracker) + .where(eq(roundTripMessageTracker.messageId, messageId)) + .limit(1); + + if (!message) { + return next( + createHttpError(HttpCode.NOT_FOUND, "Message not found") + ); + } + + return response(res, { + data: { + messageId: message.messageId, + complete: message.complete, + sentAt: message.sentAt, + receivedAt: message.receivedAt, + error: message.error, + }, + success: true, + error: false, + message: "Round trip message status retrieved successfully", + status: HttpCode.OK + }); + } catch (error) { + logger.error(error); + return next( + createHttpError(HttpCode.INTERNAL_SERVER_ERROR, "An error occurred") + ); + } +} diff --git a/server/routers/ws/handleRoundTripMessage.ts b/server/routers/ws/handleRoundTripMessage.ts new file mode 100644 index 000000000..ed5d0773f --- /dev/null +++ b/server/routers/ws/handleRoundTripMessage.ts @@ -0,0 +1,49 @@ +import { db, roundTripMessageTracker } from "@server/db"; +import { MessageHandler } from "@server/routers/ws"; +import { eq } from "drizzle-orm"; +import logger from "@server/logger"; + +interface RoundTripCompleteMessage { + messageId: number; + complete: boolean; + error?: string; +} + +export const handleRoundTripMessage: MessageHandler = async ( + context +) => { + const { message, client: c } = context; + + logger.info("Handling round trip message"); + + const data = message.data as RoundTripCompleteMessage; + + try { + const { messageId, complete, error } = data; + + if (!messageId) { + logger.error("Round trip message missing messageId"); + return; + } + + // Update the roundTripMessageTracker with completion status + await db + .update(roundTripMessageTracker) + .set({ + complete: complete, + receivedAt: Math.floor(Date.now() / 1000), + error: error || null + }) + .where(eq(roundTripMessageTracker.messageId, messageId)); + + logger.info(`Round trip message ${messageId} marked as complete: ${complete}`); + + if (error) { + logger.warn(`Round trip message ${messageId} completed with error: ${error}`); + } + } catch (error) { + logger.error("Error processing round trip message:", error); + } + + return; +}; diff --git a/server/routers/ws/index.ts b/server/routers/ws/index.ts index b580b369d..f5b4e2e4e 100644 --- a/server/routers/ws/index.ts +++ b/server/routers/ws/index.ts @@ -1,2 +1,3 @@ export * from "./ws"; export * from "./types"; +export * from "./checkRoundTripMessage"; diff --git a/server/routers/ws/messageHandlers.ts b/server/routers/ws/messageHandlers.ts index acd1aef00..143e4d516 100644 --- a/server/routers/ws/messageHandlers.ts +++ b/server/routers/ws/messageHandlers.ts @@ -1,3 +1,4 @@ +import { build } from "@server/build"; import { handleNewtRegisterMessage, handleReceiveBandwidthMessage, @@ -5,25 +6,36 @@ import { handleDockerStatusMessage, handleDockerContainersMessage, handleNewtPingRequestMessage, - handleApplyBlueprintMessage + handleApplyBlueprintMessage, + handleNewtPingMessage, + startNewtOfflineChecker, + handleNewtDisconnectingMessage } from "../newt"; +import { startPingAccumulator } from "../newt/pingAccumulator"; import { handleOlmRegisterMessage, handleOlmRelayMessage, handleOlmPingMessage, startOlmOfflineChecker, handleOlmServerPeerAddMessage, - handleOlmUnRelayMessage + handleOlmUnRelayMessage, + handleOlmDisconnectingMessage, + handleOlmServerInitAddPeerHandshake } from "../olm"; import { handleHealthcheckStatusMessage } from "../target"; +import { handleRoundTripMessage } from "./handleRoundTripMessage"; import { MessageHandler } from "./types"; export const messageHandlers: Record = { "olm/wg/server/peer/add": handleOlmServerPeerAddMessage, + "olm/wg/server/peer/init": handleOlmServerInitAddPeerHandshake, "olm/wg/register": handleOlmRegisterMessage, "olm/wg/relay": handleOlmRelayMessage, "olm/wg/unrelay": handleOlmUnRelayMessage, "olm/ping": handleOlmPingMessage, + "olm/disconnecting": handleOlmDisconnectingMessage, + "newt/disconnecting": handleNewtDisconnectingMessage, + "newt/ping": handleNewtPingMessage, "newt/wg/register": handleNewtRegisterMessage, "newt/wg/get-config": handleGetConfigMessage, "newt/receive-bandwidth": handleReceiveBandwidthMessage, @@ -31,7 +43,15 @@ export const messageHandlers: Record = { "newt/socket/containers": handleDockerContainersMessage, "newt/ping/request": handleNewtPingRequestMessage, "newt/blueprint/apply": handleApplyBlueprintMessage, - "newt/healthcheck/status": handleHealthcheckStatusMessage + "newt/healthcheck/status": handleHealthcheckStatusMessage, + "ws/round-trip/complete": handleRoundTripMessage }; -startOlmOfflineChecker(); // this is to handle the offline check for olms +// Start the ping accumulator for all builds — it batches per-site online/lastPing +// updates into periodic bulk writes, preventing connection pool exhaustion. +startPingAccumulator(); + +if (build != "saas") { + startOlmOfflineChecker(); // this is to handle the offline check for olms + startNewtOfflineChecker(); // this is to handle the offline check for newts +} diff --git a/server/routers/ws/types.ts b/server/routers/ws/types.ts index b4ec690b2..e539954ce 100644 --- a/server/routers/ws/types.ts +++ b/server/routers/ws/types.ts @@ -24,7 +24,8 @@ export interface AuthenticatedWebSocket extends WebSocket { clientType?: ClientType; connectionId?: string; isFullyConnected?: boolean; - pendingMessages?: Buffer[]; + pendingMessages?: { data: Buffer; isBinary: boolean }[]; + configVersion?: number; } export interface TokenPayload { @@ -36,6 +37,7 @@ export interface TokenPayload { export interface WSMessage { type: string; data: any; + configVersion?: number; } export interface HandlerResponse { @@ -43,6 +45,7 @@ export interface HandlerResponse { broadcast?: boolean; excludeSender?: boolean; targetClientId?: string; + options?: SendMessageOptions; } export interface HandlerContext { @@ -50,10 +53,15 @@ export interface HandlerContext { senderWs: WebSocket; client: Newt | Olm | RemoteExitNode | undefined; clientType: ClientType; - sendToClient: (clientId: string, message: WSMessage) => Promise; + sendToClient: ( + clientId: string, + message: WSMessage, + options?: SendMessageOptions + ) => Promise; broadcastToAllExcept: ( message: WSMessage, - excludeClientId?: string + excludeClientId?: string, + options?: SendMessageOptions ) => Promise; connectedClients: Map; } @@ -62,6 +70,12 @@ export type MessageHandler = ( context: HandlerContext ) => Promise; +// Options for sending messages with config version tracking +export interface SendMessageOptions { + incrementConfigVersion?: boolean; + compress?: boolean; +} + // Redis message type for cross-node communication export interface RedisMessage { type: "direct" | "broadcast"; @@ -69,4 +83,5 @@ export interface RedisMessage { excludeClientId?: string; message: WSMessage; fromNodeId: string; + options?: SendMessageOptions; } diff --git a/server/routers/ws/ws.ts b/server/routers/ws/ws.ts index 0544af9d6..6e6312715 100644 --- a/server/routers/ws/ws.ts +++ b/server/routers/ws/ws.ts @@ -1,10 +1,12 @@ import { Router, Request, Response } from "express"; +import zlib from "zlib"; import { Server as HttpServer } from "http"; import { WebSocket, WebSocketServer } from "ws"; import { Socket } from "net"; -import { Newt, newts, NewtSession, olms, Olm, OlmSession } from "@server/db"; +import { Newt, newts, NewtSession, olms, Olm, OlmSession, sites } from "@server/db"; import { eq } from "drizzle-orm"; import { db } from "@server/db"; +import { recordPing } from "@server/routers/newt/pingAccumulator"; import { validateNewtSessionToken } from "@server/auth/sessions/newt"; import { validateOlmSessionToken } from "@server/auth/sessions/olm"; import { messageHandlers } from "./messageHandlers"; @@ -15,7 +17,8 @@ import { TokenPayload, WebSocketRequest, WSMessage, - AuthenticatedWebSocket + AuthenticatedWebSocket, + SendMessageOptions } from "./types"; import { validateSessionToken } from "@server/auth/sessions/app"; @@ -34,6 +37,8 @@ const NODE_ID = uuidv4(); // Client tracking map (local to this node) const connectedClients: Map = new Map(); +// Config version tracking map (clientId -> version) +const clientConfigVersions: Map = new Map(); // Helper to get map key const getClientMapKey = (clientId: string) => clientId; @@ -53,6 +58,13 @@ const addClient = async ( existingClients.push(ws); connectedClients.set(mapKey, existingClients); + // Initialize config version to 0 if not already set, otherwise use existing + if (!clientConfigVersions.has(clientId)) { + clientConfigVersions.set(clientId, 0); + } + // Set the current config version on the websocket + ws.configVersion = clientConfigVersions.get(clientId) || 0; + logger.info( `Client added to tracking - ${clientType.toUpperCase()} ID: ${clientId}, Connection ID: ${connectionId}, Total connections: ${existingClients.length}` ); @@ -84,34 +96,84 @@ const removeClient = async ( // Local message sending (within this node) const sendToClientLocal = async ( clientId: string, - message: WSMessage + message: WSMessage, + options: SendMessageOptions = {} ): Promise => { const mapKey = getClientMapKey(clientId); const clients = connectedClients.get(mapKey); if (!clients || clients.length === 0) { return false; } - const messageString = JSON.stringify(message); + + // Include config version in message + const configVersion = clientConfigVersions.get(clientId) || 0; + // Update version on all client connections clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(messageString); - } + client.configVersion = configVersion; }); + + const messageWithVersion = { + ...message, + configVersion + }; + + const messageString = JSON.stringify(messageWithVersion); + if (options.compress) { + const compressed = zlib.gzipSync(Buffer.from(messageString, "utf8")); + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(compressed); + } + }); + } else { + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(messageString); + } + }); + } return true; }; const broadcastToAllExceptLocal = async ( message: WSMessage, - excludeClientId?: string + excludeClientId?: string, + options: SendMessageOptions = {} ): Promise => { connectedClients.forEach((clients, mapKey) => { - const [type, id] = mapKey.split(":"); - if (!(excludeClientId && id === excludeClientId)) { - clients.forEach((client) => { - if (client.readyState === WebSocket.OPEN) { - client.send(JSON.stringify(message)); - } - }); + const clientId = mapKey; // mapKey is the clientId + if (!(excludeClientId && clientId === excludeClientId)) { + // Handle config version per client + if (options.incrementConfigVersion) { + const currentVersion = clientConfigVersions.get(clientId) || 0; + const newVersion = currentVersion + 1; + clientConfigVersions.set(clientId, newVersion); + clients.forEach((client) => { + client.configVersion = newVersion; + }); + } + // Include config version in message for this client + const configVersion = clientConfigVersions.get(clientId) || 0; + const messageWithVersion = { + ...message, + configVersion + }; + if (options.compress) { + const compressed = zlib.gzipSync( + Buffer.from(JSON.stringify(messageWithVersion), "utf8") + ); + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(compressed); + } + }); + } else { + clients.forEach((client) => { + if (client.readyState === WebSocket.OPEN) { + client.send(JSON.stringify(messageWithVersion)); + } + }); + } } }); }; @@ -119,10 +181,18 @@ const broadcastToAllExceptLocal = async ( // Cross-node message sending const sendToClient = async ( clientId: string, - message: WSMessage + message: WSMessage, + options: SendMessageOptions = {} ): Promise => { + // Increment config version if requested + if (options.incrementConfigVersion) { + const currentVersion = clientConfigVersions.get(clientId) || 0; + const newVersion = currentVersion + 1; + clientConfigVersions.set(clientId, newVersion); + } + // Try to send locally first - const localSent = await sendToClientLocal(clientId, message); + const localSent = await sendToClientLocal(clientId, message, options); logger.debug( `sendToClient: Message type ${message.type} sent to clientId ${clientId}` @@ -133,10 +203,11 @@ const sendToClient = async ( const broadcastToAllExcept = async ( message: WSMessage, - excludeClientId?: string + excludeClientId?: string, + options: SendMessageOptions = {} ): Promise => { // Broadcast locally - await broadcastToAllExceptLocal(message, excludeClientId); + await broadcastToAllExceptLocal(message, excludeClientId, options); }; // Check if a client has active connections across all nodes @@ -146,6 +217,13 @@ const hasActiveConnections = async (clientId: string): Promise => { return !!(clients && clients.length > 0); }; +// Get the current config version for a client +const getClientConfigVersion = async (clientId: string): Promise => { + const version = clientConfigVersions.get(clientId); + logger.debug(`getClientConfigVersion called for clientId: ${clientId}, returning: ${version} (type: ${typeof version})`); + return version; +}; + // Get all active nodes for a client const getActiveNodes = async ( clientType: ClientType, @@ -230,9 +308,12 @@ const setupConnection = async ( clientType === "newt" ? (client as Newt).newtId : (client as Olm).olmId; await addClient(clientType, clientId, ws); - ws.on("message", async (data) => { + ws.on("message", async (data, isBinary) => { try { - const message: WSMessage = JSON.parse(data.toString()); + const messageBuffer = isBinary + ? zlib.gunzipSync(data as Buffer) + : (data as Buffer); + const message: WSMessage = JSON.parse(messageBuffer.toString()); if (!message.type || typeof message.type !== "string") { throw new Error( @@ -259,15 +340,21 @@ const setupConnection = async ( if (response.broadcast) { await broadcastToAllExcept( response.message, - response.excludeSender ? clientId : undefined + response.excludeSender ? clientId : undefined, + response.options ); } else if (response.targetClientId) { await sendToClient( response.targetClientId, - response.message + response.message, + response.options ); } else { - ws.send(JSON.stringify(response.message)); + await sendToClient( + clientId, + response.message, + response.options + ); } } } catch (error) { @@ -294,6 +381,23 @@ const setupConnection = async ( ); }); + // Handle WebSocket protocol-level pings from older newt clients that do + // not send application-level "newt/ping" messages. Update the site's + // online state and lastPing timestamp so the offline checker treats them + // the same as modern newt clients. + if (clientType === "newt") { + const newtClient = client as Newt; + ws.on("ping", () => { + if (!newtClient.siteId) return; + // Record the ping in the accumulator instead of writing to the + // database on every WS ping frame. The accumulator flushes all + // pending pings in a single batched UPDATE every ~10s, which + // prevents connection pool exhaustion under load (especially + // with cross-region latency to the database). + recordPing(newtClient.siteId); + }); + } + ws.on("error", (error: Error) => { logger.error( `WebSocket error for ${clientType.toUpperCase()} ID ${clientId}:`, @@ -434,5 +538,6 @@ export { getActiveNodes, disconnectClient, NODE_ID, - cleanup + cleanup, + getClientConfigVersion }; diff --git a/server/setup/.gitignore b/server/setup/.gitignore new file mode 100644 index 000000000..a61cfd647 --- /dev/null +++ b/server/setup/.gitignore @@ -0,0 +1 @@ +migrations.ts diff --git a/server/setup/copyInConfig.ts b/server/setup/copyInConfig.ts index aa2e040d5..be3768179 100644 --- a/server/setup/copyInConfig.ts +++ b/server/setup/copyInConfig.ts @@ -2,9 +2,13 @@ import { db, dnsRecords } from "@server/db"; import { domains, exitNodes, orgDomains, orgs, resources } from "@server/db"; import config from "@server/lib/config"; import { eq, ne } from "drizzle-orm"; -import logger from "@server/logger"; +import { build } from "@server/build"; export async function copyInConfig() { + if (build == "saas") { + return; + } + const endpoint = config.getRawConfig().gerbil.base_endpoint; const listenPort = config.getRawConfig().gerbil.start_port; diff --git a/server/setup/ensureSetupToken.ts b/server/setup/ensureSetupToken.ts index 64298029c..ff6387f0c 100644 --- a/server/setup/ensureSetupToken.ts +++ b/server/setup/ensureSetupToken.ts @@ -16,11 +16,23 @@ function generateToken(): string { return generateRandomString(random, alphabet, 32); } +function validateToken(token: string): boolean { + const tokenRegex = /^[a-z0-9]{32}$/; + return tokenRegex.test(token); +} + function generateId(length: number): string { const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; return generateRandomString(random, alphabet, length); } +function showSetupToken(token: string, source: string): void { + console.log(`=== SETUP TOKEN ${source} ===`); + console.log("Token:", token); + console.log("Use this token on the initial setup page"); + console.log("================================"); +} + export async function ensureSetupToken() { try { // Check if a server admin already exists @@ -38,17 +50,52 @@ 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 exists in DB - update it if different + 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 { + // No existing token - insert new one + 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 +111,7 @@ export async function ensureSetupToken() { dateUsed: null }); - console.log("=== SETUP TOKEN GENERATED ==="); - console.log("Token:", token); - console.log("Use this token on the initial setup page"); - console.log("================================"); + showSetupToken(token, "GENERATED"); } catch (error) { console.error("Failed to ensure setup token:", error); throw error; diff --git a/server/setup/migrationsPg.ts b/server/setup/migrationsPg.ts index 0fc42f9d5..9ba0b9767 100644 --- a/server/setup/migrationsPg.ts +++ b/server/setup/migrationsPg.ts @@ -5,6 +5,7 @@ import semver from "semver"; import { versionMigrations } from "../db/pg"; import { __DIRNAME, APP_VERSION } from "@server/lib/consts"; import path from "path"; +import { build } from "@server/build"; import m1 from "./scriptsPg/1.6.0"; import m2 from "./scriptsPg/1.7.0"; import m3 from "./scriptsPg/1.8.0"; @@ -15,6 +16,12 @@ import m7 from "./scriptsPg/1.11.0"; import m8 from "./scriptsPg/1.11.1"; import m9 from "./scriptsPg/1.12.0"; import m10 from "./scriptsPg/1.13.0"; +import m11 from "./scriptsPg/1.14.0"; +import m12 from "./scriptsPg/1.15.0"; +import m13 from "./scriptsPg/1.15.3"; +import m14 from "./scriptsPg/1.15.4"; +import m15 from "./scriptsPg/1.16.0"; +import m16 from "./scriptsPg/1.17.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -30,7 +37,13 @@ const migrations = [ { version: "1.11.0", run: m7 }, { version: "1.11.1", run: m8 }, { version: "1.12.0", run: m9 }, - { version: "1.13.0", run: m10 } + { version: "1.13.0", run: m10 }, + { version: "1.14.0", run: m11 }, + { version: "1.15.0", run: m12 }, + { version: "1.15.3", run: m13 }, + { version: "1.15.4", run: m14 }, + { version: "1.16.0", run: m15 }, + { version: "1.17.0", run: m16 } // Add new migrations here as they are created ] as { version: string; @@ -45,6 +58,10 @@ async function run() { } export async function runMigrations() { + if (build == "saas") { + console.log("Running in SaaS mode, skipping migrations..."); + return; + } if (process.env.DISABLE_MIGRATIONS) { console.log("Migrations are disabled. Skipping..."); return; diff --git a/server/setup/migrationsSqlite.ts b/server/setup/migrationsSqlite.ts index 8ff66c491..45a29ec29 100644 --- a/server/setup/migrationsSqlite.ts +++ b/server/setup/migrationsSqlite.ts @@ -7,6 +7,7 @@ import { versionMigrations } from "../db/sqlite"; import { __DIRNAME, APP_PATH, APP_VERSION } from "@server/lib/consts"; import { SqliteError } from "better-sqlite3"; import fs from "fs"; +import { build } from "@server/build"; import m1 from "./scriptsSqlite/1.0.0-beta1"; import m2 from "./scriptsSqlite/1.0.0-beta2"; import m3 from "./scriptsSqlite/1.0.0-beta3"; @@ -33,6 +34,12 @@ import m28 from "./scriptsSqlite/1.11.0"; import m29 from "./scriptsSqlite/1.11.1"; import m30 from "./scriptsSqlite/1.12.0"; import m31 from "./scriptsSqlite/1.13.0"; +import m32 from "./scriptsSqlite/1.14.0"; +import m33 from "./scriptsSqlite/1.15.0"; +import m34 from "./scriptsSqlite/1.15.3"; +import m35 from "./scriptsSqlite/1.15.4"; +import m36 from "./scriptsSqlite/1.16.0"; +import m37 from "./scriptsSqlite/1.17.0"; // THIS CANNOT IMPORT ANYTHING FROM THE SERVER // EXCEPT FOR THE DATABASE AND THE SCHEMA @@ -64,7 +71,13 @@ const migrations = [ { version: "1.11.0", run: m28 }, { version: "1.11.1", run: m29 }, { version: "1.12.0", run: m30 }, - { version: "1.13.0", run: m31 } + { version: "1.13.0", run: m31 }, + { version: "1.14.0", run: m32 }, + { version: "1.15.0", run: m33 }, + { version: "1.15.3", run: m34 }, + { version: "1.15.4", run: m35 }, + { version: "1.16.0", run: m36 }, + { version: "1.17.0", run: m37 } // Add new migrations here as they are created ] as const; @@ -97,6 +110,10 @@ function backupDb() { } export async function runMigrations() { + if (build == "saas") { + console.log("Running in SaaS mode, skipping migrations..."); + return; + } if (process.env.DISABLE_MIGRATIONS) { console.log("Migrations are disabled. Skipping..."); return; diff --git a/server/setup/scriptsPg/1.14.0.ts b/server/setup/scriptsPg/1.14.0.ts new file mode 100644 index 000000000..c396df0ce --- /dev/null +++ b/server/setup/scriptsPg/1.14.0.ts @@ -0,0 +1,96 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; +import { __DIRNAME } from "@server/lib/consts"; + +const version = "1.14.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql` + CREATE TABLE "loginPageBranding" ( + "loginPageBrandingId" serial PRIMARY KEY NOT NULL, + "logoUrl" text NOT NULL, + "logoWidth" integer NOT NULL, + "logoHeight" integer NOT NULL, + "primaryColor" text, + "resourceTitle" text NOT NULL, + "resourceSubtitle" text, + "orgTitle" text, + "orgSubtitle" text + ); + `); + + await db.execute(sql` + CREATE TABLE "loginPageBrandingOrg" ( + "loginPageBrandingId" integer NOT NULL, + "orgId" varchar NOT NULL + ); + `); + + await db.execute(sql` + CREATE TABLE "resourceHeaderAuthExtendedCompatibility" ( + "headerAuthExtendedCompatibilityId" serial PRIMARY KEY NOT NULL, + "resourceId" integer NOT NULL, + "extendedCompatibilityIsActivated" boolean DEFAULT false NOT NULL + ); + `); + + await db.execute( + sql`ALTER TABLE "resources" ADD COLUMN "maintenanceModeEnabled" boolean DEFAULT false NOT NULL;` + ); + + await db.execute( + sql`ALTER TABLE "resources" ADD COLUMN "maintenanceModeType" text DEFAULT 'forced';` + ); + + await db.execute( + sql`ALTER TABLE "resources" ADD COLUMN "maintenanceTitle" text;` + ); + + await db.execute( + sql`ALTER TABLE "resources" ADD COLUMN "maintenanceMessage" text;` + ); + + await db.execute( + sql`ALTER TABLE "resources" ADD COLUMN "maintenanceEstimatedTime" text;` + ); + + await db.execute( + sql`ALTER TABLE "siteResources" ADD COLUMN "tcpPortRangeString" varchar NOT NULL DEFAULT '*';` + ); + + await db.execute( + sql`ALTER TABLE "siteResources" ADD COLUMN "udpPortRangeString" varchar NOT NULL DEFAULT '*';` + ); + + await db.execute( + sql`ALTER TABLE "siteResources" ADD COLUMN "disableIcmp" boolean DEFAULT false NOT NULL;` + ); + + await db.execute( + sql`ALTER TABLE "loginPageBrandingOrg" ADD CONSTRAINT "loginPageBrandingOrg_loginPageBrandingId_loginPageBranding_loginPageBrandingId_fk" FOREIGN KEY ("loginPageBrandingId") REFERENCES "public"."loginPageBranding"("loginPageBrandingId") ON DELETE cascade ON UPDATE no action;` + ); + + await db.execute( + sql`ALTER TABLE "loginPageBrandingOrg" ADD CONSTRAINT "loginPageBrandingOrg_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` + ); + + await db.execute( + sql`ALTER TABLE "resourceHeaderAuthExtendedCompatibility" ADD CONSTRAINT "resourceHeaderAuthExtendedCompatibility_resourceId_resources_resourceId_fk" FOREIGN KEY ("resourceId") REFERENCES "public"."resources"("resourceId") ON DELETE cascade ON UPDATE no action;` + ); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsPg/1.15.0.ts b/server/setup/scriptsPg/1.15.0.ts new file mode 100644 index 000000000..0b96345b4 --- /dev/null +++ b/server/setup/scriptsPg/1.15.0.ts @@ -0,0 +1,136 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; +import { __DIRNAME } from "@server/lib/consts"; + +const version = "1.15.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql` + CREATE TABLE "approvals" ( + "approvalId" serial PRIMARY KEY NOT NULL, + "timestamp" integer NOT NULL, + "orgId" varchar NOT NULL, + "clientId" integer, + "userId" varchar NOT NULL, + "decision" varchar DEFAULT 'pending' NOT NULL, + "type" varchar NOT NULL + ); + `); + await db.execute(sql` + CREATE TABLE "clientPostureSnapshots" ( + "snapshotId" serial PRIMARY KEY NOT NULL, + "clientId" integer, + "collectedAt" integer NOT NULL + ); + `); + await db.execute(sql` + CREATE TABLE "currentFingerprint" ( + "id" serial PRIMARY KEY NOT NULL, + "olmId" text NOT NULL, + "firstSeen" integer NOT NULL, + "lastSeen" integer NOT NULL, + "lastCollectedAt" integer NOT NULL, + "username" text, + "hostname" text, + "platform" text, + "osVersion" text, + "kernelVersion" text, + "arch" text, + "deviceModel" text, + "serialNumber" text, + "platformFingerprint" varchar, + "biometricsEnabled" boolean DEFAULT false NOT NULL, + "diskEncrypted" boolean DEFAULT false NOT NULL, + "firewallEnabled" boolean DEFAULT false NOT NULL, + "autoUpdatesEnabled" boolean DEFAULT false NOT NULL, + "tpmAvailable" boolean DEFAULT false NOT NULL, + "windowsAntivirusEnabled" boolean DEFAULT false NOT NULL, + "macosSipEnabled" boolean DEFAULT false NOT NULL, + "macosGatekeeperEnabled" boolean DEFAULT false NOT NULL, + "macosFirewallStealthMode" boolean DEFAULT false NOT NULL, + "linuxAppArmorEnabled" boolean DEFAULT false NOT NULL, + "linuxSELinuxEnabled" boolean DEFAULT false NOT NULL + ); + `); + await db.execute(sql` + CREATE TABLE "fingerprintSnapshots" ( + "id" serial PRIMARY KEY NOT NULL, + "fingerprintId" integer, + "username" text, + "hostname" text, + "platform" text, + "osVersion" text, + "kernelVersion" text, + "arch" text, + "deviceModel" text, + "serialNumber" text, + "platformFingerprint" varchar, + "biometricsEnabled" boolean DEFAULT false NOT NULL, + "diskEncrypted" boolean DEFAULT false NOT NULL, + "firewallEnabled" boolean DEFAULT false NOT NULL, + "autoUpdatesEnabled" boolean DEFAULT false NOT NULL, + "tpmAvailable" boolean DEFAULT false NOT NULL, + "windowsAntivirusEnabled" boolean DEFAULT false NOT NULL, + "macosSipEnabled" boolean DEFAULT false NOT NULL, + "macosGatekeeperEnabled" boolean DEFAULT false NOT NULL, + "macosFirewallStealthMode" boolean DEFAULT false NOT NULL, + "linuxAppArmorEnabled" boolean DEFAULT false NOT NULL, + "linuxSELinuxEnabled" boolean DEFAULT false NOT NULL, + "hash" text NOT NULL, + "collectedAt" integer NOT NULL + ); + `); + await db.execute( + sql`ALTER TABLE "loginPageBranding" ALTER COLUMN "logoUrl" DROP NOT NULL;` + ); + await db.execute( + sql`ALTER TABLE "clients" ADD COLUMN "archived" boolean DEFAULT false NOT NULL;` + ); + await db.execute( + sql`ALTER TABLE "clients" ADD COLUMN "blocked" boolean DEFAULT false NOT NULL;` + ); + await db.execute( + sql`ALTER TABLE "clients" ADD COLUMN "approvalState" varchar;` + ); + await db.execute(sql`ALTER TABLE "idp" ADD COLUMN "tags" text;`); + await db.execute( + sql`ALTER TABLE "olms" ADD COLUMN "archived" boolean DEFAULT false NOT NULL;` + ); + await db.execute( + sql`ALTER TABLE "roles" ADD COLUMN "requireDeviceApproval" boolean DEFAULT false;` + ); + await db.execute( + sql`ALTER TABLE "approvals" ADD CONSTRAINT "approvals_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute( + sql`ALTER TABLE "approvals" ADD CONSTRAINT "approvals_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute( + sql`ALTER TABLE "approvals" ADD CONSTRAINT "approvals_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute( + sql`ALTER TABLE "clientPostureSnapshots" ADD CONSTRAINT "clientPostureSnapshots_clientId_clients_clientId_fk" FOREIGN KEY ("clientId") REFERENCES "public"."clients"("clientId") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute( + sql`ALTER TABLE "currentFingerprint" ADD CONSTRAINT "currentFingerprint_olmId_olms_id_fk" FOREIGN KEY ("olmId") REFERENCES "public"."olms"("id") ON DELETE cascade ON UPDATE no action;` + ); + await db.execute( + sql`ALTER TABLE "fingerprintSnapshots" ADD CONSTRAINT "fingerprintSnapshots_fingerprintId_currentFingerprint_id_fk" FOREIGN KEY ("fingerprintId") REFERENCES "public"."currentFingerprint"("id") ON DELETE set null ON UPDATE no action;` + ); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsPg/1.15.3.ts b/server/setup/scriptsPg/1.15.3.ts new file mode 100644 index 000000000..80c5f67d3 --- /dev/null +++ b/server/setup/scriptsPg/1.15.3.ts @@ -0,0 +1,39 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; +import { __DIRNAME } from "@server/lib/consts"; + +const version = "1.15.3"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql`BEGIN`); + + await db.execute( + sql`ALTER TABLE "limits" ADD COLUMN "override" boolean DEFAULT false;` + ); + await db.execute( + sql`ALTER TABLE "subscriptionItems" ADD COLUMN "stripeSubscriptionItemId" varchar(255);` + ); + await db.execute( + sql`ALTER TABLE "subscriptionItems" ADD COLUMN "featureId" varchar(255);` + ); + await db.execute( + sql`ALTER TABLE "subscriptions" ADD COLUMN "version" integer;` + ); + await db.execute( + sql`ALTER TABLE "subscriptions" ADD COLUMN "type" varchar(50);` + ); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsPg/1.15.4.ts b/server/setup/scriptsPg/1.15.4.ts new file mode 100644 index 000000000..cec04a1a8 --- /dev/null +++ b/server/setup/scriptsPg/1.15.4.ts @@ -0,0 +1,27 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; +import { __DIRNAME } from "@server/lib/consts"; + +const version = "1.15.4"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + try { + await db.execute(sql`BEGIN`); + + await db.execute( + sql`ALTER TABLE "resources" ADD COLUMN "postAuthPath" text;` + ); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsPg/1.16.0.ts b/server/setup/scriptsPg/1.16.0.ts new file mode 100644 index 000000000..0bcfdc4a5 --- /dev/null +++ b/server/setup/scriptsPg/1.16.0.ts @@ -0,0 +1,179 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; +import { configFilePath1, configFilePath2 } from "@server/lib/consts"; +import { encrypt } from "@server/lib/crypto"; +import { generateCA } from "@server/lib/sshCA"; +import fs from "fs"; +import yaml from "js-yaml"; + +const version = "1.16.0"; + +function getServerSecret(): string { + const envSecret = process.env.SERVER_SECRET; + + const configPath = fs.existsSync(configFilePath1) + ? configFilePath1 + : fs.existsSync(configFilePath2) + ? configFilePath2 + : null; + + // If no config file but an env secret is set, use the env secret directly + if (!configPath) { + if (envSecret && envSecret.length > 0) { + return envSecret; + } + + throw new Error( + "Cannot generate org CA keys: no config file found and SERVER_SECRET env var is not set. " + + "Expected config.yml or config.yaml in the config directory, or set SERVER_SECRET." + ); + } + + const configContent = fs.readFileSync(configPath, "utf8"); + const config = yaml.load(configContent) as { + server?: { secret?: string }; + }; + + let secret = config?.server?.secret; + if (!secret || secret.length === 0) { + // Fall back to SERVER_SECRET env var if config does not contain server.secret + if (envSecret && envSecret.length > 0) { + secret = envSecret; + } + } + + if (!secret || secret.length === 0) { + throw new Error( + "Cannot generate org CA keys: no server.secret in config and SERVER_SECRET env var is not set. " + + "Set server.secret in config.yml/config.yaml or set SERVER_SECRET." + ); + } + + return secret; +} + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + // Ensure server secret exists before running migration (required for org CA key generation) + getServerSecret(); + + try { + await db.execute(sql`BEGIN`); + + // Schema changes + await db.execute(sql` + CREATE TABLE "roundTripMessageTracker" ( + "messageId" serial PRIMARY KEY NOT NULL, + "clientId" varchar, + "messageType" varchar, + "sentAt" bigint NOT NULL, + "receivedAt" bigint, + "error" text, + "complete" boolean DEFAULT false NOT NULL + ); + `); + + await db.execute( + sql`ALTER TABLE "orgs" ADD COLUMN "sshCaPrivateKey" text;` + ); + await db.execute( + sql`ALTER TABLE "orgs" ADD COLUMN "sshCaPublicKey" text;` + ); + await db.execute( + sql`ALTER TABLE "orgs" ADD COLUMN "isBillingOrg" boolean;` + ); + await db.execute( + sql`ALTER TABLE "orgs" ADD COLUMN "billingOrgId" varchar;` + ); + + await db.execute( + sql`ALTER TABLE "roles" ADD COLUMN "sshSudoMode" varchar(32) DEFAULT 'none';` + ); + await db.execute( + sql`ALTER TABLE "roles" ADD COLUMN "sshSudoCommands" text DEFAULT '[]';` + ); + await db.execute( + sql`ALTER TABLE "roles" ADD COLUMN "sshCreateHomeDir" boolean DEFAULT true;` + ); + await db.execute( + sql`ALTER TABLE "roles" ADD COLUMN "sshUnixGroups" text DEFAULT '[]';` + ); + + await db.execute( + sql`ALTER TABLE "siteResources" ADD COLUMN "authDaemonPort" integer DEFAULT 22123;` + ); + await db.execute( + sql`ALTER TABLE "siteResources" ADD COLUMN "authDaemonMode" varchar(32) DEFAULT 'site';` + ); + + await db.execute( + sql`ALTER TABLE "userOrgs" ADD COLUMN "pamUsername" varchar;` + ); + + // Set all admin role sudo to "full"; other roles keep default "none" + await db.execute( + sql`UPDATE "roles" SET "sshSudoMode" = 'full' WHERE "isAdmin" = true;` + ); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + // Generate and store encrypted SSH CA keys for all orgs + try { + const secret = getServerSecret(); + + const orgQuery = await db.execute(sql`SELECT "orgId" FROM "orgs"`); + const orgRows = orgQuery.rows as { orgId: string }[]; + + const failedOrgIds: string[] = []; + + for (const row of orgRows) { + try { + const ca = generateCA(`pangolin-ssh-ca-${row.orgId}`); + const encryptedPrivateKey = encrypt(ca.privateKeyPem, secret); + + await db.execute(sql` + UPDATE "orgs" + SET "sshCaPrivateKey" = ${encryptedPrivateKey}, + "sshCaPublicKey" = ${ca.publicKeyOpenSSH} + WHERE "orgId" = ${row.orgId}; + `); + } catch (err) { + failedOrgIds.push(row.orgId); + console.error( + `Error: No CA was generated for organization "${row.orgId}".`, + err instanceof Error ? err.message : err + ); + } + } + + if (orgRows.length > 0) { + const succeeded = orgRows.length - failedOrgIds.length; + console.log( + `Generated and stored SSH CA keys for ${succeeded} org(s).` + ); + } + + if (failedOrgIds.length > 0) { + console.error( + `No CA was generated for ${failedOrgIds.length} organization(s): ${failedOrgIds.join( + ", " + )}` + ); + } + } catch (e) { + console.error( + "Error while generating SSH CA keys for orgs after migration:", + e + ); + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsPg/1.17.0.ts b/server/setup/scriptsPg/1.17.0.ts new file mode 100644 index 000000000..81c42e1a9 --- /dev/null +++ b/server/setup/scriptsPg/1.17.0.ts @@ -0,0 +1,73 @@ +import { db } from "@server/db/pg/driver"; +import { sql } from "drizzle-orm"; + +const version = "1.17.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + // Query existing roleId data from userOrgs before the transaction destroys it + const existingRolesQuery = await db.execute( + sql`SELECT "userId", "orgId", "roleId" FROM "userOrgs" WHERE "roleId" IS NOT NULL` + ); + const existingUserOrgRoles = existingRolesQuery.rows as { + userId: string; + orgId: string; + roleId: number; + }[]; + + console.log( + `Found ${existingUserOrgRoles.length} existing userOrgs role assignment(s) to migrate` + ); + + try { + await db.execute(sql`BEGIN`); + + await db.execute(sql` + CREATE TABLE "userOrgRoles" ( + "userId" varchar NOT NULL, + "orgId" varchar NOT NULL, + "roleId" integer NOT NULL, + CONSTRAINT "userOrgRoles_userId_orgId_roleId_unique" UNIQUE("userId","orgId","roleId") + ); + `); + await db.execute(sql`ALTER TABLE "userOrgs" DROP CONSTRAINT "userOrgs_roleId_roles_roleId_fk";`); + await db.execute(sql`ALTER TABLE "userOrgRoles" ADD CONSTRAINT "userOrgRoles_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;`); + await db.execute(sql`ALTER TABLE "userOrgRoles" ADD CONSTRAINT "userOrgRoles_orgId_orgs_orgId_fk" FOREIGN KEY ("orgId") REFERENCES "public"."orgs"("orgId") ON DELETE cascade ON UPDATE no action;`); + await db.execute(sql`ALTER TABLE "userOrgRoles" ADD CONSTRAINT "userOrgRoles_roleId_roles_roleId_fk" FOREIGN KEY ("roleId") REFERENCES "public"."roles"("roleId") ON DELETE cascade ON UPDATE no action;`); + await db.execute(sql`ALTER TABLE "userOrgs" DROP COLUMN "roleId";`); + + await db.execute(sql`COMMIT`); + console.log("Migrated database"); + } catch (e) { + await db.execute(sql`ROLLBACK`); + console.log("Unable to migrate database"); + console.log(e); + throw e; + } + + // Re-insert the preserved role assignments into the new userOrgRoles table + if (existingUserOrgRoles.length > 0) { + try { + for (const row of existingUserOrgRoles) { + await db.execute(sql` + INSERT INTO "userOrgRoles" ("userId", "orgId", "roleId") + VALUES (${row.userId}, ${row.orgId}, ${row.roleId}) + ON CONFLICT DO NOTHING + `); + } + + console.log( + `Migrated ${existingUserOrgRoles.length} role assignment(s) into userOrgRoles` + ); + } catch (e) { + console.error( + "Error while migrating role assignments into userOrgRoles:", + e + ); + throw e; + } + } + + console.log(`${version} migration complete`); +} \ No newline at end of file diff --git a/server/setup/scriptsSqlite/1.13.0.ts b/server/setup/scriptsSqlite/1.13.0.ts index df8d73443..c4b49495f 100644 --- a/server/setup/scriptsSqlite/1.13.0.ts +++ b/server/setup/scriptsSqlite/1.13.0.ts @@ -305,7 +305,7 @@ export default async function migration() { const subnets = site.remoteSubnets.split(","); for (const subnet of subnets) { // Generate a unique niceId for each new site resource - let niceId = generateName(); + const niceId = generateName(); insertCidrResource.run( site.siteId, subnet.trim(), diff --git a/server/setup/scriptsSqlite/1.14.0.ts b/server/setup/scriptsSqlite/1.14.0.ts new file mode 100644 index 000000000..9559519a4 --- /dev/null +++ b/server/setup/scriptsSqlite/1.14.0.ts @@ -0,0 +1,99 @@ +import { __DIRNAME, APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.14.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + db.prepare( + ` + CREATE TABLE 'loginPageBranding' ( + 'loginPageBrandingId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'logoUrl' text NOT NULL, + 'logoWidth' integer NOT NULL, + 'logoHeight' integer NOT NULL, + 'primaryColor' text, + 'resourceTitle' text NOT NULL, + 'resourceSubtitle' text, + 'orgTitle' text, + 'orgSubtitle' text + ); + ` + ).run(); + + db.prepare( + ` + CREATE TABLE 'loginPageBrandingOrg' ( + 'loginPageBrandingId' integer NOT NULL, + 'orgId' text NOT NULL, + FOREIGN KEY ('loginPageBrandingId') REFERENCES 'loginPageBranding'('loginPageBrandingId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + ` + CREATE TABLE 'resourceHeaderAuthExtendedCompatibility' ( + 'headerAuthExtendedCompatibilityId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'resourceId' integer NOT NULL, + 'extendedCompatibilityIsActivated' integer NOT NULL, + FOREIGN KEY ('resourceId') REFERENCES 'resources'('resourceId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + `ALTER TABLE 'resources' ADD 'maintenanceModeEnabled' integer DEFAULT false NOT NULL;` + ).run(); + + db.prepare( + `ALTER TABLE 'resources' ADD 'maintenanceModeType' text DEFAULT 'forced';` + ).run(); + + db.prepare( + `ALTER TABLE 'resources' ADD 'maintenanceTitle' text;` + ).run(); + + db.prepare( + `ALTER TABLE 'resources' ADD 'maintenanceMessage' text;` + ).run(); + + db.prepare( + `ALTER TABLE 'resources' ADD 'maintenanceEstimatedTime' text;` + ).run(); + + db.prepare( + `ALTER TABLE 'siteResources' ADD 'tcpPortRangeString' text DEFAULT '*' NOT NULL;` + ).run(); + + db.prepare( + `ALTER TABLE 'siteResources' ADD 'udpPortRangeString' text DEFAULT '*' NOT NULL;` + ).run(); + + db.prepare( + `ALTER TABLE 'siteResources' ADD 'disableIcmp' integer NOT NULL DEFAULT false;` + ).run(); + + + })(); + + db.pragma("foreign_keys = ON"); + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.15.0.ts b/server/setup/scriptsSqlite/1.15.0.ts new file mode 100644 index 000000000..dc0638d40 --- /dev/null +++ b/server/setup/scriptsSqlite/1.15.0.ts @@ -0,0 +1,155 @@ +import { __DIRNAME, APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.15.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + db.prepare( + ` +CREATE TABLE 'approvals' ( + 'approvalId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'timestamp' integer NOT NULL, + 'orgId' text NOT NULL, + 'clientId' integer, + 'userId' text, + 'decision' text DEFAULT 'pending' NOT NULL, + 'type' text NOT NULL, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('clientId') REFERENCES 'clients'('clientId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade +); + ` + ).run(); + + db.prepare( + ` +CREATE TABLE 'currentFingerprint' ( + 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'olmId' text NOT NULL, + 'firstSeen' integer NOT NULL, + 'lastSeen' integer NOT NULL, + 'lastCollectedAt' integer NOT NULL, + 'username' text, + 'hostname' text, + 'platform' text, + 'osVersion' text, + 'kernelVersion' text, + 'arch' text, + 'deviceModel' text, + 'serialNumber' text, + 'platformFingerprint' text, + 'biometricsEnabled' integer DEFAULT false NOT NULL, + 'diskEncrypted' integer DEFAULT false NOT NULL, + 'firewallEnabled' integer DEFAULT false NOT NULL, + 'autoUpdatesEnabled' integer DEFAULT false NOT NULL, + 'tpmAvailable' integer DEFAULT false NOT NULL, + 'windowsAntivirusEnabled' integer DEFAULT false NOT NULL, + 'macosSipEnabled' integer DEFAULT false NOT NULL, + 'macosGatekeeperEnabled' integer DEFAULT false NOT NULL, + 'macosFirewallStealthMode' integer DEFAULT false NOT NULL, + 'linuxAppArmorEnabled' integer DEFAULT false NOT NULL, + 'linuxSELinuxEnabled' integer DEFAULT false NOT NULL, + FOREIGN KEY ('olmId') REFERENCES 'olms'('id') ON UPDATE no action ON DELETE cascade +); + ` + ).run(); + + db.prepare( + ` +CREATE TABLE 'fingerprintSnapshots' ( + 'id' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'fingerprintId' integer, + 'username' text, + 'hostname' text, + 'platform' text, + 'osVersion' text, + 'kernelVersion' text, + 'arch' text, + 'deviceModel' text, + 'serialNumber' text, + 'platformFingerprint' text, + 'biometricsEnabled' integer DEFAULT false NOT NULL, + 'diskEncrypted' integer DEFAULT false NOT NULL, + 'firewallEnabled' integer DEFAULT false NOT NULL, + 'autoUpdatesEnabled' integer DEFAULT false NOT NULL, + 'tpmAvailable' integer DEFAULT false NOT NULL, + 'windowsAntivirusEnabled' integer DEFAULT false NOT NULL, + 'macosSipEnabled' integer DEFAULT false NOT NULL, + 'macosGatekeeperEnabled' integer DEFAULT false NOT NULL, + 'macosFirewallStealthMode' integer DEFAULT false NOT NULL, + 'linuxAppArmorEnabled' integer DEFAULT false NOT NULL, + 'linuxSELinuxEnabled' integer DEFAULT false NOT NULL, + 'hash' text NOT NULL, + 'collectedAt' integer NOT NULL, + FOREIGN KEY ('fingerprintId') REFERENCES 'currentFingerprint'('id') ON UPDATE no action ON DELETE set null +); + ` + ).run(); + + db.prepare( + ` +CREATE TABLE '__new_loginPageBranding' ( + 'loginPageBrandingId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'logoUrl' text, + 'logoWidth' integer NOT NULL, + 'logoHeight' integer NOT NULL, + 'primaryColor' text, + 'resourceTitle' text NOT NULL, + 'resourceSubtitle' text, + 'orgTitle' text, + 'orgSubtitle' text +); + ` + ).run(); + + db.prepare( + `INSERT INTO '__new_loginPageBranding'("loginPageBrandingId", "logoUrl", "logoWidth", "logoHeight", "primaryColor", "resourceTitle", "resourceSubtitle", "orgTitle", "orgSubtitle") SELECT "loginPageBrandingId", "logoUrl", "logoWidth", "logoHeight", "primaryColor", "resourceTitle", "resourceSubtitle", "orgTitle", "orgSubtitle" FROM 'loginPageBranding';` + ).run(); + + db.prepare(`DROP TABLE 'loginPageBranding';`).run(); + + db.prepare( + `ALTER TABLE '__new_loginPageBranding' RENAME TO 'loginPageBranding';` + ).run(); + + db.prepare( + `ALTER TABLE 'clients' ADD 'archived' integer DEFAULT false NOT NULL;` + ).run(); + + db.prepare( + `ALTER TABLE 'clients' ADD 'blocked' integer DEFAULT false NOT NULL;` + ).run(); + + db.prepare(`ALTER TABLE 'clients' ADD 'approvalState' text;`).run(); + + db.prepare(`ALTER TABLE 'idp' ADD 'tags' text;`).run(); + + db.prepare( + `ALTER TABLE 'olms' ADD 'archived' integer DEFAULT false NOT NULL;` + ).run(); + + db.prepare( + `ALTER TABLE 'roles' ADD 'requireDeviceApproval' integer DEFAULT false;` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.15.3.ts b/server/setup/scriptsSqlite/1.15.3.ts new file mode 100644 index 000000000..3ba8fb099 --- /dev/null +++ b/server/setup/scriptsSqlite/1.15.3.ts @@ -0,0 +1,29 @@ +import { __DIRNAME, APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.15.3"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.transaction(() => { + db.prepare(`ALTER TABLE 'limits' ADD 'override' integer DEFAULT false;`).run(); + db.prepare(`ALTER TABLE 'subscriptionItems' ADD 'featureId' text;`).run(); + db.prepare(`ALTER TABLE 'subscriptionItems' ADD 'stripeSubscriptionItemId' text;`).run(); + db.prepare(`ALTER TABLE 'subscriptions' ADD 'version' integer;`).run(); + db.prepare(`ALTER TABLE 'subscriptions' ADD 'type' text;`).run(); + })(); + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.15.4.ts b/server/setup/scriptsSqlite/1.15.4.ts new file mode 100644 index 000000000..35a51024a --- /dev/null +++ b/server/setup/scriptsSqlite/1.15.4.ts @@ -0,0 +1,27 @@ +import { __DIRNAME, APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.15.4"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.transaction(() => { + db.prepare( + `ALTER TABLE 'resources' ADD 'postAuthPath' text;` + ).run(); + })(); + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.16.0.ts b/server/setup/scriptsSqlite/1.16.0.ts new file mode 100644 index 000000000..67f87c288 --- /dev/null +++ b/server/setup/scriptsSqlite/1.16.0.ts @@ -0,0 +1,173 @@ +import { APP_PATH, configFilePath1, configFilePath2 } from "@server/lib/consts"; +import { encrypt } from "@server/lib/crypto"; +import { generateCA } from "@server/lib/sshCA"; +import Database from "better-sqlite3"; +import fs from "fs"; +import path from "path"; +import yaml from "js-yaml"; + +const version = "1.16.0"; + +function getServerSecret(): string { + const envSecret = process.env.SERVER_SECRET; + + const configPath = fs.existsSync(configFilePath1) + ? configFilePath1 + : fs.existsSync(configFilePath2) + ? configFilePath2 + : null; + + // If no config file but an env secret is set, use the env secret directly + if (!configPath) { + if (envSecret && envSecret.length > 0) { + return envSecret; + } + + throw new Error( + "Cannot generate org CA keys: no config file found and SERVER_SECRET env var is not set. " + + "Expected config.yml or config.yaml in the config directory, or set SERVER_SECRET." + ); + } + + const configContent = fs.readFileSync(configPath, "utf8"); + const config = yaml.load(configContent) as { + server?: { secret?: string }; + }; + + let secret = config?.server?.secret; + if (!secret || secret.length === 0) { + // Fall back to SERVER_SECRET env var if config does not contain server.secret + if (envSecret && envSecret.length > 0) { + secret = envSecret; + } + } + + if (!secret || secret.length === 0) { + throw new Error( + "Cannot generate org CA keys: no server.secret in config and SERVER_SECRET env var is not set. " + + "Set server.secret in config.yml/config.yaml or set SERVER_SECRET." + ); + } + + return secret; +} + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + // Ensure server secret exists before running migration (required for org CA key generation) + getServerSecret(); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + db.transaction(() => { + // Create roundTripMessageTracker table for tracking message round-trips + db.prepare( + ` + CREATE TABLE 'roundTripMessageTracker' ( + 'messageId' integer PRIMARY KEY AUTOINCREMENT NOT NULL, + 'clientId' text, + 'messageType' text, + 'sentAt' integer NOT NULL, + 'receivedAt' integer, + 'error' text, + 'complete' integer DEFAULT 0 NOT NULL + ); + ` + ).run(); + + // Org SSH CA and billing columns + db.prepare(`ALTER TABLE 'orgs' ADD 'sshCaPrivateKey' text;`).run(); + db.prepare(`ALTER TABLE 'orgs' ADD 'sshCaPublicKey' text;`).run(); + db.prepare(`ALTER TABLE 'orgs' ADD 'isBillingOrg' integer;`).run(); + db.prepare(`ALTER TABLE 'orgs' ADD 'billingOrgId' text;`).run(); + + // Role SSH sudo and unix group columns + db.prepare( + `ALTER TABLE 'roles' ADD 'sshSudoMode' text DEFAULT 'none';` + ).run(); + db.prepare( + `ALTER TABLE 'roles' ADD 'sshSudoCommands' text DEFAULT '[]';` + ).run(); + db.prepare( + `ALTER TABLE 'roles' ADD 'sshCreateHomeDir' integer DEFAULT 1;` + ).run(); + db.prepare( + `ALTER TABLE 'roles' ADD 'sshUnixGroups' text DEFAULT '[]';` + ).run(); + + // Site resource auth daemon columns + db.prepare( + `ALTER TABLE 'siteResources' ADD 'authDaemonPort' integer DEFAULT 22123;` + ).run(); + db.prepare( + `ALTER TABLE 'siteResources' ADD 'authDaemonMode' text DEFAULT 'site';` + ).run(); + + // UserOrg PAM username for SSH + db.prepare(`ALTER TABLE 'userOrgs' ADD 'pamUsername' text;`).run(); + + // Set all admin role sudo to "full"; other roles keep default "none" + db.prepare( + `UPDATE 'roles' SET 'sshSudoMode' = 'full' WHERE isAdmin = 1;` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + const orgRows = db.prepare("SELECT orgId FROM orgs").all() as { + orgId: string; + }[]; + + // Generate and store encrypted SSH CA keys for all orgs + const secret = getServerSecret(); + + const updateOrgCaKeys = db.prepare( + "UPDATE orgs SET sshCaPrivateKey = ?, sshCaPublicKey = ? WHERE orgId = ?" + ); + + const failedOrgIds: string[] = []; + + for (const row of orgRows) { + try { + const ca = generateCA(`pangolin-ssh-ca-${row.orgId}`); + const encryptedPrivateKey = encrypt(ca.privateKeyPem, secret); + updateOrgCaKeys.run( + encryptedPrivateKey, + ca.publicKeyOpenSSH, + row.orgId + ); + } catch (err) { + failedOrgIds.push(row.orgId); + console.error( + `Error: No CA was generated for organization "${row.orgId}".`, + err instanceof Error ? err.message : err + ); + } + } + + if (orgRows.length > 0) { + const succeeded = orgRows.length - failedOrgIds.length; + console.log( + `Generated and stored SSH CA keys for ${succeeded} org(s).` + ); + } + + if (failedOrgIds.length > 0) { + console.error( + `No CA was generated for ${failedOrgIds.length} organization(s): ${failedOrgIds.join(", ")}` + ); + } + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} diff --git a/server/setup/scriptsSqlite/1.17.0.ts b/server/setup/scriptsSqlite/1.17.0.ts new file mode 100644 index 000000000..fe7d82de0 --- /dev/null +++ b/server/setup/scriptsSqlite/1.17.0.ts @@ -0,0 +1,96 @@ +import { APP_PATH } from "@server/lib/consts"; +import Database from "better-sqlite3"; +import path from "path"; + +const version = "1.17.0"; + +export default async function migration() { + console.log(`Running setup script ${version}...`); + + const location = path.join(APP_PATH, "db", "db.sqlite"); + const db = new Database(location); + + try { + db.pragma("foreign_keys = OFF"); + + // Query existing roleId data from userOrgs before the transaction destroys it + const existingUserOrgRoles = db + .prepare( + `SELECT "userId", "orgId", "roleId" FROM 'userOrgs' WHERE "roleId" IS NOT NULL` + ) + .all() as { userId: string; orgId: string; roleId: number }[]; + + console.log( + `Found ${existingUserOrgRoles.length} existing userOrgs role assignment(s) to migrate` + ); + + db.transaction(() => { + db.prepare( + ` + CREATE TABLE 'userOrgRoles' ( + 'userId' text NOT NULL, + 'orgId' text NOT NULL, + 'roleId' integer NOT NULL, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('roleId') REFERENCES 'roles'('roleId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + `CREATE UNIQUE INDEX 'userOrgRoles_userId_orgId_roleId_unique' ON 'userOrgRoles' ('userId','orgId','roleId');` + ).run(); + + db.prepare( + ` + CREATE TABLE '__new_userOrgs' ( + 'userId' text NOT NULL, + 'orgId' text NOT NULL, + 'isOwner' integer DEFAULT false NOT NULL, + 'autoProvisioned' integer DEFAULT false, + 'pamUsername' text, + FOREIGN KEY ('userId') REFERENCES 'user'('id') ON UPDATE no action ON DELETE cascade, + FOREIGN KEY ('orgId') REFERENCES 'orgs'('orgId') ON UPDATE no action ON DELETE cascade + ); + ` + ).run(); + + db.prepare( + `INSERT INTO '__new_userOrgs'("userId", "orgId", "isOwner", "autoProvisioned", "pamUsername") SELECT "userId", "orgId", "isOwner", "autoProvisioned", "pamUsername" FROM 'userOrgs';` + ).run(); + db.prepare(`DROP TABLE 'userOrgs';`).run(); + db.prepare( + `ALTER TABLE '__new_userOrgs' RENAME TO 'userOrgs';` + ).run(); + })(); + + db.pragma("foreign_keys = ON"); + + // Re-insert the preserved role assignments into the new userOrgRoles table + if (existingUserOrgRoles.length > 0) { + const insertUserOrgRole = db.prepare( + `INSERT OR IGNORE INTO 'userOrgRoles' ("userId", "orgId", "roleId") VALUES (?, ?, ?)` + ); + + const insertAll = db.transaction(() => { + for (const row of existingUserOrgRoles) { + insertUserOrgRole.run(row.userId, row.orgId, row.roleId); + } + }); + + insertAll(); + + console.log( + `Migrated ${existingUserOrgRoles.length} role assignment(s) into userOrgRoles` + ); + } + + console.log(`Migrated database`); + } catch (e) { + console.log("Failed to migrate db:", e); + throw e; + } + + console.log(`${version} migration complete`); +} \ No newline at end of file diff --git a/server/types/Auth.ts b/server/types/Auth.ts index 8e222987c..398c02406 100644 --- a/server/types/Auth.ts +++ b/server/types/Auth.ts @@ -5,5 +5,5 @@ import { Session } from "@server/db"; export interface AuthenticatedRequest extends Request { user: User; session: Session; - userOrgRoleId?: number; + userOrgRoleIds?: number[]; } diff --git a/server/types/Pagination.ts b/server/types/Pagination.ts new file mode 100644 index 000000000..b0f5edfe2 --- /dev/null +++ b/server/types/Pagination.ts @@ -0,0 +1,5 @@ +export type Pagination = { total: number; pageSize: number; page: number }; + +export type PaginatedResponse = T & { + pagination: Pagination; +}; diff --git a/server/types/Tiers.ts b/server/types/Tiers.ts new file mode 100644 index 000000000..f81e94e29 --- /dev/null +++ b/server/types/Tiers.ts @@ -0,0 +1 @@ +export type Tier = "tier1" | "tier2" | "tier3" | "enterprise"; diff --git a/src/app/[orgId]/layout.tsx b/src/app/[orgId]/layout.tsx index c307efcb0..8dc28001e 100644 --- a/src/app/[orgId]/layout.tsx +++ b/src/app/[orgId]/layout.tsx @@ -18,6 +18,8 @@ import { build } from "@server/build"; import OrgPolicyResult from "@app/components/OrgPolicyResult"; import UserProvider from "@app/providers/UserProvider"; import { Layout } from "@app/components/Layout"; +import ApplyInternalRedirect from "@app/components/ApplyInternalRedirect"; +import SubscriptionViolation from "@app/components/SubscriptionViolation"; export default async function OrgLayout(props: { children: React.ReactNode; @@ -70,6 +72,7 @@ export default async function OrgLayout(props: { } catch (e) {} return ( + internal.get>( - `/org/${orgId}/billing/subscription`, + `/org/${orgId}/billing/subscriptions`, cookie ) ); @@ -104,7 +107,9 @@ export default async function OrgLayout(props: { env={env.app.environment} sandbox_mode={env.app.sandbox_mode} > + {props.children} + {build === "saas" && } ); diff --git a/src/app/[orgId]/settings/(private)/access/approvals/page.tsx b/src/app/[orgId]/settings/(private)/access/approvals/page.tsx new file mode 100644 index 000000000..de69de046 --- /dev/null +++ b/src/app/[orgId]/settings/(private)/access/approvals/page.tsx @@ -0,0 +1,67 @@ +import { ApprovalFeed } from "@app/components/ApprovalFeed"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import ApprovalsBanner from "@app/components/ApprovalsBanner"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import type { ApprovalItem } from "@app/lib/queries"; +import OrgProvider from "@app/providers/OrgProvider"; +import type { GetOrgResponse } from "@server/routers/org"; +import type { ListRolesResponse } from "@server/routers/role"; +import type { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; + +export interface ApprovalFeedPageProps { + params: Promise<{ orgId: string }>; +} + +export default async function ApprovalFeedPage(props: ApprovalFeedPageProps) { + const params = await props.params; + + let org: GetOrgResponse | null = null; + const orgRes = await getCachedOrg(params.orgId); + + if (orgRes && orgRes.status === 200) { + org = orgRes.data.data; + } + + // Fetch roles to check if approvals are enabled + let hasApprovalsEnabled = false; + const rolesRes = await internal + .get< + AxiosResponse + >(`/org/${params.orgId}/roles`, await authCookieHeader()) + .catch((e) => {}); + + if (rolesRes && rolesRes.status === 200) { + hasApprovalsEnabled = rolesRes.data.data.roles.some( + (role) => role.requireDeviceApproval === true + ); + } + + const t = await getTranslations(); + + return ( + <> + + + + + + + +
+ +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/(private)/billing/layout.tsx b/src/app/[orgId]/settings/(private)/billing/layout.tsx index e52f19edf..69c3da485 100644 --- a/src/app/[orgId]/settings/(private)/billing/layout.tsx +++ b/src/app/[orgId]/settings/(private)/billing/layout.tsx @@ -1,16 +1,12 @@ -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { HorizontalTabs } from "@app/components/HorizontalTabs"; import { verifySession } from "@app/lib/auth/verifySession"; import OrgProvider from "@app/providers/OrgProvider"; import OrgUserProvider from "@app/providers/OrgUserProvider"; -import { GetOrgResponse } from "@server/routers/org"; -import { GetOrgUserResponse } from "@server/routers/user"; -import { AxiosResponse } from "axios"; import { redirect } from "next/navigation"; -import { cache } from "react"; import { getTranslations } from "next-intl/server"; +import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import { build } from "@server/build"; type BillingSettingsProps = { children: React.ReactNode; @@ -22,9 +18,11 @@ export default async function BillingSettingsPage({ params }: BillingSettingsProps) { const { orgId } = await params; + if (build !== "saas") { + redirect(`/${orgId}/settings`); + } - const getUser = cache(verifySession); - const user = await getUser(); + const user = await verifySession(); if (!user) { redirect(`/`); @@ -32,13 +30,7 @@ export default async function BillingSettingsPage({ let orgUser = null; try { - const getOrgUser = cache(async () => - internal.get>( - `/org/${orgId}/user/${user.userId}`, - await authCookieHeader() - ) - ); - const res = await getOrgUser(); + const res = await getCachedOrgUser(orgId, user.userId); orgUser = res.data.data; } catch { redirect(`/${orgId}`); @@ -46,18 +38,16 @@ export default async function BillingSettingsPage({ let org = null; try { - const getOrg = cache(async () => - internal.get>( - `/org/${orgId}`, - await authCookieHeader() - ) - ); - const res = await getOrg(); + const res = await getCachedOrg(orgId); org = res.data.data; } catch { redirect(`/${orgId}`); } + if (!(org?.org?.isBillingOrg && orgUser?.isOwner)) { + redirect(`/${orgId}`); + } + const t = await getTranslations(); return ( diff --git a/src/app/[orgId]/settings/(private)/billing/page.tsx b/src/app/[orgId]/settings/(private)/billing/page.tsx index 1ed5c094e..ba08f6022 100644 --- a/src/app/[orgId]/settings/(private)/billing/page.tsx +++ b/src/app/[orgId]/settings/(private)/billing/page.tsx @@ -17,44 +17,200 @@ import { SettingsSectionBody, SettingsSectionFooter } from "@app/components/Settings"; -import { Badge } from "@/components/ui/badge"; -import { Separator } from "@/components/ui/separator"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Progress } from "@/components/ui/progress"; import { - CreditCard, - Database, - Clock, - AlertCircle, - CheckCircle, - Users, - Calculator, - ExternalLink, - Gift, - Server -} from "lucide-react"; -import { InfoPopup } from "@/components/ui/info-popup"; + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { cn } from "@app/lib/cn"; +import { CreditCard, ExternalLink, Check, AlertTriangle } from "lucide-react"; +import { Alert, AlertTitle, AlertDescription } from "@app/components/ui/alert"; +import { + Tooltip, + TooltipTrigger, + TooltipContent +} from "@app/components/ui/tooltip"; import { GetOrgSubscriptionResponse, GetOrgUsageResponse } from "@server/routers/billing/types"; import { useTranslations } from "use-intl"; import Link from "next/link"; +import { Tier } from "@server/types/Tiers"; +import { + freeLimitSet, + tier1LimitSet, + tier2LimitSet, + tier3LimitSet +} from "@server/lib/billing/limitSet"; +import { FeatureId } from "@server/lib/billing/features"; -export default function GeneralPage() { +// Plan tier definitions matching the mockup +type PlanId = "basic" | "home" | "team" | "business" | "enterprise"; + +type PlanOption = { + id: PlanId; + name: string; + price: string; + priceDetail?: string; + tierType: Tier | null; + features: string[]; +}; + +const planOptions: PlanOption[] = [ + { + id: "basic", + name: "Basic", + price: "Free", + tierType: null, + features: [ + "Basic Pangolin features", + "Free provided domains", + "Web-based proxy resources", + "Private resources and clients", + "Peer-to-peer connections" + ] + }, + { + id: "home", + name: "Home", + price: "$12.50", + priceDetail: "/ month", + tierType: "tier1", + features: [ + "Everything in Basic", + "OAuth2/OIDC, Google, & Azure SSO", + "Bring your own identity provider", + "Pangolin SSH", + "Custom branding", + "Device admin approvals" + ] + }, + { + id: "team", + name: "Team", + price: "$4", + priceDetail: "per user / month", + tierType: "tier2", + features: [ + "Everything in Basic", + "Custom domains", + "OAuth2/OIDC, Google, & Azure SSO", + "Access and action audit logs", + "Device posture information" + ] + }, + { + id: "business", + name: "Business", + price: "$9", + priceDetail: "per user / month", + tierType: "tier3", + features: [ + "Everything in Team", + "Multiple organizations (multi-tenancy)", + "Auto-provisioning via IdP", + "Pangolin SSH", + "Device approvals", + "Custom branding", + "Business support" + ] + }, + { + id: "enterprise", + name: "Enterprise", + price: "Custom", + tierType: null, + features: [ + "Everything in Business", + "Custom limits", + "Priority support and SLA", + "Log push and export", + "Private and Gov-Cloud deployment options", + "Dedicated, premium relay/exit nodes", + "Pay by invoice " + ] + } +]; + +// Tier limits mapping derived from limit sets +const tierLimits: Record< + Tier | "basic", + { + users: number; + sites: number; + domains: number; + remoteNodes: number; + organizations: number; + } +> = { + basic: { + users: freeLimitSet[FeatureId.USERS]?.value ?? 0, + sites: freeLimitSet[FeatureId.SITES]?.value ?? 0, + domains: freeLimitSet[FeatureId.DOMAINS]?.value ?? 0, + remoteNodes: freeLimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0, + organizations: freeLimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0 + }, + tier1: { + users: tier1LimitSet[FeatureId.USERS]?.value ?? 0, + sites: tier1LimitSet[FeatureId.SITES]?.value ?? 0, + domains: tier1LimitSet[FeatureId.DOMAINS]?.value ?? 0, + remoteNodes: tier1LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0, + organizations: tier1LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0 + }, + tier2: { + users: tier2LimitSet[FeatureId.USERS]?.value ?? 0, + sites: tier2LimitSet[FeatureId.SITES]?.value ?? 0, + domains: tier2LimitSet[FeatureId.DOMAINS]?.value ?? 0, + remoteNodes: tier2LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0, + organizations: tier2LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0 + }, + tier3: { + users: tier3LimitSet[FeatureId.USERS]?.value ?? 0, + sites: tier3LimitSet[FeatureId.SITES]?.value ?? 0, + domains: tier3LimitSet[FeatureId.DOMAINS]?.value ?? 0, + remoteNodes: tier3LimitSet[FeatureId.REMOTE_EXIT_NODES]?.value ?? 0, + organizations: tier3LimitSet[FeatureId.ORGINIZATIONS]?.value ?? 0 + }, + enterprise: { + users: 0, // Custom for enterprise + sites: 0, // Custom for enterprise + domains: 0, // Custom for enterprise + remoteNodes: 0, // Custom for enterprise + organizations: 0 // Custom for enterprise + } +}; + +export default function BillingPage() { const { org } = useOrgContext(); - const api = createApiClient(useEnvContext()); + const envContext = useEnvContext(); + const api = createApiClient(envContext); const t = useTranslations(); // Subscription state - const [subscription, setSubscription] = - useState(null); - const [subscriptionItems, setSubscriptionItems] = useState< - GetOrgSubscriptionResponse["items"] + const [allSubscriptions, setAllSubscriptions] = useState< + GetOrgSubscriptionResponse["subscriptions"] >([]); + const [tierSubscription, setTierSubscription] = useState< + GetOrgSubscriptionResponse["subscriptions"][0] | null + >(null); + const [licenseSubscription, setLicenseSubscription] = useState< + GetOrgSubscriptionResponse["subscriptions"][0] | null + >(null); const [subscriptionLoading, setSubscriptionLoading] = useState(true); - // Example usage data (replace with real usage data if available) + // Usage and limits data const [usageData, setUsageData] = useState( [] ); @@ -62,19 +218,58 @@ export default function GeneralPage() { [] ); + const [hasSubscription, setHasSubscription] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [currentTier, setCurrentTier] = useState(null); + + // Usage IDs + const USERS = "users"; + const SITES = "sites"; + const DOMAINS = "domains"; + const REMOTE_EXIT_NODES = "remoteExitNodes"; + const ORGINIZATIONS = "organizations"; + + // Confirmation dialog state + const [showConfirmDialog, setShowConfirmDialog] = useState(false); + const [pendingTier, setPendingTier] = useState<{ + tier: Tier | "basic"; + action: "upgrade" | "downgrade"; + planName: string; + price: string; + } | null>(null); + useEffect(() => { async function fetchSubscription() { setSubscriptionLoading(true); try { const res = await api.get< AxiosResponse - >(`/org/${org.org.orgId}/billing/subscription`); - const { subscription, items } = res.data.data; - setSubscription(subscription); - setSubscriptionItems(items); - setHasSubscription( - !!subscription && subscription.status === "active" + >(`/org/${org.org.orgId}/billing/subscriptions`); + const { subscriptions } = res.data.data; + setAllSubscriptions(subscriptions); + + // Find tier subscription + const tierSub = subscriptions.find( + ({ subscription }) => + subscription?.type === "tier1" || + subscription?.type === "tier2" || + subscription?.type === "tier3" || + subscription?.type === "enterprise" ); + setTierSubscription(tierSub || null); + + if (tierSub?.subscription) { + setCurrentTier(tierSub.subscription.type as Tier); + setHasSubscription( + tierSub.subscription.status === "active" + ); + } + + // Find license subscription + const licenseSub = subscriptions.find( + ({ subscription }) => subscription?.type === "license" + ); + setLicenseSubscription(licenseSub || null); } catch (error) { toast({ title: t("billingFailedToLoadSubscription"), @@ -95,7 +290,6 @@ export default function GeneralPage() { `/org/${org.org.orgId}/billing/usage` ); const { usage, limits } = res.data.data; - setUsageData(usage); setLimitsData(limits); } catch (error) { @@ -104,27 +298,18 @@ export default function GeneralPage() { description: formatAxiosError(error), variant: "destructive" }); - } finally { } } fetchUsage(); }, [org.org.orgId]); - const [hasSubscription, setHasSubscription] = useState(true); - const [isLoading, setIsLoading] = useState(false); - // const [newPricing, setNewPricing] = useState({ - // pricePerGB: mockSubscription.pricePerGB, - // pricePerMinute: mockSubscription.pricePerMinute, - // }) - - const handleStartSubscription = async () => { + const handleStartSubscription = async (tier: Tier) => { setIsLoading(true); try { const response = await api.post>( `/org/${org.org.orgId}/billing/create-checkout-session`, - {} + { tier } ); - console.log("Checkout session response:", response.data); const checkoutUrl = response.data.data; if (checkoutUrl) { window.location.href = checkoutUrl; @@ -174,206 +359,408 @@ export default function GeneralPage() { } }; - // Usage IDs - const SITE_UPTIME = "siteUptime"; - const USERS = "users"; - const EGRESS_DATA_MB = "egressDataMb"; - const DOMAINS = "domains"; - const REMOTE_EXIT_NODES = "remoteExitNodes"; - - // Helper to calculate tiered price - function calculateTieredPrice( - usage: number, - tiersRaw: string | null | undefined - ) { - if (!tiersRaw) return 0; - let tiers: any[] = []; - try { - tiers = JSON.parse(tiersRaw); - } catch { - return 0; + const handleChangeTier = async (tier: Tier) => { + if (!hasSubscription) { + // If no subscription, start a new one + handleStartSubscription(tier); + return; } - let total = 0; - let remaining = usage; - for (const tier of tiers) { - const upTo = tier.up_to === null ? Infinity : Number(tier.up_to); - const unitAmount = - tier.unit_amount !== null - ? Number(tier.unit_amount / 100) - : tier.unit_amount_decimal - ? Number(tier.unit_amount_decimal / 100) - : 0; - const tierQty = Math.min( - remaining, - upTo === Infinity ? remaining : upTo - (usage - remaining) - ); - if (tierQty > 0) { - total += tierQty * unitAmount; - remaining -= tierQty; + + setIsLoading(true); + try { + await api.post(`/org/${org.org.orgId}/billing/change-tier`, { + tier + }); + + // Poll the API to check if the tier change has been reflected + const pollForTierChange = async (targetTier: Tier) => { + const maxAttempts = 30; // 30 seconds with 1 second interval + let attempts = 0; + + const poll = async (): Promise => { + try { + const res = await api.get< + AxiosResponse + >(`/org/${org.org.orgId}/billing/subscriptions`); + const { subscriptions } = res.data.data; + + // Find tier subscription + const tierSub = subscriptions.find( + ({ subscription }) => + subscription?.type === "tier1" || + subscription?.type === "tier2" || + subscription?.type === "tier3" + ); + + // Check if the tier has changed to the target tier + if (tierSub?.subscription?.type === targetTier) { + return true; + } + + return false; + } catch (error) { + console.error("Error polling subscription:", error); + return false; + } + }; + + while (attempts < maxAttempts) { + const success = await poll(); + + if (success) { + // Tier change reflected, refresh the page + window.location.reload(); + return; + } + + attempts++; + + if (attempts < maxAttempts) { + // Wait 1 second before next poll + await new Promise((resolve) => + setTimeout(resolve, 1000) + ); + } + } + + // If we've exhausted all attempts, show an error + toast({ + title: "Tier change processing", + description: + "Your tier change is taking longer than expected. Please refresh the page in a moment to see the changes.", + variant: "destructive" + }); + setIsLoading(false); + }; + + // Start polling for the tier change + pollForTierChange(tier); + } catch (error) { + toast({ + title: "Failed to change tier", + description: formatAxiosError(error), + variant: "destructive" + }); + setIsLoading(false); + } + }; + + const confirmTierChange = () => { + if (!pendingTier) return; + + if ( + pendingTier.action === "upgrade" || + pendingTier.action === "downgrade" + ) { + // If downgrading to basic (free tier), go to Stripe portal + if (pendingTier.tier === "basic") { + handleModifySubscription(); + } else if (hasSubscription) { + handleChangeTier(pendingTier.tier); + } else { + handleStartSubscription(pendingTier.tier); } - if (remaining <= 0) break; } - return total; - } - function getDisplayPrice(tiersRaw: string | null | undefined) { - //find the first non-zero tier price - if (!tiersRaw) return "$0.00"; - let tiers: any[] = []; - try { - tiers = JSON.parse(tiersRaw); - } catch { - return "$0.00"; + // setShowConfirmDialog(false); + // setPendingTier(null); + }; + + const showTierConfirmation = ( + tier: Tier | "basic", + action: "upgrade" | "downgrade", + planName: string, + price: string + ) => { + setPendingTier({ tier, action, planName, price }); + setShowConfirmDialog(true); + }; + + const handleContactUs = () => { + window.open("https://pangolin.net/talk-to-us", "_blank"); + }; + + // Get current plan ID from tier + const getCurrentPlanId = (): PlanId => { + if (!hasSubscription || !currentTier) return "basic"; + // Handle enterprise subscription type directly + if (currentTier === "enterprise") return "enterprise"; + const plan = planOptions.find((p) => p.tierType === currentTier); + return plan?.id || "basic"; + }; + + const currentPlanId = getCurrentPlanId(); + + // Check if subscription is in a problematic state that requires attention + const hasProblematicSubscription = (): boolean => { + if (!tierSubscription?.subscription) return false; + const status = tierSubscription.subscription.status; + return ( + status === "past_due" || + status === "unpaid" || + status === "incomplete" || + status === "incomplete_expired" + ); + }; + + const isProblematicState = hasProblematicSubscription(); + + // Get user-friendly subscription status message + const getSubscriptionStatusMessage = (): { + title: string; + description: string; + } | null => { + if (!tierSubscription?.subscription || !isProblematicState) return null; + + const status = tierSubscription.subscription.status; + + switch (status) { + case "past_due": + return { + title: t("billingPastDueTitle") || "Payment Past Due", + description: + t("billingPastDueDescription") || + "Your payment is past due. Please update your payment method to continue using your current plan features. If not resolved, your subscription will be canceled and you'll be reverted to the free tier." + }; + case "unpaid": + return { + title: t("billingUnpaidTitle") || "Subscription Unpaid", + description: + t("billingUnpaidDescription") || + "Your subscription is unpaid and you have been reverted to the free tier. Please update your payment method to restore your subscription." + }; + case "incomplete": + return { + title: t("billingIncompleteTitle") || "Payment Incomplete", + description: + t("billingIncompleteDescription") || + "Your payment is incomplete. Please complete the payment process to activate your subscription." + }; + case "incomplete_expired": + return { + title: + t("billingIncompleteExpiredTitle") || "Payment Expired", + description: + t("billingIncompleteExpiredDescription") || + "Your payment was never completed and has expired. You have been reverted to the free tier. Please subscribe again to restore access to paid features." + }; + default: + return null; } - if (tiers.length === 0) return "$0.00"; + }; - // find the first tier with a non-zero price - const firstTier = - tiers.find( - (t) => - t.unit_amount > 0 || - (t.unit_amount_decimal && Number(t.unit_amount_decimal) > 0) - ) || tiers[0]; - const unitAmount = - firstTier.unit_amount !== null - ? Number(firstTier.unit_amount / 100) - : firstTier.unit_amount_decimal - ? Number(firstTier.unit_amount_decimal / 100) - : 0; - return `$${unitAmount.toFixed(4)}`; // ${firstTier.up_to === null ? "per unit" : `per ${firstTier.up_to} units`}`; - } + const statusMessage = getSubscriptionStatusMessage(); - // Helper to get included usage amount from subscription tier - function getIncludedUsage(tiersRaw: string | null | undefined) { - if (!tiersRaw) return 0; - let tiers: any[] = []; - try { - tiers = JSON.parse(tiersRaw); - } catch { - return 0; + // Get button label and action for each plan + const getPlanAction = (plan: PlanOption) => { + if (plan.id === "enterprise") { + return { + label: "Contact Us", + action: handleContactUs, + variant: "outline" as const, + disabled: false + }; } - if (tiers.length === 0) return 0; - // Find the first tier (which represents included usage) - const firstTier = tiers[0]; - if (!firstTier) return 0; + if (plan.id === currentPlanId) { + // If it's the basic plan (basic with no subscription), show as current but disabled + if ( + plan.id === "basic" && + !hasSubscription && + !isProblematicState + ) { + return { + label: "Current Plan", + action: () => {}, + variant: "default" as const, + disabled: true + }; + } + // If on free tier but has a problematic subscription, allow them to manage it + if (plan.id === "basic" && isProblematicState) { + return { + label: "Manage Subscription", + action: handleModifySubscription, + variant: "default" as const, + disabled: false + }; + } + return { + label: "Manage Current Plan", + action: handleModifySubscription, + variant: "default" as const, + disabled: false + }; + } - // If the first tier has a unit_amount of 0, it represents included usage - const isIncludedTier = - (firstTier.unit_amount === 0 || firstTier.unit_amount === null) && - (!firstTier.unit_amount_decimal || - Number(firstTier.unit_amount_decimal) === 0); + const currentIndex = planOptions.findIndex( + (p) => p.id === currentPlanId + ); + const planIndex = planOptions.findIndex((p) => p.id === plan.id); - if (isIncludedTier && firstTier.up_to !== null) { - return Number(firstTier.up_to); + if (planIndex < currentIndex) { + return { + label: "Downgrade", + action: () => { + if (plan.tierType) { + showTierConfirmation( + plan.tierType, + "downgrade", + plan.name, + plan.price + (" " + plan.priceDetail || "") + ); + } else if (plan.id === "basic") { + // Show confirmation for downgrading to basic (free tier) + showTierConfirmation( + "basic", + "downgrade", + plan.name, + plan.price + ); + } else { + handleModifySubscription(); + } + }, + variant: "outline" as const, + disabled: isProblematicState + }; + } + + return { + label: "Upgrade", + action: () => { + if (plan.tierType) { + showTierConfirmation( + plan.tierType, + "upgrade", + plan.name, + plan.price + (" " + plan.priceDetail || "") + ); + } else { + handleModifySubscription(); + } + }, + variant: "outline" as const, + disabled: isProblematicState + }; + }; + + // Get usage value by feature ID + const getUsageValue = (featureId: string): number => { + const usage = usageData.find((u) => u.featureId === featureId); + return usage?.instantaneousValue || usage?.latestValue || 0; + }; + + // Get limit value by feature ID + const getLimitValue = (featureId: string): number | null => { + const limit = limitsData.find((l) => l.featureId === featureId); + return limit?.value ?? null; + }; + + // Check if usage exceeds limit for a specific feature + const isOverLimit = (featureId: string): boolean => { + const usage = getUsageValue(featureId); + const limit = getLimitValue(featureId); + return limit !== null && usage > limit; + }; + + // Calculate current usage cost for display + const getUserCount = () => getUsageValue(USERS); + const getPricePerUser = () => { + if (!tierSubscription?.items) return 0; + + // Find the subscription item for USERS feature + const usersItem = tierSubscription.items.find( + (item) => item.featureId === USERS + ); + + console.log("Users subscription item:", usersItem); + + // unitAmount is in cents, convert to dollars + if (usersItem?.unitAmount) { + return usersItem.unitAmount / 100; } return 0; - } + }; - // Helper to get display value for included usage - function getIncludedUsageDisplay(includedAmount: number, usageType: any) { - if (includedAmount === 0) return "0"; + // Get license key count + const getLicenseKeyCount = (): number => { + if (!licenseSubscription?.items) return 0; + return licenseSubscription.items.length; + }; - if (usageType.id === EGRESS_DATA_MB) { - // Convert MB to GB for data usage - return (includedAmount / 1000).toFixed(2); + // Check if downgrading to a tier would violate current usage limits + const checkLimitViolations = ( + targetTier: Tier | "basic" + ): Array<{ + feature: string; + currentUsage: number; + newLimit: number; + }> => { + const violations: Array<{ + feature: string; + currentUsage: number; + newLimit: number; + }> = []; + + const limits = tierLimits[targetTier]; + + // Check users + const usersUsage = getUsageValue(USERS); + if (limits.users > 0 && usersUsage > limits.users) { + violations.push({ + feature: "Users", + currentUsage: usersUsage, + newLimit: limits.users + }); } - if (usageType.id === USERS || usageType.id === DOMAINS) { - // divide by 32 days - return (includedAmount / 32).toFixed(2); + // Check sites + const sitesUsage = getUsageValue(SITES); + if (limits.sites > 0 && sitesUsage > limits.sites) { + violations.push({ + feature: "Sites", + currentUsage: sitesUsage, + newLimit: limits.sites + }); } - return includedAmount.toString(); - } - - // Helper to get usage, subscription item, and limit by usageId - function getUsageItemAndLimit( - usageData: any[], - subscriptionItems: any[], - limitsData: any[], - usageId: string - ) { - const usage = usageData.find((u) => u.featureId === usageId); - if (!usage) return { usage: 0, item: undefined, limit: undefined }; - const item = subscriptionItems.find((i) => i.meterId === usage.meterId); - const limit = limitsData.find((l) => l.featureId === usageId); - return { usage: usage ?? 0, item, limit }; - } - - // Helper to check if usage exceeds limit - function isOverLimit(usage: any, limit: any, usageType: any) { - if (!limit || !usage) return false; - const currentUsage = usageType.getLimitUsage(usage); - return currentUsage > limit.value; - } - - // Map usage and pricing for each usage type - const usageTypes = [ - { - id: EGRESS_DATA_MB, - label: t("billingDataUsage"), - icon: , - unit: "GB", - unitRaw: "MB", - info: t("billingDataUsageInfo"), - note: "Not counted on self-hosted nodes", - // Convert MB to GB for display and pricing - getDisplay: (v: any) => (v.latestValue / 1000).toFixed(2), - getLimitDisplay: (v: any) => (v.value / 1000).toFixed(2), - getUsage: (v: any) => v.latestValue, - getLimitUsage: (v: any) => v.latestValue - }, - { - id: SITE_UPTIME, - label: t("billingOnlineTime"), - icon: , - unit: "min", - info: t("billingOnlineTimeInfo"), - note: "Not counted on self-hosted nodes", - getDisplay: (v: any) => v.latestValue, - getLimitDisplay: (v: any) => v.value, - getUsage: (v: any) => v.latestValue, - getLimitUsage: (v: any) => v.latestValue - }, - { - id: USERS, - label: t("billingUsers"), - icon: , - unit: "", - unitRaw: "user days", - info: t("billingUsersInfo"), - getDisplay: (v: any) => v.instantaneousValue, - getLimitDisplay: (v: any) => v.value, - getUsage: (v: any) => v.latestValue, - getLimitUsage: (v: any) => v.instantaneousValue - }, - { - id: DOMAINS, - label: t("billingDomains"), - icon: , - unit: "", - unitRaw: "domain days", - info: t("billingDomainInfo"), - getDisplay: (v: any) => v.instantaneousValue, - getLimitDisplay: (v: any) => v.value, - getUsage: (v: any) => v.latestValue, - getLimitUsage: (v: any) => v.instantaneousValue - }, - { - id: REMOTE_EXIT_NODES, - label: t("billingRemoteExitNodes"), - icon: , - unit: "", - unitRaw: "node days", - info: t("billingRemoteExitNodesInfo"), - getDisplay: (v: any) => v.instantaneousValue, - getLimitDisplay: (v: any) => v.value, - getUsage: (v: any) => v.latestValue, - getLimitUsage: (v: any) => v.instantaneousValue + // Check domains + const domainsUsage = getUsageValue(DOMAINS); + if (limits.domains > 0 && domainsUsage > limits.domains) { + violations.push({ + feature: "Domains", + currentUsage: domainsUsage, + newLimit: limits.domains + }); } - ]; + + // Check remote nodes + const remoteNodesUsage = getUsageValue(REMOTE_EXIT_NODES); + if (limits.remoteNodes > 0 && remoteNodesUsage > limits.remoteNodes) { + violations.push({ + feature: "Remote Exit Nodes", + currentUsage: remoteNodesUsage, + newLimit: limits.remoteNodes + }); + } + + // Check organizations + const organizationsUsage = getUsageValue(ORGINIZATIONS); + if ( + limits.organizations > 0 && + organizationsUsage > limits.organizations + ) { + violations.push({ + feature: "Organizations", + currentUsage: organizationsUsage, + newLimit: limits.organizations + }); + } + + return violations; + }; if (subscriptionLoading) { return ( @@ -385,370 +772,770 @@ export default function GeneralPage() { return ( -
- - {subscription?.status === "active" && ( - - )} - {subscription - ? subscription.status.charAt(0).toUpperCase() + - subscription.status.slice(1) - : t("billingFreeTier")} - - - {t("billingPricingCalculatorLink")} - - -
- - {usageTypes.some((type) => { - const { usage, limit } = getUsageItemAndLimit( - usageData, - subscriptionItems, - limitsData, - type.id - ); - return isOverLimit(usage, limit, type); - }) && ( - - - - {t("billingWarningOverLimit")} + {/* Subscription Status Alert */} + {isProblematicState && statusMessage && ( + + + {statusMessage.title} + + {statusMessage.description}{" "} + )} + {/* Your Plan Section */} - {t("billingUsageLimitsOverview")} + {t("billingYourPlan") || "Your Plan"} - {t("billingMonitorUsage")} + {t("billingViewOrModifyPlan") || + "View or modify your current plan"} -
- {usageTypes.map((type) => { - const { usage, limit } = getUsageItemAndLimit( - usageData, - subscriptionItems, - limitsData, - type.id - ); - const displayUsage = type.getDisplay(usage); - const usageForPricing = type.getLimitUsage(usage); - const overLimit = isOverLimit(usage, limit, type); - const percentage = limit - ? Math.min( - (usageForPricing / limit.value) * 100, - 100 - ) - : 0; + {/* Plan Cards Grid */} +
+ {planOptions.map((plan) => { + const isCurrentPlan = plan.id === currentPlanId; + const planAction = getPlanAction(plan); return ( -
-
-
- {type.icon} - - {type.label} - - +
+
+
+ {plan.name}
-
- - {displayUsage} {type.unit} +
+ + {plan.price} - {limit && ( - - {" "} - /{" "} - {type.getLimitDisplay( - limit - )}{" "} - {type.unit} + {plan.priceDetail && ( + + {plan.priceDetail} )}
- {type.note && ( -
- {type.note} -
- )} - {limit && ( - 80 - ? "warning" - : "success" - } - /> - )} - {!limit && ( -

- {t("billingNoLimitConfigured")} -

- )} +
+ {isProblematicState && + planAction.disabled && + !isCurrentPlan && + plan.id !== "enterprise" ? ( + + +
+ +
+
+ +

+ {t( + "billingResolvePaymentIssue" + ) || + "Please resolve your payment issue before upgrading or downgrading"} +

+
+
+ ) : ( + + )} +
); })}
+ + + + + - {(hasSubscription || - (!hasSubscription && limitsData.length > 0)) && ( + {/* Usage and Limits Section */} + + + + {t("billingUsageAndLimits") || "Usage and Limits"} + + + {t("billingViewUsageAndLimits") || + "View your plan's limits and current usage"} + + + +
+ {/* Current Usage */} +
+
+ {t("billingCurrentUsage") || "Current Usage"} +
+
+ + {getUserCount()} + + + {t("billingUsers") || "Users"} + + {hasSubscription && getPricePerUser() > 0 && ( +
+ x ${getPricePerUser()} / month = $ + {getUserCount() * getPricePerUser()} / + month +
+ )} +
+
+ + {/* Maximum Limits */} +
+
+ {t("billingMaximumLimits") || "Maximum Limits"} +
+ + + + {t("billingUsers") || "Users"} + + + {isOverLimit(USERS) ? ( + + + + + {getLimitValue(USERS) ?? + t( + "billingUnlimited" + ) ?? + "∞"}{" "} + {getLimitValue( + USERS + ) !== null && "users"} + + + +

+ {t( + "billingUsageExceedsLimit", + { + current: + getUsageValue( + USERS + ), + limit: + getLimitValue( + USERS + ) ?? 0 + } + ) || + `Current usage (${getUsageValue(USERS)}) exceeds limit (${getLimitValue(USERS)})`} +

+
+
+ ) : ( + <> + {getLimitValue(USERS) ?? + t("billingUnlimited") ?? + "∞"}{" "} + {getLimitValue(USERS) !== + null && "users"} + + )} +
+
+ + + {t("billingSites") || "Sites"} + + + {isOverLimit(SITES) ? ( + + + + + {getLimitValue(SITES) ?? + t( + "billingUnlimited" + ) ?? + "∞"}{" "} + {getLimitValue( + SITES + ) !== null && "sites"} + + + +

+ {t( + "billingUsageExceedsLimit", + { + current: + getUsageValue( + SITES + ), + limit: + getLimitValue( + SITES + ) ?? 0 + } + ) || + `Current usage (${getUsageValue(SITES)}) exceeds limit (${getLimitValue(SITES)})`} +

+
+
+ ) : ( + <> + {getLimitValue(SITES) ?? + t("billingUnlimited") ?? + "∞"}{" "} + {getLimitValue(SITES) !== + null && "sites"} + + )} +
+
+ + + {t("billingDomains") || "Domains"} + + + {isOverLimit(DOMAINS) ? ( + + + + + {getLimitValue( + DOMAINS + ) ?? + t( + "billingUnlimited" + ) ?? + "∞"}{" "} + {getLimitValue( + DOMAINS + ) !== null && "domains"} + + + +

+ {t( + "billingUsageExceedsLimit", + { + current: + getUsageValue( + DOMAINS + ), + limit: + getLimitValue( + DOMAINS + ) ?? 0 + } + ) || + `Current usage (${getUsageValue(DOMAINS)}) exceeds limit (${getLimitValue(DOMAINS)})`} +

+
+
+ ) : ( + <> + {getLimitValue(DOMAINS) ?? + t("billingUnlimited") ?? + "∞"}{" "} + {getLimitValue(DOMAINS) !== + null && "domains"} + + )} +
+
+ + + {t("billingOrganizations") || + "Organizations"} + + + {isOverLimit(ORGINIZATIONS) ? ( + + + + + {getLimitValue( + ORGINIZATIONS + ) ?? + t( + "billingUnlimited" + ) ?? + "∞"}{" "} + {getLimitValue( + ORGINIZATIONS + ) !== null && "orgs"} + + + +

+ {t( + "billingUsageExceedsLimit", + { + current: + getUsageValue( + ORGINIZATIONS + ), + limit: + getLimitValue( + ORGINIZATIONS + ) ?? 0 + } + ) || + `Current usage (${getUsageValue(ORGINIZATIONS)}) exceeds limit (${getLimitValue(ORGINIZATIONS)})`} +

+
+
+ ) : ( + <> + {getLimitValue(ORGINIZATIONS) ?? + t("billingUnlimited") ?? + "∞"}{" "} + {getLimitValue( + ORGINIZATIONS + ) !== null && "orgs"} + + )} +
+
+ + + {t("billingRemoteNodes") || + "Remote Nodes"} + + + {isOverLimit(REMOTE_EXIT_NODES) ? ( + + + + + {getLimitValue( + REMOTE_EXIT_NODES + ) ?? + t( + "billingUnlimited" + ) ?? + "∞"}{" "} + {getLimitValue( + REMOTE_EXIT_NODES + ) !== null && "nodes"} + + + +

+ {t( + "billingUsageExceedsLimit", + { + current: + getUsageValue( + REMOTE_EXIT_NODES + ), + limit: + getLimitValue( + REMOTE_EXIT_NODES + ) ?? 0 + } + ) || + `Current usage (${getUsageValue(REMOTE_EXIT_NODES)}) exceeds limit (${getLimitValue(REMOTE_EXIT_NODES)})`} +

+
+
+ ) : ( + <> + {getLimitValue( + REMOTE_EXIT_NODES + ) ?? + t("billingUnlimited") ?? + "∞"}{" "} + {getLimitValue( + REMOTE_EXIT_NODES + ) !== null && "nodes"} + + )} +
+
+
+
+
+
+
+ + {/* Paid License Keys Section */} + {(licenseSubscription || getLicenseKeyCount() > 0) && ( - {t("billingIncludedUsage")} + {t("billingPaidLicenseKeys") || "Paid License Keys"} - {hasSubscription - ? t("billingIncludedUsageDescription") - : t("billingFreeTierIncludedUsage")} + {t("billingManageLicenseSubscription") || + "Manage your subscription for paid self-hosted license keys"} -
- {usageTypes.map((type) => { - const { item, limit } = getUsageItemAndLimit( - usageData, - subscriptionItems, - limitsData, - type.id - ); +
+
+
+
+ {t("billingCurrentKeys") || + "Current Keys"} +
+
+ + {getLicenseKeyCount()} + + + {getLicenseKeyCount() === 1 + ? "key" + : "keys"} + +
+
+ +
+
+ + + )} - // For subscribed users, show included usage from tiers - // For free users, show the limit as "included" - let includedAmount = 0; - let displayIncluded = "0"; + {/* Tier Change Confirmation Dialog */} + + + + + {pendingTier?.action === "upgrade" + ? t("billingConfirmUpgrade") || + "Confirm Upgrade" + : t("billingConfirmDowngrade") || + "Confirm Downgrade"} + + + {pendingTier?.action === "upgrade" + ? t("billingConfirmUpgradeDescription") || + `You are about to upgrade to the ${pendingTier?.planName} plan.` + : t("billingConfirmDowngradeDescription") || + `You are about to downgrade to the ${pendingTier?.planName} plan.`} + + + + {pendingTier && pendingTier.tier && ( +
+
+
+ {pendingTier.planName} +
+
+ + {pendingTier.price} + +
+
- if (hasSubscription && item) { - includedAmount = getIncludedUsage( - item.tiers + {/* Features with check marks */} + {(() => { + const plan = planOptions.find( + (p) => + p.tierType === pendingTier.tier || + (pendingTier.tier === "basic" && + p.id === "basic") ); - displayIncluded = getIncludedUsageDisplay( - includedAmount, - type - ); - } else if ( - !hasSubscription && - limit && - limit.value > 0 - ) { - // Show free tier limits as "included" - includedAmount = limit.value; - displayIncluded = - type.getLimitDisplay(limit); - } - - if (includedAmount === 0) return null; - - return ( -
-
- {type.icon} - - {type.label} - -
-
-
- {hasSubscription ? ( - - ) : ( - + return plan?.features?.length ? ( +
+

+ {"What's included:"} +

+
+ {plan.features.map( + (feature, i) => ( +
+ + + {feature} + +
+ ) )} - - {displayIncluded}{" "} - {type.unit} +
+
+ ) : null; + })()} + + {/* Limits without check marks */} + {tierLimits[pendingTier.tier] && ( +
+

+ {"Up to:"} +

+
+
+ + + { + tierLimits[ + pendingTier.tier + ].users + }{" "} + {t("billingUsers") || + "Users"}
-
- {hasSubscription - ? t("billingIncluded") - : t("billingFreeTier")} +
+ + + { + tierLimits[ + pendingTier.tier + ].sites + }{" "} + {t("billingSites") || + "Sites"} + +
+
+ + + { + tierLimits[ + pendingTier.tier + ].domains + }{" "} + {t("billingDomains") || + "Domains"} + +
+
+ + + { + tierLimits[ + pendingTier.tier + ].organizations + }{" "} + {t( + "billingOrganizations" + ) || "Organizations"} + +
+
+ + + { + tierLimits[ + pendingTier.tier + ].remoteNodes + }{" "} + {t("billingRemoteNodes") || + "Remote Nodes"} +
- ); - })} -
- - - )} + )} - {hasSubscription && ( - - - - {t("billingEstimatedPeriod")} - - - -
-
- {usageTypes.map((type) => { - const { usage, item } = - getUsageItemAndLimit( - usageData, - subscriptionItems, - limitsData, - type.id + {/* Warning for limit violations when downgrading */} + {pendingTier.action === "downgrade" && + (() => { + const violations = checkLimitViolations( + pendingTier.tier ); - const displayPrice = getDisplayPrice( - item?.tiers - ); - return ( -
- {type.label}: - - {type.getUsage(usage)}{" "} - {type.unitRaw || type.unit} x{" "} - {displayPrice} - -
- ); - })} - {/* Show recurring charges (items with unitAmount but no tiers/meterId) */} - {subscriptionItems - .filter( - (item) => - item.unitAmount && - item.unitAmount > 0 && - !item.tiers && - !item.meterId - ) - .map((item, index) => ( -
- - {item.name || - t("billingRecurringCharge")} - : - - - $ - {( - (item.unitAmount || 0) / 100 - ).toFixed(2)} - -
- ))} - -
- {t("billingEstimatedTotal")} - - $ - {( - usageTypes.reduce((sum, type) => { - const { usage, item } = - getUsageItemAndLimit( - usageData, - subscriptionItems, - limitsData, - type.id - ); - const usageForPricing = - type.getUsage(usage); - const cost = item - ? calculateTieredPrice( - usageForPricing, - item.tiers - ) - : 0; - return sum + cost; - }, 0) + - // Add recurring charges - subscriptionItems - .filter( - (item) => - item.unitAmount && - item.unitAmount > 0 && - !item.tiers && - !item.meterId - ) - .reduce( - (sum, item) => - sum + - (item.unitAmount || 0) / - 100, - 0 - ) - ).toFixed(2)} - -
-
-
-

- {t("billingNotes")} -

-
-

{t("billingEstimateNote")}

-

{t("billingActualChargesMayVary")}

-

{t("billingBilledAtEnd")}

-
-
-
+ if (violations.length > 0) { + return ( + + + + {t( + "billingLimitViolationWarning" + ) || + "Usage Exceeds New Plan Limits"} + + +

+ {t( + "billingLimitViolationDescription" + ) || + "Your current usage exceeds the limits of this plan. The following features will be disabled until you reduce usage:"} +

+
    + {violations.map( + ( + violation, + index + ) => ( +
  • + + { + violation.feature + } + : + + + Currently + using{" "} + { + violation.currentUsage + } + , + new + limit + is{" "} + { + violation.newLimit + } + +
  • + ) + )} +
+
+
+ ); + } + return null; + })()} - -
+ )} + + + + - - - - )} - - {!hasSubscription && ( - - -
- -

- {t("billingNoActiveSubscription")} -

- -
-
-
- )} +
+ +
+ + ); } diff --git a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx index 73c6a3cf9..37334e342 100644 --- a/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/[idpId]/general/page.tsx @@ -31,7 +31,6 @@ import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useState, useEffect } from "react"; -import { SwitchInput } from "@app/components/SwitchInput"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon, ExternalLink } from "lucide-react"; import { @@ -41,12 +40,22 @@ import { InfoSectionTitle } from "@app/components/InfoSection"; import CopyToClipboard from "@app/components/CopyToClipboard"; -import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import IdpTypeBadge from "@app/components/IdpTypeBadge"; import { useTranslations } from "next-intl"; import { AxiosResponse } from "axios"; import { ListRolesResponse } from "@server/routers/role"; -import AutoProvisionConfigWidget from "@app/components/private/AutoProvisionConfigWidget"; +import AutoProvisionConfigWidget from "@app/components/AutoProvisionConfigWidget"; +import IdpAutoProvisionUsersDescription from "@app/components/IdpAutoProvisionUsersDescription"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + compileRoleMappingExpression, + createMappingBuilderRule, + detectRoleMappingConfig, + ensureMappingBuilderRuleIds, + MappingBuilderRule, + RoleMappingMode +} from "@app/lib/idpRoleMapping"; export default function GeneralPage() { const { env } = useEnvContext(); @@ -56,12 +65,18 @@ export default function GeneralPage() { const [loading, setLoading] = useState(false); const [initialLoading, setInitialLoading] = useState(true); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); - const [roleMappingMode, setRoleMappingMode] = useState< - "role" | "expression" - >("role"); + const [roleMappingMode, setRoleMappingMode] = + useState("fixedRoles"); + const [fixedRoleNames, setFixedRoleNames] = useState([]); + const [mappingBuilderClaimPath, setMappingBuilderClaimPath] = + useState("groups"); + const [mappingBuilderRules, setMappingBuilderRules] = useState< + MappingBuilderRule[] + >([createMappingBuilderRule()]); + const [rawRoleExpression, setRawRoleExpression] = useState(""); const [variant, setVariant] = useState<"oidc" | "google" | "azure">("oidc"); - const { isUnlocked } = useLicenseStatusContext(); + const dashboardRedirectUrl = `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback`; const [redirectUrl, setRedirectUrl] = useState( `${env.app.dashboardUrl}/auth/idp/${idpId}/oidc/callback` ); @@ -190,34 +205,8 @@ export default function GeneralPage() { // Set the variant setVariant(idpVariant as "oidc" | "google" | "azure"); - // Check if roleMapping matches the basic pattern '{role name}' (simple single role) - // This should NOT match complex expressions like 'Admin' || 'Member' - const isBasicRolePattern = - roleMapping && - typeof roleMapping === "string" && - /^'[^']+'$/.test(roleMapping); - - // Determine if roleMapping is a number (roleId) or matches basic pattern - const isRoleId = - !isNaN(Number(roleMapping)) && roleMapping !== ""; - const isRoleName = isBasicRolePattern; - - // Extract role name from basic pattern for matching - let extractedRoleName = null; - if (isRoleName) { - extractedRoleName = roleMapping.slice(1, -1); // Remove quotes - } - - // Try to find matching role by name if we have a basic pattern - let matchingRoleId = undefined; - if (extractedRoleName && availableRoles.length > 0) { - const matchingRole = availableRoles.find( - (role) => role.name === extractedRoleName - ); - if (matchingRole) { - matchingRoleId = matchingRole.roleId; - } - } + const detectedRoleMappingConfig = + detectRoleMappingConfig(roleMapping); // Extract tenant ID from Azure URLs if present let tenantId = ""; @@ -238,9 +227,7 @@ export default function GeneralPage() { clientSecret: data.idpOidcConfig.clientSecret, autoProvision: data.idp.autoProvision, roleMapping: roleMapping || null, - roleId: isRoleId - ? Number(roleMapping) - : matchingRoleId || null + roleId: null }; // Add variant-specific fields @@ -259,10 +246,18 @@ export default function GeneralPage() { form.reset(formData); - // Set the role mapping mode based on the data - // Default to "expression" unless it's a simple roleId or basic '{role name}' pattern - setRoleMappingMode( - matchingRoleId && isRoleName ? "role" : "expression" + setRoleMappingMode(detectedRoleMappingConfig.mode); + setFixedRoleNames(detectedRoleMappingConfig.fixedRoleNames); + setMappingBuilderClaimPath( + detectedRoleMappingConfig.mappingBuilder.claimPath + ); + setMappingBuilderRules( + ensureMappingBuilderRuleIds( + detectedRoleMappingConfig.mappingBuilder.rules + ) + ); + setRawRoleExpression( + detectedRoleMappingConfig.rawExpression ); } } catch (e) { @@ -327,7 +322,26 @@ export default function GeneralPage() { return; } - const roleName = roles.find((r) => r.roleId === data.roleId)?.name; + const roleMappingExpression = compileRoleMappingExpression({ + mode: roleMappingMode, + fixedRoleNames, + mappingBuilder: { + claimPath: mappingBuilderClaimPath, + rules: mappingBuilderRules + }, + rawExpression: rawRoleExpression + }); + + if (data.autoProvision && !roleMappingExpression) { + toast({ + title: t("error"), + description: + "A role mapping is required when auto-provisioning is enabled.", + variant: "destructive" + }); + setLoading(false); + return; + } // Build payload based on variant let payload: any = { @@ -335,10 +349,7 @@ export default function GeneralPage() { clientId: data.clientId, clientSecret: data.clientSecret, autoProvision: data.autoProvision, - roleMapping: - roleMappingMode === "role" - ? `'${roleName}'` - : data.roleMapping || "" + roleMapping: roleMappingExpression }; // Add variant-specific fields @@ -423,24 +434,21 @@ export default function GeneralPage() { - {t("redirectUrl")} + {t("orgIdpRedirectUrls")} + {redirectUrl !== dashboardRedirectUrl && ( + + + + )} - - - - {t("redirectUrlAbout")} - - - {t("redirectUrlAboutDescription")} - - - {/* IDP Type Indicator */}
@@ -486,42 +494,47 @@ export default function GeneralPage() { {t("idpAutoProvisionUsers")} - {t("idpAutoProvisionUsersDescription")} + - -
- - { - form.setValue( - "autoProvision", - checked - ); - }} - roleMappingMode={roleMappingMode} - onRoleMappingModeChange={(data) => { - setRoleMappingMode(data); - // Clear roleId and roleMapping when mode changes - form.setValue("roleId", null); - form.setValue("roleMapping", null); - }} - roles={roles} - roleIdFieldName="roleId" - roleMappingFieldName="roleMapping" - /> - - -
+ + +
+ + { + form.setValue("autoProvision", checked); + }} + roleMappingMode={roleMappingMode} + onRoleMappingModeChange={(data) => { + setRoleMappingMode(data); + }} + roles={roles} + fixedRoleNames={fixedRoleNames} + onFixedRoleNamesChange={setFixedRoleNames} + mappingBuilderClaimPath={ + mappingBuilderClaimPath + } + onMappingBuilderClaimPathChange={ + setMappingBuilderClaimPath + } + mappingBuilderRules={mappingBuilderRules} + onMappingBuilderRulesChange={ + setMappingBuilderRules + } + rawExpression={rawRoleExpression} + onRawExpressionChange={setRawRoleExpression} + /> + +
@@ -821,29 +834,6 @@ export default function GeneralPage() { className="space-y-4" id="general-settings-form" > - - - - {t("idpJmespathAbout")} - - - {t( - "idpJmespathAboutDescription" - )}{" "} - - {t( - "idpJmespathAboutDescriptionLink" - )}{" "} - - - - - ([]); - const [roleMappingMode, setRoleMappingMode] = useState< - "role" | "expression" - >("role"); - const { isUnlocked } = useLicenseStatusContext(); + const [roleMappingMode, setRoleMappingMode] = + useState("fixedRoles"); + const [fixedRoleNames, setFixedRoleNames] = useState([]); + const [mappingBuilderClaimPath, setMappingBuilderClaimPath] = + useState("groups"); + const [mappingBuilderRules, setMappingBuilderRules] = useState< + MappingBuilderRule[] + >([createMappingBuilderRule()]); + const [rawRoleExpression, setRawRoleExpression] = useState(""); const t = useTranslations(); + const { isPaidUser } = usePaidStatus(); const params = useParams(); @@ -84,49 +96,6 @@ export default function Page() { type CreateIdpFormValues = z.infer; - interface ProviderTypeOption { - id: "oidc" | "google" | "azure"; - title: string; - description: string; - icon?: React.ReactNode; - } - - const providerTypes: ReadonlyArray = [ - { - id: "oidc", - title: "OAuth2/OIDC", - description: t("idpOidcDescription") - }, - { - id: "google", - title: t("idpGoogleTitle"), - description: t("idpGoogleDescription"), - icon: ( - {t("idpGoogleAlt")} - ) - }, - { - id: "azure", - title: t("idpAzureTitle"), - description: t("idpAzureDescription"), - icon: ( - {t("idpAzureAlt")} - ) - } - ]; - const form = useForm({ resolver: zodResolver(createIdpFormSchema), defaultValues: { @@ -174,47 +143,6 @@ export default function Page() { fetchRoles(); }, []); - // Handle provider type changes and set defaults - const handleProviderChange = (value: "oidc" | "google" | "azure") => { - form.setValue("type", value); - - if (value === "google") { - // Set Google defaults - form.setValue( - "authUrl", - "https://accounts.google.com/o/oauth2/v2/auth" - ); - form.setValue("tokenUrl", "https://oauth2.googleapis.com/token"); - form.setValue("identifierPath", "email"); - form.setValue("emailPath", "email"); - form.setValue("namePath", "name"); - form.setValue("scopes", "openid profile email"); - } else if (value === "azure") { - // Set Azure Entra ID defaults (URLs will be constructed dynamically) - form.setValue( - "authUrl", - "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/authorize" - ); - form.setValue( - "tokenUrl", - "https://login.microsoftonline.com/{{TENANT_ID}}/oauth2/v2.0/token" - ); - form.setValue("identifierPath", "email"); - form.setValue("emailPath", "email"); - form.setValue("namePath", "name"); - form.setValue("scopes", "openid profile email"); - form.setValue("tenantId", ""); - } else { - // Reset to OIDC defaults - form.setValue("authUrl", ""); - form.setValue("tokenUrl", ""); - form.setValue("identifierPath", "sub"); - form.setValue("namePath", "name"); - form.setValue("emailPath", "email"); - form.setValue("scopes", "openid profile email"); - } - }; - async function onSubmit(data: CreateIdpFormValues) { setCreateLoading(true); @@ -228,7 +156,26 @@ export default function Page() { tokenUrl = tokenUrl?.replace("{{TENANT_ID}}", data.tenantId); } - const roleName = roles.find((r) => r.roleId === data.roleId)?.name; + const roleMappingExpression = compileRoleMappingExpression({ + mode: roleMappingMode, + fixedRoleNames, + mappingBuilder: { + claimPath: mappingBuilderClaimPath, + rules: mappingBuilderRules + }, + rawExpression: rawRoleExpression + }); + + if (data.autoProvision && !roleMappingExpression) { + toast({ + title: t("error"), + description: + "A role mapping is required when auto-provisioning is enabled.", + variant: "destructive" + }); + setCreateLoading(false); + return; + } const payload = { name: data.name, @@ -240,10 +187,7 @@ export default function Page() { emailPath: data.emailPath, namePath: data.namePath, autoProvision: data.autoProvision, - roleMapping: - roleMappingMode === "role" - ? `'${roleName}'` - : data.roleMapping || "", + roleMapping: roleMappingExpression, scopes: data.scopes, variant: data.type }; @@ -275,6 +219,8 @@ export default function Page() { } } + const disabled = !isPaidUser(tierMatrix.orgOidc); + return ( <>
@@ -285,13 +231,16 @@ export default function Page() {
+ + +
@@ -303,6 +252,13 @@ export default function Page() { + { + applyOidcIdpProviderType(form.setValue, next); + }} + /> +
- - - - {t("idpType")} - - - {t("idpTypeDescription")} - - - - { - handleProviderChange( - value as "oidc" | "google" | "azure" - ); - }} - cols={3} - /> - - - {/* Auto Provision Settings */} @@ -364,44 +297,48 @@ export default function Page() { {t("idpAutoProvisionUsers")} - {t("idpAutoProvisionUsersDescription")} + - - - - { - form.setValue( - "autoProvision", - checked - ); - }} - roleMappingMode={roleMappingMode} - onRoleMappingModeChange={(data) => { - setRoleMappingMode(data); - // Clear roleId and roleMapping when mode changes - form.setValue("roleId", null); - form.setValue("roleMapping", null); - }} - roles={roles} - roleIdFieldName="roleId" - roleMappingFieldName="roleMapping" - /> - - - + +
+ + { + form.setValue("autoProvision", checked); + }} + roleMappingMode={roleMappingMode} + onRoleMappingModeChange={(data) => { + setRoleMappingMode(data); + }} + roles={roles} + fixedRoleNames={fixedRoleNames} + onFixedRoleNamesChange={setFixedRoleNames} + mappingBuilderClaimPath={ + mappingBuilderClaimPath + } + onMappingBuilderClaimPathChange={ + setMappingBuilderClaimPath + } + mappingBuilderRules={mappingBuilderRules} + onMappingBuilderRulesChange={ + setMappingBuilderRules + } + rawExpression={rawRoleExpression} + onRawExpressionChange={setRawRoleExpression} + /> + +
@@ -676,16 +613,6 @@ export default function Page() { /> - - - - - {t("idpOidcConfigureAlert")} - - - {t("idpOidcConfigureAlertDescription")} - -
@@ -705,29 +632,6 @@ export default function Page() { id="create-idp-form" onSubmit={form.handleSubmit(onSubmit)} > - - - - {t("idpJmespathAbout")} - - - {t( - "idpJmespathAboutDescription" - )}{" "} - - {t( - "idpJmespathAboutDescriptionLink" - )}{" "} - - - - -
+ ); } diff --git a/src/app/[orgId]/settings/(private)/idp/layout.tsx b/src/app/[orgId]/settings/(private)/idp/layout.tsx new file mode 100644 index 000000000..ead085b2c --- /dev/null +++ b/src/app/[orgId]/settings/(private)/idp/layout.tsx @@ -0,0 +1,8 @@ +interface LayoutProps { + children: React.ReactNode; + params: Promise<{}>; +} + +export default async function Layout(props: LayoutProps) { + return props.children; +} diff --git a/src/app/[orgId]/settings/(private)/idp/page.tsx b/src/app/[orgId]/settings/(private)/idp/page.tsx index 01845b18f..cd0bc5566 100644 --- a/src/app/[orgId]/settings/(private)/idp/page.tsx +++ b/src/app/[orgId]/settings/(private)/idp/page.tsx @@ -1,17 +1,12 @@ -import { internal, priv } from "@app/lib/api"; +import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import IdpTable, { IdpRow } from "@app/components/private/OrgIdpTable"; +import IdpTable, { IdpRow } from "@app/components/OrgIdpTable"; import { getTranslations } from "next-intl/server"; -import { Alert, AlertDescription } from "@app/components/ui/alert"; -import { cache } from "react"; -import { - GetOrgSubscriptionResponse, - GetOrgTierResponse -} from "@server/routers/billing/types"; -import { TierId } from "@server/lib/billing/tiers"; -import { build } from "@server/build"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { IdpGlobalModeBanner } from "@app/components/IdpGlobalModeBanner"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; type OrgIdpPageProps = { params: Promise<{ orgId: string }>; @@ -35,21 +30,6 @@ export default async function OrgIdpPage(props: OrgIdpPageProps) { const t = await getTranslations(); - let subscriptionStatus: GetOrgTierResponse | null = null; - try { - const getSubscription = cache(() => - priv.get>( - `/org/${params.orgId}/billing/tier` - ) - ); - const subRes = await getSubscription(); - subscriptionStatus = subRes.data.data; - } catch {} - const subscribed = - build === "enterprise" - ? true - : subscriptionStatus?.tier === TierId.STANDARD; - return ( <> - {build === "saas" && !subscribed ? ( - - - {t("idpDisabled")} {t("subscriptionRequiredToUse")} - - - ) : null} + + + diff --git a/src/app/[orgId]/settings/(private)/license/layout.tsx b/src/app/[orgId]/settings/(private)/license/layout.tsx index 9083bb812..453b33722 100644 --- a/src/app/[orgId]/settings/(private)/license/layout.tsx +++ b/src/app/[orgId]/settings/(private)/license/layout.tsx @@ -4,6 +4,8 @@ import { redirect } from "next/navigation"; import { cache } from "react"; import { getTranslations } from "next-intl/server"; import { build } from "@server/build"; +import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; type LicensesSettingsProps = { children: React.ReactNode; @@ -27,6 +29,26 @@ export default async function LicensesSetingsLayoutProps({ redirect(`/`); } + let orgUser = null; + try { + const res = await getCachedOrgUser(orgId, user.userId); + orgUser = res.data.data; + } catch { + redirect(`/${orgId}`); + } + + let org = null; + try { + const res = await getCachedOrg(orgId); + org = res.data.data; + } catch { + redirect(`/${orgId}`); + } + + if (!org?.org?.isBillingOrg || !orgUser?.isOwner) { + redirect(`/${orgId}`); + } + const t = await getTranslations(); return ( diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx index 4f66b7f83..2f24b15cf 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/credentials/page.tsx @@ -23,10 +23,6 @@ import { } from "@server/routers/remoteExitNode/types"; import { useRemoteExitNodeContext } from "@app/hooks/useRemoteExitNodeContext"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; -import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; -import { build } from "@server/build"; -import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert"; import { InfoSection, InfoSectionContent, @@ -36,6 +32,9 @@ import { import CopyToClipboard from "@app/components/CopyToClipboard"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon } from "lucide-react"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; export default function CredentialsPage() { const { env } = useEnvContext(); @@ -45,6 +44,8 @@ export default function CredentialsPage() { const t = useTranslations(); const { remoteExitNode } = useRemoteExitNodeContext(); + const { isPaidUser } = usePaidStatus(); + const [modalOpen, setModalOpen] = useState(false); const [credentials, setCredentials] = useState(null); @@ -57,16 +58,6 @@ export default function CredentialsPage() { const [showCredentialsAlert, setShowCredentialsAlert] = useState(false); const [shouldDisconnect, setShouldDisconnect] = useState(true); - const { licenseStatus, isUnlocked } = useLicenseStatusContext(); - const subscription = useSubscriptionStatusContext(); - - const isSecurityFeatureDisabled = () => { - const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked(); - const isSaasNotSubscribed = - build === "saas" && !subscription?.isSubscribed(); - return isEnterpriseNotLicensed || isSaasNotSubscribed; - }; - const handleConfirmRegenerate = async () => { try { const response = await api.get< @@ -131,19 +122,21 @@ export default function CredentialsPage() { - {t("generatedcredentials")} + {t("credentials")} - {t("regenerateCredentials")} + {t("remoteNodeCredentialsDescription")} - + - {t("endpoint") || "Endpoint"} + {t("endpoint")} - {t("remoteExitNodeId") || - "Remote Exit Node ID"} + {t("remoteExitNodeId")} {displayRemoteExitNodeId ? ( @@ -168,7 +160,7 @@ export default function CredentialsPage() { - {t("secretKey") || "Secret Key"} + {t("remoteExitNodeSecretKey")} {displaySecret ? ( @@ -196,7 +188,7 @@ export default function CredentialsPage() { )} - {build !== "oss" && ( + {!env.flags.disableEnterpriseFeatures && ( @@ -213,7 +207,9 @@ export default function CredentialsPage() { setShouldDisconnect(true); setModalOpen(true); }} - disabled={isSecurityFeatureDisabled()} + disabled={ + !isPaidUser(tierMatrix.rotateCredentials) + } > {t("remoteExitNodeRegenerateAndDisconnect")} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx index 98af49a68..ec1dea837 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/[remoteExitNodeId]/layout.tsx @@ -43,7 +43,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { return ( <> diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx index 062b7e9ac..0e68c3791 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/create/page.tsx @@ -319,19 +319,6 @@ export default function CreateRemoteExitNodePage() { id: "${defaults?.remoteExitNodeId}" secret: "${defaults?.secret}"`} /> - - - - {t( - "remoteExitNodeCreate.generate.saveCredentialsTitle" - )} - - - {t( - "remoteExitNodeCreate.generate.saveCredentialsDescription" - )} - - )} diff --git a/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx b/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx index 632dc0ad8..2da0e0da5 100644 --- a/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx +++ b/src/app/[orgId]/settings/(private)/remote-exit-nodes/page.tsx @@ -2,7 +2,9 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { ListRemoteExitNodesResponse } from "@server/routers/remoteExitNode/types"; import { AxiosResponse } from "axios"; -import ExitNodesTable, { RemoteExitNodeRow } from "./ExitNodesTable"; +import ExitNodesTable, { + RemoteExitNodeRow +} from "@app/components/ExitNodesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; diff --git a/src/app/[orgId]/settings/access/invitations/page.tsx b/src/app/[orgId]/settings/access/invitations/page.tsx index b6ee14484..00cb0ffc8 100644 --- a/src/app/[orgId]/settings/access/invitations/page.tsx +++ b/src/app/[orgId]/settings/access/invitations/page.tsx @@ -29,9 +29,8 @@ export default async function InvitationsPage(props: InvitationsPageProps) { let invitations: { inviteId: string; email: string; - expiresAt: string; - roleId: number; - roleName?: string; + expiresAt: number; + roles: { roleId: number; roleName: string | null }[]; }[] = []; let hasInvitations = false; @@ -66,12 +65,15 @@ export default async function InvitationsPage(props: InvitationsPageProps) { } const invitationRows: InvitationRow[] = invitations.map((invite) => { + const names = invite.roles + .map((r) => r.roleName || t("accessRoleUnknown")) + .filter(Boolean); return { id: invite.inviteId, email: invite.email, expiresAt: new Date(Number(invite.expiresAt)).toISOString(), - role: invite.roleName || t("accessRoleUnknown"), - roleId: invite.roleId + roleLabels: names, + roleIds: invite.roles.map((r) => r.roleId) }; }); diff --git a/src/app/[orgId]/settings/access/roles/page.tsx b/src/app/[orgId]/settings/access/roles/page.tsx index c4818abe7..7165d9e6c 100644 --- a/src/app/[orgId]/settings/access/roles/page.tsx +++ b/src/app/[orgId]/settings/access/roles/page.tsx @@ -2,12 +2,12 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { AxiosResponse } from "axios"; import { GetOrgResponse } from "@server/routers/org"; -import { cache } from "react"; import OrgProvider from "@app/providers/OrgProvider"; import { ListRolesResponse } from "@server/routers/role"; -import RolesTable, { RoleRow } from "../../../../../components/RolesTable"; +import RolesTable, { type RoleRow } from "@app/components/RolesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { getTranslations } from "next-intl/server"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; type RolesPageProps = { params: Promise<{ orgId: string }>; @@ -47,14 +47,7 @@ export default async function RolesPage(props: RolesPageProps) { } let org: GetOrgResponse | null = null; - const getOrg = cache(async () => - internal - .get< - AxiosResponse - >(`/org/${params.orgId}`, await authCookieHeader()) - .catch((e) => {}) - ); - const orgRes = await getOrg(); + const orgRes = await getCachedOrg(params.orgId); if (orgRes && orgRes.status === 200) { org = orgRes.data.data; diff --git a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx index 6313d512a..9ab9e93fa 100644 --- a/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx +++ b/src/app/[orgId]/settings/access/users/[userId]/access-controls/page.tsx @@ -8,18 +8,10 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { Input } from "@app/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; import { Checkbox } from "@app/components/ui/checkbox"; +import OrgRolesTagField from "@app/components/OrgRolesTagField"; import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; -import { InviteUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; @@ -44,34 +36,69 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; import IdpTypeBadge from "@app/components/IdpTypeBadge"; import { UserType } from "@server/types/UserTypes"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { build } from "@server/build"; + +const accessControlsFormSchema = z.object({ + username: z.string(), + autoProvisioned: z.boolean(), + roles: z.array( + z.object({ + id: z.string(), + text: z.string() + }) + ) +}); export default function AccessControlsPage() { - const { orgUser: user } = userOrgUserContext(); + const { orgUser: user, updateOrgUser } = userOrgUserContext(); + const { env } = useEnvContext(); - const api = createApiClient(useEnvContext()); + const api = createApiClient({ env }); const { orgId } = useParams(); const [loading, setLoading] = useState(false); const [roles, setRoles] = useState<{ roleId: number; name: string }[]>([]); + const [activeRoleTagIndex, setActiveRoleTagIndex] = useState( + null + ); const t = useTranslations(); - - const formSchema = z.object({ - username: z.string(), - roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }), - autoProvisioned: z.boolean() - }); + const { isPaidUser } = usePaidStatus(); + const isPaid = isPaidUser(tierMatrix.fullRbac); + const supportsMultipleRolesPerUser = isPaid; + const showMultiRolePaywallMessage = + !env.flags.disableEnterpriseFeatures && + ((build === "saas" && !isPaid) || + (build === "enterprise" && !isPaid) || + (build === "oss" && !isPaid)); const form = useForm({ - resolver: zodResolver(formSchema), + resolver: zodResolver(accessControlsFormSchema), defaultValues: { username: user.username!, - roleId: user.roleId?.toString(), - autoProvisioned: user.autoProvisioned || false + autoProvisioned: user.autoProvisioned || false, + roles: (user.roles ?? []).map((r) => ({ + id: r.roleId.toString(), + text: r.name + })) } }); + const currentRoleIds = user.roleIds ?? []; + + useEffect(() => { + form.setValue( + "roles", + (user.roles ?? []).map((r) => ({ + id: r.roleId.toString(), + text: r.name + })) + ); + }, [user.userId, currentRoleIds.join(",")]); + useEffect(() => { async function fetchRoles() { const res = await api @@ -94,32 +121,59 @@ export default function AccessControlsPage() { } fetchRoles(); - - form.setValue("roleId", user.roleId.toString()); form.setValue("autoProvisioned", user.autoProvisioned || false); }, []); - async function onSubmit(values: z.infer) { - setLoading(true); + const allRoleOptions = roles.map((role) => ({ + id: role.roleId.toString(), + text: role.name + })); + const paywallMessage = + build === "saas" + ? t("singleRolePerUserPlanNotice") + : t("singleRolePerUserEditionNotice"); + + async function onSubmit(values: z.infer) { + if (values.roles.length === 0) { + toast({ + variant: "destructive", + title: t("accessRoleErrorAdd"), + description: t("accessRoleSelectPlease") + }); + return; + } + + setLoading(true); try { - // Execute both API calls simultaneously - const [roleRes, userRes] = await Promise.all([ - api.post>( - `/role/${values.roleId}/add/${user.userId}` - ), + const roleIds = values.roles.map((r) => parseInt(r.id, 10)); + const updateRoleRequest = supportsMultipleRolesPerUser + ? api.post(`/user/${user.userId}/org/${orgId}/roles`, { + roleIds + }) + : api.post(`/role/${roleIds[0]}/add/${user.userId}`); + + await Promise.all([ + updateRoleRequest, api.post(`/org/${orgId}/user/${user.userId}`, { autoProvisioned: values.autoProvisioned }) ]); - if (roleRes.status === 200 && userRes.status === 200) { - toast({ - variant: "default", - title: t("userSaved"), - description: t("userSavedDescription") - }); - } + updateOrgUser({ + roleIds, + roles: values.roles.map((r) => ({ + roleId: parseInt(r.id, 10), + name: r.text + })), + autoProvisioned: values.autoProvisioned + }); + + toast({ + variant: "default", + title: t("userSaved"), + description: t("userSavedDescription") + }); } catch (e) { toast({ variant: "destructive", @@ -130,7 +184,6 @@ export default function AccessControlsPage() { ) }); } - setLoading(false); } @@ -154,7 +207,6 @@ export default function AccessControlsPage() { className="space-y-4" id="access-controls-form" > - {/* IDP Type Display */} {user.type !== UserType.Internal && user.idpType && (
@@ -171,48 +223,22 @@ export default function AccessControlsPage() {
)} - ( - - {t("role")} - - - - )} + {user.idpAutoProvision && ( diff --git a/src/app/[orgId]/settings/access/users/create/page.tsx b/src/app/[orgId]/settings/access/users/create/page.tsx index 3199d817f..0263d2b72 100644 --- a/src/app/[orgId]/settings/access/users/create/page.tsx +++ b/src/app/[orgId]/settings/access/users/create/page.tsx @@ -32,7 +32,7 @@ import { } from "@app/components/ui/select"; import { toast } from "@app/hooks/useToast"; import { zodResolver } from "@hookform/resolvers/zod"; -import { InviteUserBody, InviteUserResponse } from "@server/routers/user"; +import { InviteUserResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import { useEffect } from "react"; import { useForm } from "react-hook-form"; @@ -47,8 +47,9 @@ import { ListIdpsResponse } from "@server/routers/idp"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; import Image from "next/image"; -import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; -import { TierId } from "@server/lib/billing/tiers"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import OrgRolesTagField from "@app/components/OrgRolesTagField"; type UserType = "internal" | "oidc"; @@ -76,7 +77,14 @@ export default function Page() { const api = createApiClient({ env }); const t = useTranslations(); - const subscription = useSubscriptionStatusContext(); + const { hasSaasSubscription, isPaidUser } = usePaidStatus(); + const isPaid = isPaidUser(tierMatrix.fullRbac); + const supportsMultipleRolesPerUser = isPaid; + const showMultiRolePaywallMessage = + !env.flags.disableEnterpriseFeatures && + ((build === "saas" && !isPaid) || + (build === "enterprise" && !isPaid) || + (build === "oss" && !isPaid)); const [selectedOption, setSelectedOption] = useState( "internal" @@ -89,19 +97,34 @@ export default function Page() { const [sendEmail, setSendEmail] = useState(env.email.emailEnabled); const [userOptions, setUserOptions] = useState([]); const [dataLoaded, setDataLoaded] = useState(false); + const [activeInviteRoleTagIndex, setActiveInviteRoleTagIndex] = useState< + number | null + >(null); + const [activeOidcRoleTagIndex, setActiveOidcRoleTagIndex] = useState< + number | null + >(null); + + const roleTagsFieldSchema = z + .array( + z.object({ + id: z.string(), + text: z.string() + }) + ) + .min(1, { message: t("accessRoleSelectPlease") }); const internalFormSchema = z.object({ email: z.email({ message: t("emailInvalid") }), validForHours: z .string() .min(1, { message: t("inviteValidityDuration") }), - roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) + roles: roleTagsFieldSchema }); const googleAzureFormSchema = z.object({ email: z.email({ message: t("emailInvalid") }), name: z.string().optional(), - roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) + roles: roleTagsFieldSchema }); const genericOidcFormSchema = z.object({ @@ -111,7 +134,7 @@ export default function Page() { .optional() .or(z.literal("")), name: z.string().optional(), - roleId: z.string().min(1, { message: t("accessRoleSelectPlease") }) + roles: roleTagsFieldSchema }); const formatIdpType = (type: string) => { @@ -166,12 +189,22 @@ export default function Page() { { hours: 168, name: t("day", { count: 7 }) } ]; + const allRoleOptions = roles.map((role) => ({ + id: role.roleId.toString(), + text: role.name + })); + + const invitePaywallMessage = + build === "saas" + ? t("singleRolePerUserPlanNotice") + : t("singleRolePerUserEditionNotice"); + const internalForm = useForm({ resolver: zodResolver(internalFormSchema), defaultValues: { email: "", validForHours: "72", - roleId: "" + roles: [] as { id: string; text: string }[] } }); @@ -180,7 +213,7 @@ export default function Page() { defaultValues: { email: "", name: "", - roleId: "" + roles: [] as { id: string; text: string }[] } }); @@ -190,7 +223,7 @@ export default function Page() { username: "", email: "", name: "", - roleId: "" + roles: [] as { id: string; text: string }[] } }); @@ -238,7 +271,7 @@ export default function Page() { } async function fetchIdps() { - if (build === "saas" && !subscription?.subscribed) { + if (build === "saas" && !hasSaasSubscription(tierMatrix.orgOidc)) { return; } @@ -305,16 +338,17 @@ export default function Page() { ) { setLoading(true); - const res = await api - .post>( - `/org/${orgId}/create-invite`, - { - email: values.email, - roleId: parseInt(values.roleId), - validHours: parseInt(values.validForHours), - sendEmail: sendEmail - } as InviteUserBody - ) + const roleIds = values.roles.map((r) => parseInt(r.id, 10)); + + const res = await api.post>( + `/org/${orgId}/create-invite`, + { + email: values.email, + roleIds, + validHours: parseInt(values.validForHours), + sendEmail + } + ) .catch((e) => { if (e.response?.status === 409) { toast({ @@ -358,14 +392,16 @@ export default function Page() { setLoading(true); + const roleIds = values.roles.map((r) => parseInt(r.id, 10)); + const res = await api .put(`/org/${orgId}/user`, { username: values.email, // Use email as username for Google/Azure - email: values.email, + email: values.email || undefined, name: values.name, type: "oidc", idpId: selectedUserOption.idpId, - roleId: parseInt(values.roleId) + roleIds }) .catch((e) => { toast({ @@ -400,14 +436,16 @@ export default function Page() { setLoading(true); + const roleIds = values.roles.map((r) => parseInt(r.id, 10)); + const res = await api .put(`/org/${orgId}/user`, { username: values.username, - email: values.email, + email: values.email || undefined, name: values.name, type: "oidc", idpId: selectedUserOption.idpId, - roleId: parseInt(values.roleId) + roleIds }) .catch((e) => { toast({ @@ -575,52 +613,32 @@ export default function Page() { )} /> - ( - - - {t("role")} - - - - + {env.email.emailEnabled && ( @@ -764,52 +782,32 @@ export default function Page() { )} /> - ( - - - {t("role")} - - - - + @@ -909,52 +907,32 @@ export default function Page() { )} /> - ( - - - {t("role")} - - - - + diff --git a/src/app/[orgId]/settings/access/users/page.tsx b/src/app/[orgId]/settings/access/users/page.tsx index 662ada608..812ac2b64 100644 --- a/src/app/[orgId]/settings/access/users/page.tsx +++ b/src/app/[orgId]/settings/access/users/page.tsx @@ -1,5 +1,6 @@ import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; import { ListUsersResponse } from "@server/routers/user"; import { AxiosResponse } from "axios"; import UsersTable, { UserRow } from "../../../../../components/UsersTable"; @@ -73,7 +74,11 @@ export default async function UsersPage(props: UsersPageProps) { return { id: user.id, username: user.username, - displayUsername: user.email || user.name || user.username, + displayUsername: getUserDisplayName({ + email: user.email, + name: user.name, + username: user.username + }), name: user.name, email: user.email, type: user.type, @@ -81,9 +86,14 @@ export default async function UsersPage(props: UsersPageProps) { idpId: user.idpId, idpName: user.idpName || t("idpNameInternal"), status: t("userConfirmed"), - role: user.isOwner - ? t("accessRoleOwner") - : user.roleName || t("accessRoleMember"), + roleLabels: user.isOwner + ? [t("accessRoleOwner")] + : (() => { + const names = (user.roles ?? []) + .map((r) => r.roleName) + .filter((n): n is string => Boolean(n?.length)); + return names.length ? names : [t("accessRoleMember")]; + })(), isOwner: user.isOwner || false }; }); diff --git a/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx b/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx index e75aa3eb5..634994b25 100644 --- a/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/[niceId]/credentials/page.tsx @@ -19,10 +19,6 @@ import { useTranslations } from "next-intl"; import { PickClientDefaultsResponse } from "@server/routers/client"; import { useClientContext } from "@app/hooks/useClientContext"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; -import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; -import { build } from "@server/build"; -import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert"; import { InfoSection, InfoSectionContent, @@ -32,6 +28,10 @@ import { import CopyToClipboard from "@app/components/CopyToClipboard"; import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { InfoIcon } from "lucide-react"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { OlmInstallCommands } from "@app/components/olm-install-commands"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; export default function CredentialsPage() { const { env } = useEnvContext(); @@ -53,15 +53,7 @@ export default function CredentialsPage() { const [showCredentialsAlert, setShowCredentialsAlert] = useState(false); const [shouldDisconnect, setShouldDisconnect] = useState(true); - const { licenseStatus, isUnlocked } = useLicenseStatusContext(); - const subscription = useSubscriptionStatusContext(); - - const isSecurityFeatureDisabled = () => { - const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked(); - const isSaasNotSubscribed = - build === "saas" && !subscription?.isSubscribed(); - return isEnterpriseNotLicensed || isSaasNotSubscribed; - }; + const { isPaidUser } = usePaidStatus(); const handleConfirmRegenerate = async () => { try { @@ -127,7 +119,9 @@ export default function CredentialsPage() { - + @@ -180,7 +174,7 @@ export default function CredentialsPage() { )} - {build !== "oss" && ( + {!env.flags.disableEnterpriseFeatures && ( @@ -197,13 +193,21 @@ export default function CredentialsPage() { setShouldDisconnect(true); setModalOpen(true); }} - disabled={isSecurityFeatureDisabled()} + disabled={ + !isPaidUser(tierMatrix.rotateCredentials) + } > {t("clientRegenerateAndDisconnect")} )} + + { + if (!client?.clientId) return; + setIsRefreshing(true); + try { + await api.post(`/client/${client.clientId}/unblock`); + // Optimistically update the client context + updateClient({ blocked: false, approvalState: null }); + toast({ + title: t("unblockClient"), + description: t("unblockClientDescription") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("error"), + description: formatAxiosError(e, t("error")) + }); + } finally { + setIsRefreshing(false); + } + }; + return ( + {/* Blocked Device Banner */} + {client?.blocked && ( + } + description={t("deviceBlockedDescription")} + actions={ + + } + /> + )} diff --git a/src/app/[orgId]/settings/clients/machine/create/page.tsx b/src/app/[orgId]/settings/clients/machine/create/page.tsx index 05ace912a..a7ae6e107 100644 --- a/src/app/[orgId]/settings/clients/machine/create/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/create/page.tsx @@ -1,15 +1,22 @@ "use client"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; import { SettingsContainer, SettingsSection, SettingsSectionBody, SettingsSectionDescription, - SettingsSectionForm, SettingsSectionHeader, SettingsSectionTitle } from "@app/components/Settings"; -import { StrategySelect } from "@app/components/StrategySelect"; +import HeaderTitle from "@app/components/SettingsSectionTitle"; +import { Button } from "@app/components/ui/button"; import { Form, FormControl, @@ -19,44 +26,24 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import HeaderTitle from "@app/components/SettingsSectionTitle"; -import { z } from "zod"; -import { createElement, useEffect, useState } from "react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; import { Input } from "@app/components/ui/input"; -import { InfoIcon, Terminal } from "lucide-react"; -import { Button } from "@app/components/ui/button"; -import CopyTextBox from "@app/components/CopyTextBox"; -import CopyToClipboard from "@app/components/CopyToClipboard"; -import { - InfoSection, - InfoSectionContent, - InfoSections, - InfoSectionTitle -} from "@app/components/InfoSection"; -import { - FaApple, - FaCubes, - FaDocker, - FaFreebsd, - FaWindows -} from "react-icons/fa"; -import { SiNixos, SiKubernetes } from "react-icons/si"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { zodResolver } from "@hookform/resolvers/zod"; import { CreateClientBody, CreateClientResponse, PickClientDefaultsResponse } from "@server/routers/client"; -import { ListSitesResponse } from "@server/routers/site"; -import { toast } from "@app/hooks/useToast"; import { AxiosResponse } from "axios"; +import { ChevronDown, ChevronUp } from "lucide-react"; import { useParams, useRouter } from "next/navigation"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { OlmInstallCommands } from "@app/components/olm-install-commands"; import { useTranslations } from "next-intl"; type ClientType = "olm"; @@ -68,17 +55,6 @@ interface TunnelTypeOption { disabled?: boolean; } -type CommandItem = string | { title: string; command: string }; - -type Commands = { - unix: Record; - windows: Record; -}; - -const platforms = ["unix", "windows"] as const; - -type Platform = (typeof platforms)[number]; - export default function Page() { const { env } = useEnvContext(); const api = createApiClient({ env }); @@ -112,126 +88,16 @@ export default function Page() { const [loadingPage, setLoadingPage] = useState(true); - const [platform, setPlatform] = useState("unix"); - const [architecture, setArchitecture] = useState("All"); - const [commands, setCommands] = useState(null); - const [olmId, setOlmId] = useState(""); const [olmSecret, setOlmSecret] = useState(""); - const [olmCommand, setOlmCommand] = useState(""); + const [olmVersion, setOlmVersion] = useState("latest"); const [createLoading, setCreateLoading] = useState(false); + const [showAdvancedSettings, setShowAdvancedSettings] = useState(false); const [clientDefaults, setClientDefaults] = useState(null); - const hydrateCommands = ( - id: string, - secret: string, - endpoint: string, - version: string - ) => { - const commands = { - unix: { - All: [ - { - title: t("install"), - command: `curl -fsSL https://static.pangolin.net/get-olm.sh | bash` - }, - { - title: t("run"), - command: `sudo olm --id ${id} --secret ${secret} --endpoint ${endpoint}` - } - ] - }, - windows: { - x64: [ - { - title: t("install"), - command: `curl -o olm.exe -L "https://github.com/fosrl/olm/releases/download/${version}/olm_windows_installer.exe"` - }, - { - title: t("run"), - command: `olm.exe --id ${id} --secret ${secret} --endpoint ${endpoint}` - } - ] - } - }; - setCommands(commands); - }; - - const getArchitectures = () => { - switch (platform) { - case "unix": - return ["All"]; - case "windows": - return ["x64"]; - default: - return ["x64"]; - } - }; - - const getPlatformName = (platformName: string) => { - switch (platformName) { - case "windows": - return "Windows"; - case "unix": - return "Unix & macOS"; - case "docker": - return "Docker"; - default: - return "Unix & macOS"; - } - }; - - const getCommand = (): CommandItem[] => { - const placeholder: CommandItem[] = [t("unknownCommand")]; - if (!commands) { - return placeholder; - } - let platformCommands = commands[platform as keyof Commands]; - - if (!platformCommands) { - // get first key - const firstPlatform = Object.keys(commands)[0] as Platform; - platformCommands = commands[firstPlatform as keyof Commands]; - - setPlatform(firstPlatform); - } - - let architectureCommands = platformCommands[architecture]; - if (!architectureCommands) { - // get first key - const firstArchitecture = Object.keys(platformCommands)[0]; - architectureCommands = platformCommands[firstArchitecture]; - - setArchitecture(firstArchitecture); - } - - return architectureCommands || placeholder; - }; - - const getPlatformIcon = (platformName: string) => { - switch (platformName) { - case "windows": - return ; - case "unix": - return ; - case "docker": - return ; - case "kubernetes": - return ; - case "podman": - return ; - case "freebsd": - return ; - case "nixos": - return ; - default: - return ; - } - }; - const form = useForm({ resolver: zodResolver(createClientFormSchema), defaultValues: { @@ -286,23 +152,6 @@ export default function Page() { const load = async () => { setLoadingPage(true); - // Fetch available sites - - // const res = await api.get>( - // `/org/${orgId}/sites/` - // ); - // const sites = res.data.data.sites.filter( - // (s) => s.type === "newt" && s.subnet - // ); - // setSites( - // sites.map((site) => ({ - // id: site.siteId.toString(), - // text: site.name - // })) - // ); - - let olmVersion = "latest"; - try { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 3000); @@ -323,7 +172,7 @@ export default function Page() { } const data = await response.json(); const latestVersion = data.tag_name; - olmVersion = latestVersion; + setOlmVersion(latestVersion); } catch (error) { if (error instanceof Error && error.name === "AbortError") { console.error(t("olmErrorFetchTimeout")); @@ -352,18 +201,9 @@ export default function Page() { const olmId = data.olmId; const olmSecret = data.olmSecret; - const olmCommand = `olm --id ${olmId} --secret ${olmSecret} --endpoint ${env.app.dashboardUrl}`; setOlmId(olmId); setOlmSecret(olmSecret); - setOlmCommand(olmCommand); - - hydrateCommands( - olmId, - olmSecret, - env.app.dashboardUrl, - olmVersion - ); if (data.subnet) { form.setValue("subnet", data.subnet); @@ -443,33 +283,54 @@ export default function Page() { )} /> - - ( - - - {t("clientAddress")} - - - + +
+ {showAdvancedSettings && ( + ( + + + {t("clientAddress")} + + + + + + + {t( + "addressDescription" )} - {...field} - /> - - - - {t( - "addressDescription" - )} - - - )} - /> + + + )} + /> + )} @@ -523,132 +384,14 @@ export default function Page() { - - - - - {t("clientCredentialsSave")} - - - {t( - "clientCredentialsSaveDescription" - )} - - - - - - - - {t("clientInstallOlm")} - - - {t("clientInstallOlmDescription")} - - - -
-

- {t("operatingSystem")} -

-
- {platforms.map((os) => ( - - ))} -
-
- -
-

- {["docker", "podman"].includes( - platform - ) - ? t("method") - : t("architecture")} -

-
- {getArchitectures().map( - (arch) => ( - - ) - )} -
-
-

- {t("commands")} -

-
- {getCommand().map( - (item, index) => { - const commandText = - typeof item === - "string" - ? item - : item.command; - const title = - typeof item === - "string" - ? undefined - : item.title; - - return ( -
- {title && ( -

- { - title - } -

- )} - -
- ); - } - )} -
-
-
+ )} diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx index e1a904ada..4b40c906c 100644 --- a/src/app/[orgId]/settings/clients/machine/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -1,15 +1,17 @@ import type { ClientRow } from "@app/components/MachineClientsTable"; import MachineClientsTable from "@app/components/MachineClientsTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import MachineClientsBanner from "@app/components/MachineClientsBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { ListClientsResponse } from "@server/routers/client"; import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; +import type { Pagination } from "@server/types/Pagination"; type ClientsPageProps = { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; @@ -18,17 +20,25 @@ export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); let machineClients: ListClientsResponse["clients"] = []; + let pagination: Pagination = { + page: 1, + total: 0, + pageSize: 20 + }; try { const machineRes = await internal.get< AxiosResponse >( - `/org/${params.orgId}/clients?filter=machine`, + `/org/${params.orgId}/clients?${searchParams.toString()}`, await authCookieHeader() ); - machineClients = machineRes.data.data.clients; + const responseData = machineRes.data.data; + machineClients = responseData.clients; + pagination = responseData.pagination; } catch (e) {} function formatSize(mb: number): string { @@ -58,7 +68,10 @@ export default async function ClientsPage(props: ClientsPageProps) { username: client.username, userEmail: client.userEmail, niceId: client.niceId, - agent: client.agent + agent: client.agent, + archived: client.archived || false, + blocked: client.blocked || false, + approvalState: client.approvalState ?? "approved" }; }; @@ -71,9 +84,16 @@ export default async function ClientsPage(props: ClientsPageProps) { description={t("manageMachineClientsDescription")} /> + + ); diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx new file mode 100644 index 000000000..c08551865 --- /dev/null +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -0,0 +1,849 @@ +"use client"; + +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { useClientContext } from "@app/hooks/useClientContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { useTranslations } from "next-intl"; +import { build } from "@server/build"; +import { + InfoSection, + InfoSectionContent, + InfoSections, + InfoSectionTitle +} from "@app/components/InfoSection"; +import { Badge } from "@app/components/ui/badge"; +import { Button } from "@app/components/ui/button"; +import ActionBanner from "@app/components/ActionBanner"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { toast } from "@app/hooks/useToast"; +import { useRouter } from "next/navigation"; +import { useState, useEffect, useTransition } from "react"; +import { + Check, + Ban, + Shield, + ShieldOff, + Clock, + CheckCircle2, + XCircle +} from "lucide-react"; +import { useParams } from "next/navigation"; +import { FaApple, FaWindows, FaLinux } from "react-icons/fa"; +import { SiAndroid } from "react-icons/si"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; + +function formatTimestamp(timestamp: number | null | undefined): string { + if (!timestamp) return "-"; + return new Date(timestamp * 1000).toLocaleString(); +} + +function formatPlatform(platform: string | null | undefined): string { + if (!platform) return "-"; + const platformMap: Record = { + macos: "macOS", + windows: "Windows", + linux: "Linux", + ios: "iOS", + android: "Android", + unknown: "Unknown" + }; + return platformMap[platform.toLowerCase()] || platform; +} + +function getPlatformIcon(platform: string | null | undefined) { + if (!platform) return null; + const normalizedPlatform = platform.toLowerCase(); + switch (normalizedPlatform) { + case "macos": + case "ios": + return ; + case "windows": + return ; + case "linux": + return ; + case "android": + return ; + default: + return null; + } +} + +type FieldConfig = { + show: boolean; + labelKey: string; +}; + +function getPlatformFieldConfig( + platform: string | null | undefined +): Record { + const normalizedPlatform = platform?.toLowerCase() || "unknown"; + + const configs: Record> = { + macos: { + osVersion: { show: true, labelKey: "macosVersion" }, + kernelVersion: { show: false, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + windows: { + osVersion: { show: true, labelKey: "windowsVersion" }, + kernelVersion: { show: true, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + linux: { + osVersion: { show: true, labelKey: "osVersion" }, + kernelVersion: { show: true, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + }, + ios: { + osVersion: { show: true, labelKey: "iosVersion" }, + kernelVersion: { show: false, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" } + }, + android: { + osVersion: { show: true, labelKey: "androidVersion" }, + kernelVersion: { show: true, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" } + }, + unknown: { + osVersion: { show: true, labelKey: "osVersion" }, + kernelVersion: { show: true, labelKey: "kernelVersion" }, + arch: { show: true, labelKey: "architecture" }, + deviceModel: { show: true, labelKey: "deviceModel" }, + serialNumber: { show: true, labelKey: "serialNumber" }, + username: { show: true, labelKey: "username" }, + hostname: { show: true, labelKey: "hostname" } + } + }; + + return configs[normalizedPlatform] || configs.unknown; +} + +export default function GeneralPage() { + const { client, updateClient } = useClientContext(); + const { isPaidUser } = usePaidStatus(); + const t = useTranslations(); + const api = createApiClient(useEnvContext()); + const router = useRouter(); + const params = useParams(); + const orgId = params.orgId as string; + const [approvalId, setApprovalId] = useState(null); + const [isRefreshing, setIsRefreshing] = useState(false); + const [, startTransition] = useTransition(); + const { env } = useEnvContext(); + + const showApprovalFeatures = + build !== "oss" && isPaidUser(tierMatrix.deviceApprovals); + + const formatPostureValue = (value: boolean | null | undefined | "-") => { + if (value === null || value === undefined || value === "-") return "-"; + return ( +
+ {value ? ( + + ) : ( + + )} + {value ? t("enabled") : t("disabled")} +
+ ); + }; + + // Fetch approval ID for this client if pending + useEffect(() => { + if ( + showApprovalFeatures && + client.approvalState === "pending" && + client.clientId + ) { + api.get(`/org/${orgId}/approvals?approvalState=pending`) + .then((res) => { + const approval = res.data.data.approvals.find( + (a: any) => a.clientId === client.clientId + ); + if (approval) { + setApprovalId(approval.approvalId); + } + }) + .catch(() => { + // Silently fail - approval might not exist + }); + } + }, [ + showApprovalFeatures, + client.approvalState, + client.clientId, + orgId, + api + ]); + + const handleApprove = async () => { + if (!approvalId) return; + setIsRefreshing(true); + try { + await api.put(`/org/${orgId}/approvals/${approvalId}`, { + decision: "approved" + }); + // Optimistically update the client context + updateClient({ approvalState: "approved" }); + toast({ + title: t("accessApprovalUpdated"), + description: t("accessApprovalApprovedDescription") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("accessApprovalErrorUpdate"), + description: formatAxiosError( + e, + t("accessApprovalErrorUpdateDescription") + ) + }); + } finally { + setIsRefreshing(false); + } + }; + + const handleDeny = async () => { + if (!approvalId) return; + setIsRefreshing(true); + try { + await api.put(`/org/${orgId}/approvals/${approvalId}`, { + decision: "denied" + }); + // Optimistically update the client context + updateClient({ approvalState: "denied", blocked: true }); + toast({ + title: t("accessApprovalUpdated"), + description: t("accessApprovalDeniedDescription") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("accessApprovalErrorUpdate"), + description: formatAxiosError( + e, + t("accessApprovalErrorUpdateDescription") + ) + }); + } finally { + setIsRefreshing(false); + } + }; + + const handleBlock = async () => { + if (!client.clientId) return; + setIsRefreshing(true); + try { + await api.post(`/client/${client.clientId}/block`); + // Optimistically update the client context + updateClient({ blocked: true, approvalState: "denied" }); + toast({ + title: t("blockClient"), + description: t("blockClientMessage") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("error"), + description: formatAxiosError(e, t("error")) + }); + } finally { + setIsRefreshing(false); + } + }; + + const handleUnblock = async () => { + if (!client.clientId) return; + setIsRefreshing(true); + try { + await api.post(`/client/${client.clientId}/unblock`); + // Optimistically update the client context + updateClient({ blocked: false, approvalState: null }); + toast({ + title: t("unblockClient"), + description: t("unblockClientDescription") + }); + startTransition(() => { + router.refresh(); + }); + } catch (e) { + toast({ + variant: "destructive", + title: t("error"), + description: formatAxiosError(e, t("error")) + }); + } finally { + setIsRefreshing(false); + } + }; + + return ( + + {/* Pending Approval Banner */} + {showApprovalFeatures && client.approvalState === "pending" && ( + } + description={t("devicePendingApprovalBannerDescription")} + actions={ + <> + + + + } + /> + )} + + {/* Blocked Device Banner */} + {client.blocked && client.approvalState !== "pending" && ( + } + description={t("deviceBlockedDescription")} + actions={ + + } + /> + )} + + {/* Device Information Section */} + {(client.fingerprint || (client.agent && client.olmVersion)) && ( + + + + {t("deviceInformation")} + + + {t("deviceInformationDescription")} + + + + + {client.agent && client.olmVersion && ( +
+ + + {t("agent")} + + + + {client.agent + + " v" + + client.olmVersion} + + + +
+ )} + + {client.fingerprint && + (() => { + const platform = client.fingerprint.platform; + const fieldConfig = + getPlatformFieldConfig(platform); + + return ( + + {platform && ( + + + {t("platform")} + + +
+ {getPlatformIcon( + platform + )} + + {formatPlatform( + platform + )} + +
+
+
+ )} + + {client.fingerprint.osVersion && + fieldConfig.osVersion?.show && ( + + + {t( + fieldConfig + .osVersion + ?.labelKey || + "osVersion" + )} + + + { + client.fingerprint + .osVersion + } + + + )} + + {client.fingerprint.kernelVersion && + fieldConfig.kernelVersion?.show && ( + + + {t("kernelVersion")} + + + { + client.fingerprint + .kernelVersion + } + + + )} + + {client.fingerprint.arch && + fieldConfig.arch.show && ( + + + {t("architecture")} + + + { + client.fingerprint + .arch + } + + + )} + + {client.fingerprint.deviceModel && + fieldConfig.deviceModel?.show && ( + + + {t("deviceModel")} + + + { + client.fingerprint + .deviceModel + } + + + )} + + {client.fingerprint.serialNumber && + fieldConfig.serialNumber.show && ( + + + {t("serialNumber")} + + + { + client.fingerprint + .serialNumber + } + + + )} + + {client.fingerprint.username && + fieldConfig.username?.show && ( + + + {t("username")} + + + { + client.fingerprint + .username + } + + + )} + + {client.fingerprint.hostname && + fieldConfig.hostname?.show && ( + + + {t("hostname")} + + + { + client.fingerprint + .hostname + } + + + )} + + {client.fingerprint.firstSeen && ( + + + {t("firstSeen")} + + + {formatTimestamp( + client.fingerprint + .firstSeen + )} + + + )} + + {client.fingerprint.lastSeen && ( + + + {t("lastSeen")} + + + {formatTimestamp( + client.fingerprint + .lastSeen + )} + + + )} +
+ ); + })()} +
+
+ )} + + {!env.flags.disableEnterpriseFeatures && ( + + + + {t("deviceSecurity")} + + + {t("deviceSecurityDescription")} + + + + + + + {client.posture && + Object.keys(client.posture).length > 0 ? ( + <> + + {client.posture.biometricsEnabled !== + null && + client.posture.biometricsEnabled !== + undefined && ( + + + {t("biometricsEnabled")} + + + {isPaidUser( + tierMatrix.devicePosture + ) + ? formatPostureValue( + client.posture + .biometricsEnabled === + true + ) + : "-"} + + + )} + + {client.posture.diskEncrypted !== null && + client.posture.diskEncrypted !== + undefined && ( + + + {t("diskEncrypted")} + + + {isPaidUser( + tierMatrix.devicePosture + ) + ? formatPostureValue( + client.posture + .diskEncrypted === + true + ) + : "-"} + + + )} + + {client.posture.firewallEnabled !== null && + client.posture.firewallEnabled !== + undefined && ( + + + {t("firewallEnabled")} + + + {isPaidUser( + tierMatrix.devicePosture + ) + ? formatPostureValue( + client.posture + .firewallEnabled === + true + ) + : "-"} + + + )} + + {client.posture.autoUpdatesEnabled !== + null && + client.posture.autoUpdatesEnabled !== + undefined && ( + + + {t("autoUpdatesEnabled")} + + + {isPaidUser( + tierMatrix.devicePosture + ) + ? formatPostureValue( + client.posture + .autoUpdatesEnabled === + true + ) + : "-"} + + + )} + + {client.posture.tpmAvailable !== null && + client.posture.tpmAvailable !== + undefined && ( + + + {t("tpmAvailable")} + + + {isPaidUser( + tierMatrix.devicePosture + ) + ? formatPostureValue( + client.posture + .tpmAvailable === + true + ) + : "-"} + + + )} + + {client.posture.windowsAntivirusEnabled !== + null && + client.posture + .windowsAntivirusEnabled !== + undefined && ( + + + {t( + "windowsAntivirusEnabled" + )} + + + {isPaidUser( + tierMatrix.devicePosture + ) + ? formatPostureValue( + client.posture + .windowsAntivirusEnabled === + true + ) + : "-"} + + + )} + + {client.posture.macosSipEnabled !== null && + client.posture.macosSipEnabled !== + undefined && ( + + + {t("macosSipEnabled")} + + + {isPaidUser( + tierMatrix.devicePosture + ) + ? formatPostureValue( + client.posture + .macosSipEnabled === + true + ) + : "-"} + + + )} + + {client.posture.macosGatekeeperEnabled !== + null && + client.posture + .macosGatekeeperEnabled !== + undefined && ( + + + {t( + "macosGatekeeperEnabled" + )} + + + {isPaidUser( + tierMatrix.devicePosture + ) + ? formatPostureValue( + client.posture + .macosGatekeeperEnabled === + true + ) + : "-"} + + + )} + + {client.posture.macosFirewallStealthMode !== + null && + client.posture + .macosFirewallStealthMode !== + undefined && ( + + + {t( + "macosFirewallStealthMode" + )} + + + {isPaidUser( + tierMatrix.devicePosture + ) + ? formatPostureValue( + client.posture + .macosFirewallStealthMode === + true + ) + : "-"} + + + )} + + {client.posture.linuxAppArmorEnabled !== + null && + client.posture.linuxAppArmorEnabled !== + undefined && ( + + + {t("linuxAppArmorEnabled")} + + + {isPaidUser( + tierMatrix.devicePosture + ) + ? formatPostureValue( + client.posture + .linuxAppArmorEnabled === + true + ) + : "-"} + + + )} + + {client.posture.linuxSELinuxEnabled !== + null && + client.posture.linuxSELinuxEnabled !== + undefined && ( + + + {t("linuxSELinuxEnabled")} + + + {isPaidUser( + tierMatrix.devicePosture + ) + ? formatPostureValue( + client.posture + .linuxSELinuxEnabled === + true + ) + : "-"} + + + )} + + + ) : ( +
+ {t("noData")} +
+ )} +
+
+ )} +
+ ); +} diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx new file mode 100644 index 000000000..2d9934cbe --- /dev/null +++ b/src/app/[orgId]/settings/clients/user/[niceId]/layout.tsx @@ -0,0 +1,57 @@ +import ClientInfoCard from "@app/components/ClientInfoCard"; +import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import ClientProvider from "@app/providers/ClientProvider"; +import { GetClientResponse } from "@server/routers/client"; +import { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; + +type SettingsLayoutProps = { + children: React.ReactNode; + params: Promise<{ niceId: number | string; orgId: string }>; +}; + +export default async function SettingsLayout(props: SettingsLayoutProps) { + const params = await props.params; + + const { children } = props; + + let client = null; + try { + const res = await internal.get>( + `/org/${params.orgId}/client/${params.niceId}`, + await authCookieHeader() + ); + client = res.data.data; + } catch (error) { + redirect(`/${params.orgId}/settings/clients/user`); + } + + const t = await getTranslations(); + + const navItems = [ + { + title: t("general"), + href: `/${params.orgId}/settings/clients/user/${params.niceId}/general` + } + ]; + + return ( + <> + + + +
+ + {children} +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx new file mode 100644 index 000000000..9ad97186d --- /dev/null +++ b/src/app/[orgId]/settings/clients/user/[niceId]/page.tsx @@ -0,0 +1,10 @@ +import { redirect } from "next/navigation"; + +export default async function ClientPage(props: { + params: Promise<{ orgId: string; niceId: number | string }>; +}) { + const params = await props.params; + redirect( + `/${params.orgId}/settings/clients/user/${params.niceId}/general` + ); +} diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index 28288fd28..fcb24e4e3 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -1,14 +1,16 @@ +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import type { ClientRow } from "@app/components/UserDevicesTable"; +import UserDevicesTable from "@app/components/UserDevicesTable"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; +import { type ListUserDevicesResponse } from "@server/routers/client"; +import type { Pagination } from "@server/types/Pagination"; import { AxiosResponse } from "axios"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { ListClientsResponse } from "@server/routers/client"; import { getTranslations } from "next-intl/server"; -import type { ClientRow } from "@app/components/MachineClientsTable"; -import UserDevicesTable from "@app/components/UserDevicesTable"; type ClientsPageProps = { params: Promise<{ orgId: string }>; + searchParams: Promise>; }; export const dynamic = "force-dynamic"; @@ -17,15 +19,26 @@ export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); const params = await props.params; + const searchParams = new URLSearchParams(await props.searchParams); - let userClients: ListClientsResponse["clients"] = []; + let userClients: ListUserDevicesResponse["devices"] = []; + + let pagination: Pagination = { + page: 1, + total: 0, + pageSize: 20 + }; try { - const userRes = await internal.get>( - `/org/${params.orgId}/clients?filter=user`, + const userRes = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/user-devices?${searchParams.toString()}`, await authCookieHeader() ); - userClients = userRes.data.data.clients; + const responseData = userRes.data.data; + userClients = responseData.devices; + pagination = responseData.pagination; } catch (e) {} function formatSize(mb: number): string { @@ -39,23 +52,51 @@ export default async function ClientsPage(props: ClientsPageProps) { } const mapClientToRow = ( - client: ListClientsResponse["clients"][0] + client: ListUserDevicesResponse["devices"][number] ): ClientRow => { + // Build fingerprint object if any fingerprint data exists + const hasFingerprintData = + client.fingerprintPlatform || + client.fingerprintOsVersion || + client.fingerprintKernelVersion || + client.fingerprintArch || + client.fingerprintSerialNumber || + client.fingerprintUsername || + client.fingerprintHostname || + client.deviceModel; + + const fingerprint = hasFingerprintData + ? { + platform: client.fingerprintPlatform, + osVersion: client.fingerprintOsVersion, + kernelVersion: client.fingerprintKernelVersion, + arch: client.fingerprintArch, + deviceModel: client.deviceModel, + serialNumber: client.fingerprintSerialNumber, + username: client.fingerprintUsername, + hostname: client.fingerprintHostname + } + : null; + return { name: client.name, id: client.clientId, subnet: client.subnet.split("/")[0], - mbIn: formatSize(client.megabytesIn || 0), - mbOut: formatSize(client.megabytesOut || 0), + mbIn: formatSize(client.megabytesIn ?? 0), + mbOut: formatSize(client.megabytesOut ?? 0), orgId: params.orgId, online: client.online, olmVersion: client.olmVersion || undefined, - olmUpdateAvailable: client.olmUpdateAvailable || false, + olmUpdateAvailable: Boolean(client.olmUpdateAvailable), userId: client.userId, username: client.username, userEmail: client.userEmail, niceId: client.niceId, - agent: client.agent + agent: client.agent, + archived: Boolean(client.archived), + blocked: Boolean(client.blocked), + approvalState: client.approvalState, + fingerprint }; }; @@ -71,6 +112,11 @@ export default async function ClientsPage(props: ClientsPageProps) { ); diff --git a/src/app/[orgId]/settings/domains/[domainId]/page.tsx b/src/app/[orgId]/settings/domains/[domainId]/page.tsx index 39ad02db2..cf23e81be 100644 --- a/src/app/[orgId]/settings/domains/[domainId]/page.tsx +++ b/src/app/[orgId]/settings/domains/[domainId]/page.tsx @@ -69,6 +69,7 @@ export default async function DomainSettingsPage({ failed={domain.failed} verified={domain.verified} type={domain.type} + errorMessage={domain.errorMessage} /> diff --git a/src/app/[orgId]/settings/general/auth-page/page.tsx b/src/app/[orgId]/settings/general/auth-page/page.tsx new file mode 100644 index 000000000..0bd482864 --- /dev/null +++ b/src/app/[orgId]/settings/general/auth-page/page.tsx @@ -0,0 +1,58 @@ +import AuthPageBrandingForm from "@app/components/AuthPageBrandingForm"; +import AuthPageSettings from "@app/components/AuthPageSettings"; +import { SettingsContainer } from "@app/components/Settings"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { getCachedSubscription } from "@app/lib/api/getCachedSubscription"; +import { build } from "@server/build"; +import type { GetOrgTierResponse } from "@server/routers/billing/types"; +import { + GetLoginPageBrandingResponse, + GetLoginPageResponse +} from "@server/routers/loginPage/types"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; + +export interface AuthPageProps { + params: Promise<{ orgId: string }>; +} + +export default async function AuthPage(props: AuthPageProps) { + const orgId = (await props.params).orgId; + + let subscriptionStatus: GetOrgTierResponse | null = null; + try { + const subRes = await getCachedSubscription(orgId); + subscriptionStatus = subRes.data.data; + } catch {} + + let loginPage: GetLoginPageResponse | null = null; + try { + if (build === "saas") { + const res = await internal.get>( + `/org/${orgId}/login-page`, + await authCookieHeader() + ); + if (res.status === 200) { + loginPage = res.data.data; + } + } + } catch (error) {} + + let loginPageBranding: GetLoginPageBrandingResponse | null = null; + try { + const res = await internal.get< + AxiosResponse + >(`/org/${orgId}/login-page-branding`, await authCookieHeader()); + if (res.status === 200) { + loginPageBranding = res.data.data; + } + } catch (error) {} + + return ( + + {build === "saas" && } + + + ); +} diff --git a/src/app/[orgId]/settings/general/layout.tsx b/src/app/[orgId]/settings/general/layout.tsx index 8c8efa59d..736e2037e 100644 --- a/src/app/[orgId]/settings/general/layout.tsx +++ b/src/app/[orgId]/settings/general/layout.tsx @@ -1,16 +1,16 @@ -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { HorizontalTabs } from "@app/components/HorizontalTabs"; +import { HorizontalTabs, type TabItem } from "@app/components/HorizontalTabs"; import { verifySession } from "@app/lib/auth/verifySession"; import OrgProvider from "@app/providers/OrgProvider"; import OrgUserProvider from "@app/providers/OrgUserProvider"; -import { GetOrgResponse } from "@server/routers/org"; -import { GetOrgUserResponse } from "@server/routers/user"; -import { AxiosResponse } from "axios"; +import OrgInfoCard from "@app/components/OrgInfoCard"; + import { redirect } from "next/navigation"; -import { cache } from "react"; import { getTranslations } from "next-intl/server"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; +import { getCachedOrgUser } from "@app/lib/api/getCachedOrgUser"; +import { build } from "@server/build"; +import { pullEnv } from "@app/lib/pullEnv"; type GeneralSettingsProps = { children: React.ReactNode; @@ -23,8 +23,8 @@ export default async function GeneralSettingsPage({ }: GeneralSettingsProps) { const { orgId } = await params; - const getUser = cache(verifySession); - const user = await getUser(); + const user = await verifySession(); + const env = pullEnv(); if (!user) { redirect(`/`); @@ -32,13 +32,7 @@ export default async function GeneralSettingsPage({ let orgUser = null; try { - const getOrgUser = cache(async () => - internal.get>( - `/org/${orgId}/user/${user.userId}`, - await authCookieHeader() - ) - ); - const res = await getOrgUser(); + const res = await getCachedOrgUser(orgId, user.userId); orgUser = res.data.data; } catch { redirect(`/${orgId}`); @@ -46,13 +40,7 @@ export default async function GeneralSettingsPage({ let org = null; try { - const getOrg = cache(async () => - internal.get>( - `/org/${orgId}`, - await authCookieHeader() - ) - ); - const res = await getOrg(); + const res = await getCachedOrg(orgId); org = res.data.data; } catch { redirect(`/${orgId}`); @@ -60,11 +48,25 @@ export default async function GeneralSettingsPage({ const t = await getTranslations(); - const navItems = [ + const navItems: TabItem[] = [ { title: t("general"), - href: `/{orgId}/settings/general` - } + href: `/{orgId}/settings/general`, + exact: true + }, + { + title: t("security"), + href: `/{orgId}/settings/general/security` + }, + // PaidFeaturesAlert + ...(!env.flags.disableEnterpriseFeatures + ? [ + { + title: t("authPage"), + href: `/{orgId}/settings/general/auth-page` + } + ] + : []) ]; return ( @@ -76,7 +78,12 @@ export default async function GeneralSettingsPage({ description={t("orgSettingsDescription")} /> - {children} +
+ + + {children} + +
diff --git a/src/app/[orgId]/settings/general/page.tsx b/src/app/[orgId]/settings/general/page.tsx index 97dd4a03e..0a2ed39bb 100644 --- a/src/app/[orgId]/settings/general/page.tsx +++ b/src/app/[orgId]/settings/general/page.tsx @@ -1,14 +1,9 @@ "use client"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import AuthPageSettings, { - AuthPageSettingsRef -} from "@app/components/private/AuthPageSettings"; - import { Button } from "@app/components/ui/button"; import { useOrgContext } from "@app/hooks/useOrgContext"; -import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; import { toast } from "@app/hooks/useToast"; -import { useState, useRef } from "react"; +import { useState, useTransition, useActionState } from "react"; import { Form, FormControl, @@ -19,13 +14,6 @@ import { FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@/components/ui/select"; import { z } from "zod"; import { useForm } from "react-hook-form"; @@ -49,155 +37,36 @@ import { import { useUserContext } from "@app/hooks/useUserContext"; import { useTranslations } from "next-intl"; import { build } from "@server/build"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { SecurityFeaturesAlert } from "@app/components/SecurityFeaturesAlert"; -import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; -import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; - -// Session length options in hours -const SESSION_LENGTH_OPTIONS = [ - { value: null, labelKey: "unenforced" }, - { value: 1, labelKey: "1Hour" }, - { value: 3, labelKey: "3Hours" }, - { value: 6, labelKey: "6Hours" }, - { value: 12, labelKey: "12Hours" }, - { value: 24, labelKey: "1DaySession" }, - { value: 72, labelKey: "3Days" }, - { value: 168, labelKey: "7Days" }, - { value: 336, labelKey: "14Days" }, - { value: 720, labelKey: "30DaysSession" }, - { value: 2160, labelKey: "90DaysSession" }, - { value: 4320, labelKey: "180DaysSession" } -]; - -// Password expiry options in days - will be translated in component -const PASSWORD_EXPIRY_OPTIONS = [ - { value: null, labelKey: "neverExpire" }, - { value: 1, labelKey: "1Day" }, - { value: 30, labelKey: "30Days" }, - { value: 60, labelKey: "60Days" }, - { value: 90, labelKey: "90Days" }, - { value: 180, labelKey: "180Days" }, - { value: 365, labelKey: "1Year" } -]; +import type { OrgContextType } from "@app/contexts/orgContext"; // Schema for general organization settings const GeneralFormSchema = z.object({ name: z.string(), - subnet: z.string().optional(), - requireTwoFactor: z.boolean().optional(), - maxSessionLengthHours: z.number().nullable().optional(), - passwordExpiryDays: z.number().nullable().optional(), - settingsLogRetentionDaysRequest: z.number(), - settingsLogRetentionDaysAccess: z.number(), - settingsLogRetentionDaysAction: z.number() + subnet: z.string().optional() }); -type GeneralFormValues = z.infer; - -const LOG_RETENTION_OPTIONS = [ - { label: "logRetentionDisabled", value: 0 }, - { label: "logRetention3Days", value: 3 }, - { label: "logRetention7Days", value: 7 }, - { label: "logRetention14Days", value: 14 }, - { label: "logRetention30Days", value: 30 }, - { label: "logRetention90Days", value: 90 }, - ...(build != "saas" - ? [ - { label: "logRetentionForever", value: -1 }, - { label: "logRetentionEndOfFollowingYear", value: 9001 } - ] - : []) -]; - export default function GeneralPage() { - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const { orgUser } = userOrgUserContext(); - const router = useRouter(); const { org } = useOrgContext(); - const api = createApiClient(useEnvContext()); - const { user } = useUserContext(); + return ( + + + {!org.org.isBillingOrg && } + + ); +} + +type SectionFormProps = { + org: OrgContextType["org"]["org"]; +}; + +function DeleteForm({ org }: SectionFormProps) { const t = useTranslations(); - const { env } = useEnvContext(); - const { licenseStatus, isUnlocked } = useLicenseStatusContext(); - const subscription = useSubscriptionStatusContext(); + const api = createApiClient(useEnvContext()); - // Check if security features are disabled due to licensing/subscription - const isSecurityFeatureDisabled = () => { - const isEnterpriseNotLicensed = build === "enterprise" && !isUnlocked(); - const isSaasNotSubscribed = - build === "saas" && !subscription?.isSubscribed(); - return isEnterpriseNotLicensed || isSaasNotSubscribed; - }; - - const [loadingDelete, setLoadingDelete] = useState(false); - const [loadingSave, setLoadingSave] = useState(false); - const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] = - useState(false); - const authPageSettingsRef = useRef(null); - - const form = useForm({ - resolver: zodResolver(GeneralFormSchema), - defaultValues: { - name: org?.org.name, - subnet: org?.org.subnet || "", // Add default value for subnet - requireTwoFactor: org?.org.requireTwoFactor || false, - maxSessionLengthHours: org?.org.maxSessionLengthHours || null, - passwordExpiryDays: org?.org.passwordExpiryDays || null, - settingsLogRetentionDaysRequest: - org.org.settingsLogRetentionDaysRequest ?? 15, - settingsLogRetentionDaysAccess: - org.org.settingsLogRetentionDaysAccess ?? 15, - settingsLogRetentionDaysAction: - org.org.settingsLogRetentionDaysAction ?? 15 - }, - mode: "onChange" - }); - - // Track initial security policy values - const initialSecurityValues = { - requireTwoFactor: org?.org.requireTwoFactor || false, - maxSessionLengthHours: org?.org.maxSessionLengthHours || null, - passwordExpiryDays: org?.org.passwordExpiryDays || null - }; - - // Check if security policies have changed - const hasSecurityPolicyChanged = () => { - const currentValues = form.getValues(); - return ( - currentValues.requireTwoFactor !== - initialSecurityValues.requireTwoFactor || - currentValues.maxSessionLengthHours !== - initialSecurityValues.maxSessionLengthHours || - currentValues.passwordExpiryDays !== - initialSecurityValues.passwordExpiryDays - ); - }; - - async function deleteOrg() { - setLoadingDelete(true); - try { - const res = await api.delete>( - `/org/${org?.org.orgId}` - ); - toast({ - title: t("orgDeleted"), - description: t("orgDeletedMessage") - }); - if (res.status === 200) { - pickNewOrgAndNavigate(); - } - } catch (err) { - console.error(err); - toast({ - variant: "destructive", - title: t("orgErrorDelete"), - description: formatAxiosError(err, t("orgErrorDeleteMessage")) - }); - } finally { - setLoadingDelete(false); - } - } + const router = useRouter(); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [loadingDelete, startTransition] = useTransition(); + const { user } = useUserContext(); async function pickNewOrgAndNavigate() { try { @@ -224,65 +93,29 @@ export default function GeneralPage() { }); } } - - async function onSubmit(data: GeneralFormValues) { - // Check if security policies have changed - if (hasSecurityPolicyChanged()) { - setIsSecurityPolicyConfirmOpen(true); - return; - } - - await performSave(data); - } - - async function performSave(data: GeneralFormValues) { - setLoadingSave(true); - + async function deleteOrg() { try { - const reqData = { - name: data.name, - settingsLogRetentionDaysRequest: - data.settingsLogRetentionDaysRequest, - settingsLogRetentionDaysAccess: - data.settingsLogRetentionDaysAccess, - settingsLogRetentionDaysAction: - data.settingsLogRetentionDaysAction - } as any; - if (build !== "oss") { - reqData.requireTwoFactor = data.requireTwoFactor || false; - reqData.maxSessionLengthHours = data.maxSessionLengthHours; - reqData.passwordExpiryDays = data.passwordExpiryDays; - } - - // Update organization - await api.post(`/org/${org?.org.orgId}`, reqData); - - // Also save auth page settings if they have unsaved changes - if ( - build === "saas" && - authPageSettingsRef.current?.hasUnsavedChanges() - ) { - await authPageSettingsRef.current.saveAuthSettings(); - } - + const res = await api.delete>( + `/org/${org.orgId}` + ); toast({ - title: t("orgUpdated"), - description: t("orgUpdatedDescription") + title: t("orgDeleted"), + description: t("orgDeletedMessage") }); - router.refresh(); - } catch (e) { + if (res.status === 200) { + pickNewOrgAndNavigate(); + } + } catch (err) { + console.error(err); toast({ variant: "destructive", - title: t("orgErrorUpdate"), - description: formatAxiosError(e, t("orgErrorUpdateMessage")) + title: t("orgErrorDelete"), + description: formatAxiosError(err, t("orgErrorDeleteMessage")) }); - } finally { - setLoadingSave(false); } } - return ( - + <> { @@ -295,548 +128,20 @@ export default function GeneralPage() {
} buttonText={t("orgDeleteConfirm")} - onConfirm={deleteOrg} - string={org?.org.name || ""} + onConfirm={async () => startTransition(deleteOrg)} + string={org.name || ""} title={t("orgDelete")} /> - -

{t("securityPolicyChangeDescription")}

-
- } - buttonText={t("saveSettings")} - onConfirm={() => performSave(form.getValues())} - string={t("securityPolicyChangeConfirmMessage")} - title={t("securityPolicyChangeWarning")} - warningText={t("securityPolicyChangeWarningText")} - /> - -
- - - - - {t("general")} - - - {t("orgGeneralSettingsDescription")} - - - - - ( - - {t("name")} - - - - - - {t("orgDisplayName")} - - - )} - /> - ( - - {t("subnet")} - - - - - - {t("subnetDescription")} - - - )} - /> - - - - - - - - {t("logRetention")} - - - {t("logRetentionDescription")} - - - - - ( - - - {t("logRetentionRequestLabel")} - - - - - - - )} - /> - - {build != "oss" && ( - <> - - - { - const isDisabled = - (build == "saas" && - !subscription?.subscribed) || - (build == "enterprise" && - !isUnlocked()); - - return ( - - - {t( - "logRetentionAccessLabel" - )} - - - - - - - ); - }} - /> - { - const isDisabled = - (build == "saas" && - !subscription?.subscribed) || - (build == "enterprise" && - !isUnlocked()); - - return ( - - - {t( - "logRetentionActionLabel" - )} - - - - - - - ); - }} - /> - - )} - - - - - {build !== "oss" && ( - - - - {t("securitySettings")} - - - {t("securitySettingsDescription")} - - - - - - { - const isDisabled = - isSecurityFeatureDisabled(); - - return ( - -
- - { - if ( - !isDisabled - ) { - form.setValue( - "requireTwoFactor", - val - ); - } - }} - /> - -
- - - {t( - "requireTwoFactorDescription" - )} - -
- ); - }} - /> - { - const isDisabled = - isSecurityFeatureDisabled(); - - return ( - - - {t("maxSessionLength")} - - - - - - - {t( - "maxSessionLengthDescription" - )} - - - ); - }} - /> - { - const isDisabled = - isSecurityFeatureDisabled(); - - return ( - - - {t( - "passwordExpiryDays" - )} - - - - - - - {t( - "editPasswordExpiryDescription" - )} - - - ); - }} - /> -
-
-
- )} -
- - - {build === "saas" && } - -
- {build !== "saas" && ( + + + + {t("dangerSection")} + + + {t("dangerSectionDescription")} + + + - )} + + + + ); +} + +function GeneralSectionForm({ org }: SectionFormProps) { + const { updateOrg } = useOrgContext(); + const form = useForm({ + resolver: zodResolver( + GeneralFormSchema.pick({ + name: true, + subnet: true + }) + ), + defaultValues: { + name: org.name, + subnet: org.subnet || "" // Add default value for subnet + }, + mode: "onChange" + }); + const t = useTranslations(); + const router = useRouter(); + + const [, formAction, loadingSave] = useActionState(performSave, null); + const api = createApiClient(useEnvContext()); + + async function performSave() { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + + try { + const reqData = { + name: data.name + } as any; + + // Update organization + await api.post(`/org/${org.orgId}`, reqData); + + // Update the org context to reflect the change in the info card + updateOrg({ + name: data.name + }); + + toast({ + title: t("orgUpdated"), + description: t("orgUpdatedDescription") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("orgErrorUpdate"), + description: formatAxiosError(e, t("orgErrorUpdateMessage")) + }); + } + } + + return ( + + + {t("general")} + + {t("orgGeneralSettingsDescription")} + + + + +
+ + ( + + {t("name")} + + + + + + {t("orgDisplayName")} + + + )} + /> + + +
+
+ +
- +
); } diff --git a/src/app/[orgId]/settings/general/security/page.tsx b/src/app/[orgId]/settings/general/security/page.tsx new file mode 100644 index 000000000..e7d0d85c8 --- /dev/null +++ b/src/app/[orgId]/settings/general/security/page.tsx @@ -0,0 +1,966 @@ +"use client"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { Button } from "@app/components/ui/button"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { toast } from "@app/hooks/useToast"; +import { useState, useRef, useActionState, type ComponentRef } from "react"; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@/components/ui/select"; + +import { z } from "zod"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { formatAxiosError } from "@app/lib/api"; +import { useRouter } from "next/navigation"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionHeader, + SettingsSectionTitle, + SettingsSectionDescription, + SettingsSectionBody, + SettingsSectionForm +} from "@app/components/Settings"; +import { useTranslations } from "next-intl"; +import { build } from "@server/build"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import type { OrgContextType } from "@app/contexts/orgContext"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; + +// Session length options in hours +const SESSION_LENGTH_OPTIONS = [ + { value: null, labelKey: "unenforced" }, + { value: 1, labelKey: "1Hour" }, + { value: 3, labelKey: "3Hours" }, + { value: 6, labelKey: "6Hours" }, + { value: 12, labelKey: "12Hours" }, + { value: 24, labelKey: "1DaySession" }, + { value: 72, labelKey: "3Days" }, + { value: 168, labelKey: "7Days" }, + { value: 336, labelKey: "14Days" }, + { value: 720, labelKey: "30DaysSession" }, + { value: 2160, labelKey: "90DaysSession" }, + { value: 4320, labelKey: "180DaysSession" } +]; + +// Password expiry options in days - will be translated in component +const PASSWORD_EXPIRY_OPTIONS = [ + { value: null, labelKey: "neverExpire" }, + { value: 1, labelKey: "1Day" }, + { value: 30, labelKey: "30Days" }, + { value: 60, labelKey: "60Days" }, + { value: 90, labelKey: "90Days" }, + { value: 180, labelKey: "180Days" }, + { value: 365, labelKey: "1Year" } +]; + +// Schema for security organization settings +const SecurityFormSchema = z.object({ + requireTwoFactor: z.boolean().optional(), + maxSessionLengthHours: z.number().nullable().optional(), + passwordExpiryDays: z.number().nullable().optional(), + settingsLogRetentionDaysRequest: z.number(), + settingsLogRetentionDaysAccess: z.number(), + settingsLogRetentionDaysAction: z.number(), + settingsLogRetentionDaysConnection: z.number() +}); + +const LOG_RETENTION_OPTIONS = [ + { label: "logRetentionDisabled", value: 0 }, + { label: "logRetention3Days", value: 3 }, + { label: "logRetention7Days", value: 7 }, + { label: "logRetention14Days", value: 14 }, + { label: "logRetention30Days", value: 30 }, + { label: "logRetention90Days", value: 90 }, + ...(build != "saas" + ? [ + { label: "logRetentionForever", value: -1 }, + { label: "logRetentionEndOfFollowingYear", value: 9001 } + ] + : []) +]; + +type SectionFormProps = { + org: OrgContextType["org"]["org"]; +}; + +export default function SecurityPage() { + const { org } = useOrgContext(); + const { env } = useEnvContext(); + return ( + + + {!env.flags.disableEnterpriseFeatures && ( + + )} + + ); +} + +function LogRetentionSectionForm({ org }: SectionFormProps) { + const form = useForm({ + resolver: zodResolver( + SecurityFormSchema.pick({ + settingsLogRetentionDaysRequest: true, + settingsLogRetentionDaysAccess: true, + settingsLogRetentionDaysAction: true, + settingsLogRetentionDaysConnection: true + }) + ), + defaultValues: { + settingsLogRetentionDaysRequest: + org.settingsLogRetentionDaysRequest ?? 15, + settingsLogRetentionDaysAccess: + org.settingsLogRetentionDaysAccess ?? 15, + settingsLogRetentionDaysAction: + org.settingsLogRetentionDaysAction ?? 15, + settingsLogRetentionDaysConnection: + org.settingsLogRetentionDaysConnection ?? 15 + }, + mode: "onChange" + }); + + const router = useRouter(); + const t = useTranslations(); + const { isPaidUser, subscriptionTier } = usePaidStatus(); + + const [, formAction, loadingSave] = useActionState(performSave, null); + const { env } = useEnvContext(); + const api = createApiClient({ env }); + + async function performSave() { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + + try { + const reqData = { + settingsLogRetentionDaysRequest: + data.settingsLogRetentionDaysRequest, + settingsLogRetentionDaysAccess: + data.settingsLogRetentionDaysAccess, + settingsLogRetentionDaysAction: + data.settingsLogRetentionDaysAction, + settingsLogRetentionDaysConnection: + data.settingsLogRetentionDaysConnection + } as any; + + // Update organization + await api.post(`/org/${org.orgId}`, reqData); + + toast({ + title: t("orgUpdated"), + description: t("orgUpdatedDescription") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("orgErrorUpdate"), + description: formatAxiosError(e, t("orgErrorUpdateMessage")) + }); + } + } + + return ( + + + {t("logRetention")} + + {t("logRetentionDescription")} + + + + +
+ + ( + + + {t("logRetentionRequestLabel")} + + + + + + + )} + /> + + {!env.flags.disableEnterpriseFeatures && ( + <> + + + { + const isDisabled = !isPaidUser( + tierMatrix.accessLogs + ); + + return ( + + + {t( + "logRetentionAccessLabel" + )} + + + + + + + ); + }} + /> + { + const isDisabled = !isPaidUser( + tierMatrix.actionLogs + ); + + return ( + + + {t( + "logRetentionActionLabel" + )} + + + + + + + ); + }} + /> + { + const isDisabled = !isPaidUser( + tierMatrix.connectionLogs + ); + + return ( + + + {t( + "logRetentionConnectionLabel" + )} + + + + + + + ); + }} + /> + + )} + + +
+
+ +
+ +
+
+ ); +} + +function SecuritySettingsSectionForm({ org }: SectionFormProps) { + const router = useRouter(); + const form = useForm({ + resolver: zodResolver( + SecurityFormSchema.pick({ + requireTwoFactor: true, + maxSessionLengthHours: true, + passwordExpiryDays: true + }) + ), + defaultValues: { + requireTwoFactor: org.requireTwoFactor || false, + maxSessionLengthHours: org.maxSessionLengthHours || null, + passwordExpiryDays: org.passwordExpiryDays || null + }, + mode: "onChange" + }); + const t = useTranslations(); + const { isPaidUser } = usePaidStatus(); + + // Track initial security policy values + const initialSecurityValues = { + requireTwoFactor: org.requireTwoFactor || false, + maxSessionLengthHours: org.maxSessionLengthHours || null, + passwordExpiryDays: org.passwordExpiryDays || null + }; + + const [isSecurityPolicyConfirmOpen, setIsSecurityPolicyConfirmOpen] = + useState(false); + + // Check if security policies have changed + const hasSecurityPolicyChanged = () => { + const currentValues = form.getValues(); + return ( + currentValues.requireTwoFactor !== + initialSecurityValues.requireTwoFactor || + currentValues.maxSessionLengthHours !== + initialSecurityValues.maxSessionLengthHours || + currentValues.passwordExpiryDays !== + initialSecurityValues.passwordExpiryDays + ); + }; + + const [, formAction, loadingSave] = useActionState(onSubmit, null); + const api = createApiClient(useEnvContext()); + + const formRef = useRef>(null); + + async function onSubmit() { + // Check if security policies have changed + if (hasSecurityPolicyChanged()) { + setIsSecurityPolicyConfirmOpen(true); + return; + } + + await performSave(); + } + + async function performSave() { + const isValid = await form.trigger(); + if (!isValid) return; + + const data = form.getValues(); + + try { + const reqData = { + requireTwoFactor: data.requireTwoFactor || false, + maxSessionLengthHours: data.maxSessionLengthHours, + passwordExpiryDays: data.passwordExpiryDays + } as any; + + // Update organization + await api.post(`/org/${org.orgId}`, reqData); + + toast({ + title: t("orgUpdated"), + description: t("orgUpdatedDescription") + }); + router.refresh(); + } catch (e) { + toast({ + variant: "destructive", + title: t("orgErrorUpdate"), + description: formatAxiosError(e, t("orgErrorUpdateMessage")) + }); + } + } + + return ( + <> + +

{t("securityPolicyChangeDescription")}

+
+ } + buttonText={t("saveSettings")} + onConfirm={performSave} + string={t("securityPolicyChangeConfirmMessage")} + title={t("securityPolicyChangeWarning")} + warningText={t("securityPolicyChangeWarningText")} + /> + + + + {t("securitySettings")} + + + {t("securitySettingsDescription")} + + + + +
+ + + + { + const isDisabled = !isPaidUser( + tierMatrix.twoFactorEnforcement + ); + + return ( + +
+ + { + if ( + !isDisabled + ) { + form.setValue( + "requireTwoFactor", + val + ); + } + }} + /> + +
+ + + {t( + "requireTwoFactorDescription" + )} + +
+ ); + }} + /> + { + const isDisabled = !isPaidUser( + tierMatrix.sessionDurationPolicies + ); + + return ( + + + {t("maxSessionLength")} + + + + + + + {t( + "maxSessionLengthDescription" + )} + + + ); + }} + /> + { + const isDisabled = !isPaidUser( + tierMatrix.passwordExpirationPolicies + ); + + return ( + + + {t("passwordExpiryDays")} + + + + + + + {t( + "editPasswordExpiryDescription" + )} + + + ); + }} + /> + + +
+
+ +
+ +
+
+ + ); +} diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 8f44c23c6..8ee7b1dc0 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -77,12 +77,16 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { } } catch (e) {} + const primaryOrg = orgs.find((o) => o.orgId === params.orgId)?.isPrimaryOrg; + return ( {children} diff --git a/src/app/[orgId]/settings/logs/access/page.tsx b/src/app/[orgId]/settings/logs/access/page.tsx index d5b12ddbf..a0f1b5386 100644 --- a/src/app/[orgId]/settings/logs/access/page.tsx +++ b/src/app/[orgId]/settings/logs/access/page.tsx @@ -13,13 +13,13 @@ import { ArrowUpRight, Key, User } from "lucide-react"; import Link from "next/link"; import { ColumnFilter } from "@app/components/ColumnFilter"; 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 { getSevenDaysAgo } from "@app/lib/getSevenDaysAgo"; import axios from "axios"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; export default function GeneralPage() { const router = useRouter(); @@ -27,8 +27,8 @@ export default function GeneralPage() { const api = createApiClient(useEnvContext()); const t = useTranslations(); const { orgId } = useParams(); - const subscription = useSubscriptionStatusContext(); - const { isUnlocked } = useLicenseStatusContext(); + + const { isPaidUser } = usePaidStatus(); const [rows, setRows] = useState([]); const [isRefreshing, setIsRefreshing] = useState(false); @@ -207,10 +207,7 @@ export default function GeneralPage() { } ) => { console.log("Date range changed:", { startDate, endDate, page, size }); - if ( - (build == "saas" && !subscription?.subscribed) || - (build == "enterprise" && !isUnlocked()) - ) { + if (!isPaidUser(tierMatrix.accessLogs) || build === "oss") { console.log( "Access denied: subscription inactive or license locked" ); @@ -468,7 +465,11 @@ export default function GeneralPage() { cell: ({ row }) => { return ( + + ); + } + return ( + + {row.original.resourceName ?? "—"} + + ); + } + }, + { + accessorKey: "clientName", + header: ({ column }) => { + return ( +
+ {t("client")} + ({ + value: c.id.toString(), + label: c.name + }))} + selectedValue={filters.clientId} + onValueChange={(value) => + handleFilterChange("clientId", value) + } + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + const clientType = row.original.clientType === "olm" ? "machine" : "user"; + if (row.original.clientName && row.original.clientNiceId) { + return ( + + + + ); + } + return ( + + {row.original.clientName ?? "—"} + + ); + } + }, + { + accessorKey: "userEmail", + header: ({ column }) => { + return ( +
+ {t("user")} + ({ + value: u.id, + label: u.email || u.id + }))} + selectedValue={filters.userId} + onValueChange={(value) => + handleFilterChange("userId", value) + } + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + if (row.original.userEmail || row.original.userId) { + return ( + + + {row.original.userEmail ?? row.original.userId} + + ); + } + return ; + } + }, + { + accessorKey: "sourceAddr", + header: ({ column }) => { + return t("sourceAddress"); + }, + cell: ({ row }) => { + return ( + + {row.original.sourceAddr} + + ); + } + }, + { + accessorKey: "destAddr", + header: ({ column }) => { + return ( +
+ {t("destinationAddress")} + ({ + value: addr, + label: addr + }))} + selectedValue={filters.destAddr} + onValueChange={(value) => + handleFilterChange("destAddr", value) + } + searchPlaceholder="Search..." + emptyMessage="None found" + /> +
+ ); + }, + cell: ({ row }) => { + return ( + + {row.original.destAddr} + + ); + } + }, + { + accessorKey: "duration", + header: ({ column }) => { + return t("duration"); + }, + cell: ({ row }) => { + return ( + + {formatDuration( + row.original.startedAt, + row.original.endedAt + )} + + ); + } + } + ]; + + const renderExpandedRow = (row: any) => { + return ( +
+
+
+ {/*
+ Connection Details +
*/} +
+ Session ID:{" "} + + {row.sessionId ?? "—"} + +
+
+ Protocol:{" "} + {row.protocol?.toUpperCase() ?? "—"} +
+
+ Source:{" "} + + {row.sourceAddr ?? "—"} + +
+
+ Destination:{" "} + + {row.destAddr ?? "—"} + +
+
+
+ {/*
+ Resource & Site +
*/} + {/*
+ Resource:{" "} + {row.resourceName ?? "—"} + {row.resourceNiceId && ( + + ({row.resourceNiceId}) + + )} +
*/} +
+ Site: {row.siteName ?? "—"} + {row.siteNiceId && ( + + ({row.siteNiceId}) + + )} +
+
+ Site ID: {row.siteId ?? "—"} +
+
+ Started At:{" "} + {row.startedAt + ? new Date( + row.startedAt * 1000 + ).toLocaleString() + : "—"} +
+
+ Ended At:{" "} + {row.endedAt + ? new Date( + row.endedAt * 1000 + ).toLocaleString() + : "Active"} +
+
+ Duration:{" "} + {formatDuration(row.startedAt, row.endedAt)} +
+ {/*
+ Resource ID:{" "} + {row.siteResourceId ?? "—"} +
*/} +
+
+ {/*
+ Client & Transfer +
*/} + {/*
+ Bytes Sent (TX):{" "} + {formatBytes(row.bytesTx)} +
*/} + {/*
+ Bytes Received (RX):{" "} + {formatBytes(row.bytesRx)} +
*/} + {/*
+ Total Transfer:{" "} + {formatBytes( + (row.bytesTx ?? 0) + (row.bytesRx ?? 0) + )} +
*/} +
+
+
+ ); + }; + + return ( + <> + + + + + startTransition(exportData)} + isExporting={isExporting} + onDateRangeChange={handleDateRangeChange} + dateRange={{ + start: dateRange.startDate, + end: dateRange.endDate + }} + defaultSort={{ + id: "startedAt", + desc: true + }} + // Server-side pagination props + totalCount={totalCount} + currentPage={currentPage} + pageSize={pageSize} + onPageChange={handlePageChange} + onPageSizeChange={handlePageSizeChange} + isLoading={isLoading} + // Row expansion props + expandable={true} + renderExpandedRow={renderExpandedRow} + disabled={ + !isPaidUser(tierMatrix.connectionLogs) || build === "oss" + } + /> + + ); +} diff --git a/src/app/[orgId]/settings/logs/page.tsx b/src/app/[orgId]/settings/logs/page.tsx index 45b5a7de5..d9663e721 100644 --- a/src/app/[orgId]/settings/logs/page.tsx +++ b/src/app/[orgId]/settings/logs/page.tsx @@ -1,54 +1,3 @@ -"use client"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import AuthPageSettings, { - AuthPageSettingsRef -} from "@app/components/private/AuthPageSettings"; - -import { Button } from "@app/components/ui/button"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { userOrgUserContext } from "@app/hooks/useOrgUserContext"; -import { toast } from "@app/hooks/useToast"; -import { useState, useRef } from "react"; -import { - Form, - FormControl, - FormDescription, - FormField, - FormItem, - FormLabel, - FormMessage -} from "@/components/ui/form"; -import { Input } from "@/components/ui/input"; - -import { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { formatAxiosError } from "@app/lib/api"; -import { AxiosResponse } from "axios"; -import { DeleteOrgResponse, ListUserOrgsResponse } from "@server/routers/org"; -import { useRouter } from "next/navigation"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionHeader, - SettingsSectionTitle, - SettingsSectionDescription, - SettingsSectionBody, - SettingsSectionForm, - SettingsSectionFooter -} from "@app/components/Settings"; -import { useUserContext } from "@app/hooks/useUserContext"; -import { useTranslations } from "next-intl"; -import { build } from "@server/build"; - export default function GeneralPage() { - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const router = useRouter(); - const api = createApiClient(useEnvContext()); - const t = useTranslations(); - const { env } = useEnvContext(); - - return

dfas

; + return null; } diff --git a/src/app/[orgId]/settings/logs/request/page.tsx b/src/app/[orgId]/settings/logs/request/page.tsx index 741dd9942..4a1fe3cd9 100644 --- a/src/app/[orgId]/settings/logs/request/page.tsx +++ b/src/app/[orgId]/settings/logs/request/page.tsx @@ -16,6 +16,7 @@ import Link from "next/link"; import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useEffect, useState, useTransition } from "react"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; +import { build } from "@server/build"; export default function GeneralPage() { const router = useRouter(); @@ -110,6 +111,9 @@ export default function GeneralPage() { // Trigger search with default values on component mount useEffect(() => { + if (build === "oss") { + return; + } const defaultRange = getDefaultDateRange(); queryDateTime( defaultRange.startDate, diff --git a/src/app/[orgId]/settings/provisioning/page.tsx b/src/app/[orgId]/settings/provisioning/page.tsx new file mode 100644 index 000000000..e8b53104f --- /dev/null +++ b/src/app/[orgId]/settings/provisioning/page.tsx @@ -0,0 +1,60 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import SiteProvisioningKeysTable, { + SiteProvisioningKeyRow +} from "../../../../components/SiteProvisioningKeysTable"; +import { ListSiteProvisioningKeysResponse } from "@server/routers/siteProvisioning/types"; +import { getTranslations } from "next-intl/server"; +import { TierFeature, tierMatrix } from "@server/lib/billing/tierMatrix"; + +type ProvisioningPageProps = { + params: Promise<{ orgId: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ProvisioningPage(props: ProvisioningPageProps) { + const params = await props.params; + const t = await getTranslations(); + + let siteProvisioningKeys: ListSiteProvisioningKeysResponse["siteProvisioningKeys"] = + []; + try { + const res = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/site-provisioning-keys`, + await authCookieHeader() + ); + siteProvisioningKeys = res.data.data.siteProvisioningKeys; + } catch (e) {} + + const rows: SiteProvisioningKeyRow[] = siteProvisioningKeys.map((k) => ({ + name: k.name, + id: k.siteProvisioningKeyId, + key: `${k.siteProvisioningKeyId}••••••••••••••••••••${k.lastChars}`, + createdAt: k.createdAt, + lastUsed: k.lastUsed, + maxBatchSize: k.maxBatchSize, + numUsed: k.numUsed, + validUntil: k.validUntil + })); + + return ( + <> + + + + + + + ); +} diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index 49ccb97fe..f0f582f0f 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -1,6 +1,7 @@ import type { InternalResourceRow } from "@app/components/ClientResourcesTable"; import ClientResourcesTable from "@app/components/ClientResourcesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import PrivateResourcesBanner from "@app/components/PrivateResourcesBanner"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; import { getCachedOrg } from "@app/lib/api/getCachedOrg"; @@ -13,7 +14,7 @@ import { redirect } from "next/navigation"; export interface ClientResourcesPageProps { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; + searchParams: Promise>; } export default async function ClientResourcesPage( @@ -21,22 +22,24 @@ export default async function ClientResourcesPage( ) { const params = await props.params; const t = await getTranslations(); - - let resources: ListResourcesResponse["resources"] = []; - try { - const res = await internal.get>( - `/org/${params.orgId}/resources`, - await authCookieHeader() - ); - resources = res.data.data.resources; - } catch (e) {} + const searchParams = new URLSearchParams(await props.searchParams); let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; + let pagination: ListResourcesResponse["pagination"] = { + total: 0, + page: 1, + pageSize: 20 + }; try { const res = await internal.get< AxiosResponse - >(`/org/${params.orgId}/site-resources`, await authCookieHeader()); - siteResources = res.data.data.siteResources; + >( + `/org/${params.orgId}/site-resources?${searchParams.toString()}`, + await authCookieHeader() + ); + const responseData = res.data.data; + siteResources = responseData.siteResources; + pagination = responseData.pagination; } catch (e) {} let org = null; @@ -66,8 +69,14 @@ export default async function ClientResourcesPage( destination: siteResource.destination, // destinationPort: siteResource.destinationPort, alias: siteResource.alias || null, + aliasAddress: siteResource.aliasAddress || null, siteNiceId: siteResource.siteNiceId, - niceId: siteResource.niceId + niceId: siteResource.niceId, + tcpPortRangeString: siteResource.tcpPortRangeString || null, + udpPortRangeString: siteResource.udpPortRangeString || null, + disableIcmp: siteResource.disableIcmp || false, + authDaemonMode: siteResource.authDaemonMode ?? null, + authDaemonPort: siteResource.authDaemonPort ?? null }; } ); @@ -78,13 +87,16 @@ export default async function ClientResourcesPage( description={t("clientResourceDescription")} /> + + diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx index 27f4fd739..414a9b652 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -1,21 +1,21 @@ "use client"; -import { useEffect, useState } from "react"; -import { ListRolesResponse } from "@server/routers/role"; -import { toast } from "@app/hooks/useToast"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { useResourceContext } from "@app/hooks/useResourceContext"; -import { AxiosResponse } from "axios"; -import { formatAxiosError } from "@app/lib/api"; +import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm"; +import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm"; import { - GetResourceWhitelistResponse, - ListResourceRolesResponse, - ListResourceUsersResponse -} from "@server/routers/resource"; + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; import { Button } from "@app/components/ui/button"; -import { z } from "zod"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; import { Form, FormControl, @@ -25,32 +25,7 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; -import { ListUsersResponse } from "@server/routers/user"; -import { Binary, Key, Bot } from "lucide-react"; -import SetResourcePasswordForm from "components/SetResourcePasswordForm"; -import SetResourcePincodeForm from "@app/components/SetResourcePincodeForm"; -import SetResourceHeaderAuthForm from "@app/components/SetResourceHeaderAuthForm"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionTitle, - SettingsSectionHeader, - SettingsSectionDescription, - SettingsSectionBody, - SettingsSectionFooter, - SettingsSectionForm -} from "@app/components/Settings"; -import { SwitchInput } from "@app/components/SwitchInput"; import { InfoPopup } from "@app/components/ui/info-popup"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; -import { useRouter } from "next/navigation"; -import { UserType } from "@server/types/UserTypes"; -import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert"; -import { InfoIcon } from "lucide-react"; -import { useTranslations } from "next-intl"; -import { CheckboxWithLabel } from "@app/components/ui/checkbox"; import { Select, SelectContent, @@ -58,10 +33,34 @@ import { SelectTrigger, SelectValue } from "@app/components/ui/select"; -import { Separator } from "@app/components/ui/separator"; +import type { ResourceContextType } from "@app/contexts/resourceContext"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useOrgContext } from "@app/hooks/useOrgContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { useResourceContext } from "@app/hooks/useResourceContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { getUserDisplayName } from "@app/lib/getUserDisplayName"; +import { orgQueries, resourceQueries } from "@app/lib/queries"; +import { zodResolver } from "@hookform/resolvers/zod"; import { build } from "@server/build"; -import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; -import { TierId } from "@server/lib/billing/tiers"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { UserType } from "@server/types/UserTypes"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import SetResourcePasswordForm from "components/SetResourcePasswordForm"; +import { Binary, Bot, InfoIcon, Key } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { + useActionState, + useEffect, + useMemo, + useRef, + useState, + useTransition +} from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; const UsersRolesFormSchema = z.object({ roles: z.array( @@ -98,16 +97,90 @@ export default function ResourceAuthenticationPage() { const router = useRouter(); const t = useTranslations(); - const subscription = useSubscriptionStatusContext(); + const { isPaidUser } = usePaidStatus(); - const [pageLoading, setPageLoading] = useState(true); + const queryClient = useQueryClient(); + const { data: resourceRoles = [], isLoading: isLoadingResourceRoles } = + useQuery( + resourceQueries.resourceRoles({ + resourceId: resource.resourceId + }) + ); + const { data: resourceUsers = [], isLoading: isLoadingResourceUsers } = + useQuery( + resourceQueries.resourceUsers({ + resourceId: resource.resourceId + }) + ); - const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>( - [] + const { data: whitelist = [], isLoading: isLoadingWhiteList } = useQuery( + resourceQueries.resourceWhitelist({ + resourceId: resource.resourceId + }) ); - const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>( - [] + + const { data: orgRoles = [], isLoading: isLoadingOrgRoles } = useQuery( + orgQueries.roles({ + orgId: org.org.orgId + }) ); + const { data: orgUsers = [], isLoading: isLoadingOrgUsers } = useQuery( + orgQueries.users({ + orgId: org.org.orgId + }) + ); + const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery({ + ...orgQueries.identityProviders({ + orgId: org.org.orgId, + useOrgOnlyIdp: env.app.identityProviderMode === "org" + }), + enabled: isPaidUser(tierMatrix.orgOidc) + }); + + const pageLoading = + isLoadingOrgRoles || + isLoadingOrgUsers || + isLoadingResourceRoles || + isLoadingResourceUsers || + isLoadingWhiteList || + isLoadingOrgIdps; + + const allRoles = useMemo(() => { + return orgRoles + .map((role) => ({ + id: role.roleId.toString(), + text: role.name + })) + .filter((role) => role.text !== "Admin"); + }, [orgRoles]); + + const allUsers = useMemo(() => { + return orgUsers.map((user) => ({ + id: user.id.toString(), + text: `${getUserDisplayName({ + email: user.email, + username: user.username + })}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` + })); + }, [orgUsers]); + + const allIdps = useMemo(() => { + if (build === "saas") { + if (isPaidUser(tierMatrix.orgOidc)) { + return orgIdps.map((idp) => ({ + id: idp.idpId, + text: idp.name + })); + } + } else { + return orgIdps.map((idp) => ({ + id: idp.idpId, + text: idp.name + })); + } + return []; + }, [orgIdps]); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< number | null >(null); @@ -115,26 +188,15 @@ export default function ResourceAuthenticationPage() { number | null >(null); - const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< - number | null - >(null); + const [ssoEnabled, setSsoEnabled] = useState(resource.sso ?? false); - const [ssoEnabled, setSsoEnabled] = useState(resource.sso); - // const [blockAccess, setBlockAccess] = useState(resource.blockAccess); - const [whitelistEnabled, setWhitelistEnabled] = useState( - resource.emailWhitelistEnabled - ); + useEffect(() => { + setSsoEnabled(resource.sso ?? false); + }, [resource.sso]); - const [autoLoginEnabled, setAutoLoginEnabled] = useState( - resource.skipToIdpId !== null && resource.skipToIdpId !== undefined - ); const [selectedIdpId, setSelectedIdpId] = useState( resource.skipToIdpId || null ); - const [allIdps, setAllIdps] = useState<{ id: number; text: string }[]>([]); - - const [loadingSaveUsersRoles, setLoadingSaveUsersRoles] = useState(false); - const [loadingSaveWhitelist, setLoadingSaveWhitelist] = useState(false); const [loadingRemoveResourcePassword, setLoadingRemoveResourcePassword] = useState(false); @@ -159,177 +221,53 @@ export default function ResourceAuthenticationPage() { defaultValues: { emails: [] } }); + const hasInitializedRef = useRef(false); + useEffect(() => { - const fetchData = async () => { - try { - const [ - rolesResponse, - resourceRolesResponse, - usersResponse, - resourceUsersResponse, - whitelist, - idpsResponse - ] = await Promise.all([ - api.get>( - `/org/${org?.org.orgId}/roles` - ), - api.get>( - `/resource/${resource.resourceId}/roles` - ), - api.get>( - `/org/${org?.org.orgId}/users` - ), - api.get>( - `/resource/${resource.resourceId}/users` - ), - api.get>( - `/resource/${resource.resourceId}/whitelist` - ), - api.get< - AxiosResponse<{ - idps: { idpId: number; name: string }[]; - }> - >(build === "saas" ? `/org/${org?.org.orgId}/idp` : "/idp") - ]); + if (pageLoading || hasInitializedRef.current) return; - setAllRoles( - rolesResponse.data.data.roles - .map((role) => ({ - id: role.roleId.toString(), - text: role.name - })) - .filter((role) => role.text !== "Admin") - ); + usersRolesForm.setValue( + "roles", + resourceRoles + .map((i) => ({ + id: i.roleId.toString(), + text: i.name + })) + .filter((role) => role.text !== "Admin") + ); + usersRolesForm.setValue( + "users", + resourceUsers.map((i) => ({ + id: i.userId.toString(), + text: `${getUserDisplayName({ + email: i.email, + username: i.username + })}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` + })) + ); - usersRolesForm.setValue( - "roles", - resourceRolesResponse.data.data.roles - .map((i) => ({ - id: i.roleId.toString(), - text: i.name - })) - .filter((role) => role.text !== "Admin") - ); + whitelistForm.setValue( + "emails", + whitelist.map((w) => ({ + id: w.email, + text: w.email + })) + ); + hasInitializedRef.current = true; + }, [pageLoading, resourceRoles, resourceUsers, whitelist, orgIdps]); - setAllUsers( - usersResponse.data.data.users.map((user) => ({ - id: user.id.toString(), - text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` - })) - ); + const [, submitUserRolesForm, loadingSaveUsersRoles] = useActionState( + onSubmitUsersRoles, + null + ); - usersRolesForm.setValue( - "users", - resourceUsersResponse.data.data.users.map((i) => ({ - id: i.userId.toString(), - text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` - })) - ); + async function onSubmitUsersRoles() { + const isValid = usersRolesForm.trigger(); + if (!isValid) return; - whitelistForm.setValue( - "emails", - whitelist.data.data.whitelist.map((w) => ({ - id: w.email, - text: w.email - })) - ); + const data = usersRolesForm.getValues(); - if (build === "saas") { - if (subscription?.subscribed) { - setAllIdps( - idpsResponse.data.data.idps.map((idp) => ({ - id: idp.idpId, - text: idp.name - })) - ); - } - } else { - setAllIdps( - idpsResponse.data.data.idps.map((idp) => ({ - id: idp.idpId, - text: idp.name - })) - ); - } - - if ( - autoLoginEnabled && - !selectedIdpId && - idpsResponse.data.data.idps.length > 0 - ) { - setSelectedIdpId(idpsResponse.data.data.idps[0].idpId); - } - - setPageLoading(false); - } catch (e) { - console.error(e); - toast({ - variant: "destructive", - title: t("resourceErrorAuthFetch"), - description: formatAxiosError( - e, - t("resourceErrorAuthFetchDescription") - ) - }); - } - }; - - fetchData(); - }, []); - - async function saveWhitelist() { - setLoadingSaveWhitelist(true); try { - await api.post(`/resource/${resource.resourceId}`, { - emailWhitelistEnabled: whitelistEnabled - }); - - if (whitelistEnabled) { - await api.post(`/resource/${resource.resourceId}/whitelist`, { - emails: whitelistForm.getValues().emails.map((i) => i.text) - }); - } - - updateResource({ - emailWhitelistEnabled: whitelistEnabled - }); - - toast({ - title: t("resourceWhitelistSave"), - description: t("resourceWhitelistSaveDescription") - }); - router.refresh(); - } catch (e) { - console.error(e); - toast({ - variant: "destructive", - title: t("resourceErrorWhitelistSave"), - description: formatAxiosError( - e, - t("resourceErrorWhitelistSaveDescription") - ) - }); - } finally { - setLoadingSaveWhitelist(false); - } - } - - async function onSubmitUsersRoles( - data: z.infer - ) { - try { - setLoadingSaveUsersRoles(true); - - // Validate that an IDP is selected if auto login is enabled - if (autoLoginEnabled && !selectedIdpId) { - toast({ - variant: "destructive", - title: t("error"), - description: t("selectIdpRequired") - }); - return; - } - const jobs = [ api.post(`/resource/${resource.resourceId}/roles`, { roleIds: data.roles.map((i) => parseInt(i.id)) @@ -339,7 +277,7 @@ export default function ResourceAuthenticationPage() { }), api.post(`/resource/${resource.resourceId}`, { sso: ssoEnabled, - skipToIdpId: autoLoginEnabled ? selectedIdpId : null + skipToIdpId: selectedIdpId }) ]; @@ -347,7 +285,7 @@ export default function ResourceAuthenticationPage() { updateResource({ sso: ssoEnabled, - skipToIdpId: autoLoginEnabled ? selectedIdpId : null + skipToIdpId: selectedIdpId }); updateAuthInfo({ @@ -358,6 +296,18 @@ export default function ResourceAuthenticationPage() { title: t("resourceAuthSettingsSave"), description: t("resourceAuthSettingsSaveDescription") }); + // invalidate resource queries + await queryClient.invalidateQueries( + resourceQueries.resourceUsers({ + resourceId: resource.resourceId + }) + ); + await queryClient.invalidateQueries( + resourceQueries.resourceRoles({ + resourceId: resource.resourceId + }) + ); + router.refresh(); } catch (e) { console.error(e); @@ -369,8 +319,6 @@ export default function ResourceAuthenticationPage() { t("resourceErrorUsersRolesSaveDescription") ) }); - } finally { - setLoadingSaveUsersRoles(false); } } @@ -439,7 +387,8 @@ export default function ResourceAuthenticationPage() { api.post(`/resource/${resource.resourceId}/header-auth`, { user: null, - password: null + password: null, + extendedCompatibility: null }) .then(() => { toast({ @@ -528,15 +477,13 @@ export default function ResourceAuthenticationPage() { setSsoEnabled(val)} />
@@ -661,85 +608,52 @@ export default function ResourceAuthenticationPage() { )} {ssoEnabled && allIdps.length > 0 && ( -
-
- { - setAutoLoginEnabled( - checked as boolean +
+ + - setSelectedIdpId( - parseInt(value) - ) - } - value={ - selectedIdpId - ? selectedIdpId.toString() - : undefined - } - > - - - - - {allIdps.map( - (idp) => ( - - { - idp.text - } - - ) - )} - - -
- )} + } + }} + value={ + selectedIdpId + ? selectedIdpId.toString() + : "none" + } + > + + + + + + {t("none")} + + {allIdps.map((idp) => ( + + {idp.text} + + ))} + + +

+ {t( + "defaultIdentityProviderDescription" + )} +

)} @@ -772,7 +686,7 @@ export default function ResourceAuthenticationPage() { {/* Password Protection */}
@@ -802,7 +716,7 @@ export default function ResourceAuthenticationPage() { {/* PIN Code Protection */}
@@ -832,7 +746,7 @@ export default function ResourceAuthenticationPage() { {/* Header Authentication Protection */}
@@ -864,136 +778,226 @@ export default function ResourceAuthenticationPage() { - - - - {t("otpEmailTitle")} - - - {t("otpEmailTitleDescription")} - - - - - {!env.email.emailEnabled && ( - - - - {t("otpEmailSmtpRequired")} - - - {t("otpEmailSmtpRequiredDescription")} - - - )} - - - {whitelistEnabled && env.email.emailEnabled && ( -
- - ( - - - - - - {/* @ts-ignore */} - { - return z - .email() - .or( - z - .string() - .regex( - /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, - { - message: - t( - "otpEmailErrorInvalid" - ) - } - ) - ) - .safeParse( - tag - ).success; - }} - setActiveTagIndex={ - setActiveEmailTagIndex - } - placeholder={t( - "otpEmailEnter" - )} - tags={ - whitelistForm.getValues() - .emails - } - setTags={( - newRoles - ) => { - whitelistForm.setValue( - "emails", - newRoles as [ - Tag, - ...Tag[] - ] - ); - }} - allowDuplicates={ - false - } - sortTags={true} - /> - - - {t( - "otpEmailEnterDescription" - )} - - - )} - /> - - - )} -
-
- - - -
+ ); } + +type OneTimePasswordFormSectionProps = Pick< + ResourceContextType, + "resource" | "updateResource" +> & { + whitelist: Array<{ email: string }>; + isLoadingWhiteList: boolean; +}; + +function OneTimePasswordFormSection({ + resource, + updateResource, + whitelist, + isLoadingWhiteList +}: OneTimePasswordFormSectionProps) { + const { env } = useEnvContext(); + const [whitelistEnabled, setWhitelistEnabled] = useState( + resource.emailWhitelistEnabled ?? false + ); + + useEffect(() => { + setWhitelistEnabled(resource.emailWhitelistEnabled); + }, [resource.emailWhitelistEnabled]); + + const queryClient = useQueryClient(); + + const [loadingSaveWhitelist, startTransition] = useTransition(); + const whitelistForm = useForm({ + resolver: zodResolver(whitelistSchema), + defaultValues: { emails: [] } + }); + const api = createApiClient({ env }); + const router = useRouter(); + const t = useTranslations(); + + const [activeEmailTagIndex, setActiveEmailTagIndex] = useState< + number | null + >(null); + + useEffect(() => { + if (isLoadingWhiteList) return; + + whitelistForm.setValue( + "emails", + whitelist.map((w) => ({ + id: w.email, + text: w.email + })) + ); + }, [isLoadingWhiteList, whitelist, whitelistForm]); + + async function saveWhitelist() { + try { + await api.post(`/resource/${resource.resourceId}`, { + emailWhitelistEnabled: whitelistEnabled + }); + + if (whitelistEnabled) { + await api.post(`/resource/${resource.resourceId}/whitelist`, { + emails: whitelistForm.getValues().emails.map((i) => i.text) + }); + } + + updateResource({ + emailWhitelistEnabled: whitelistEnabled + }); + + toast({ + title: t("resourceWhitelistSave"), + description: t("resourceWhitelistSaveDescription") + }); + router.refresh(); + await queryClient.invalidateQueries( + resourceQueries.resourceWhitelist({ + resourceId: resource.resourceId + }) + ); + } catch (e) { + console.error(e); + toast({ + variant: "destructive", + title: t("resourceErrorWhitelistSave"), + description: formatAxiosError( + e, + t("resourceErrorWhitelistSaveDescription") + ) + }); + } + } + + return ( + + + + {t("otpEmailTitle")} + + + {t("otpEmailTitleDescription")} + + + + + {!env.email.emailEnabled && ( + + + + {t("otpEmailSmtpRequired")} + + + {t("otpEmailSmtpRequiredDescription")} + + + )} + + + {whitelistEnabled && env.email.emailEnabled && ( +
+ + ( + + + + + + {/* @ts-ignore */} + { + return z + .email() + .or( + z + .string() + .regex( + /^\*@[\w.-]+\.[a-zA-Z]{2,}$/, + { + message: + t( + "otpEmailErrorInvalid" + ) + } + ) + ) + .safeParse(tag) + .success; + }} + setActiveTagIndex={ + setActiveEmailTagIndex + } + placeholder={t( + "otpEmailEnter" + )} + tags={ + whitelistForm.getValues() + .emails + } + setTags={(newRoles) => { + whitelistForm.setValue( + "emails", + newRoles as [ + Tag, + ...Tag[] + ] + ); + }} + allowDuplicates={false} + sortTags={true} + /> + + + {t("otpEmailEnterDescription")} + + + )} + /> + + + )} +
+
+ + + +
+ ); +} diff --git a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx index 87cb6f1e8..9589f6a2e 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/general/page.tsx @@ -1,8 +1,5 @@ "use client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; -import { formatAxiosError } from "@app/lib/api"; import { Button } from "@/components/ui/button"; import { Form, @@ -14,32 +11,8 @@ import { FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; import { useResourceContext } from "@app/hooks/useResourceContext"; -import { ListSitesResponse } from "@server/routers/site"; -import { useEffect, useState } from "react"; -import { AxiosResponse } from "axios"; -import { useParams, useRouter } from "next/navigation"; -import { useForm } from "react-hook-form"; -import { toast } from "@app/hooks/useToast"; -import { - SettingsContainer, - SettingsSection, - SettingsSectionHeader, - SettingsSectionTitle, - SettingsSectionDescription, - SettingsSectionBody, - SettingsSectionForm, - SettingsSectionFooter -} from "@app/components/Settings"; -import { useOrgContext } from "@app/hooks/useOrgContext"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import { Label } from "@app/components/ui/label"; -import { ListDomainsResponse } from "@server/routers/domain"; -import { UpdateResourceResponse } from "@server/routers/resource"; -import { SwitchInput } from "@app/components/SwitchInput"; -import { useTranslations } from "next-intl"; -import { Checkbox } from "@app/components/ui/checkbox"; import { Credenza, CredenzaBody, @@ -51,26 +24,424 @@ import { CredenzaTitle } from "@app/components/Credenza"; import DomainPicker from "@app/components/DomainPicker"; -import { Globe } from "lucide-react"; -import { build } from "@server/build"; +import { + SettingsContainer, + SettingsSection, + SettingsSectionBody, + SettingsSectionDescription, + SettingsSectionFooter, + SettingsSectionForm, + SettingsSectionHeader, + SettingsSectionTitle +} from "@app/components/Settings"; +import { SwitchInput } from "@app/components/SwitchInput"; +import { Label } from "@app/components/ui/label"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; -import { DomainRow } from "@app/components/DomainsTable"; +import { UpdateResourceResponse } from "@server/routers/resource"; +import { AxiosResponse } from "axios"; +import { AlertCircle, Globe } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useParams, useRouter } from "next/navigation"; import { toASCII, toUnicode } from "punycode"; -import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; -import { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; -import { useUserContext } from "@app/hooks/useUserContext"; +import { useActionState, useMemo, useState } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import z from "zod"; +import { Alert, AlertDescription } from "@app/components/ui/alert"; +import { RadioGroup, RadioGroupItem } from "@app/components/ui/radio-group"; +import { + Tooltip, + TooltipProvider, + TooltipTrigger +} from "@app/components/ui/tooltip"; +import { PaidFeaturesAlert } from "@app/components/PaidFeaturesAlert"; +import { GetResourceResponse } from "@server/routers/resource/getResource"; +import type { ResourceContextType } from "@app/contexts/resourceContext"; +import { usePaidStatus } from "@app/hooks/usePaidStatus"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; + +type MaintenanceSectionFormProps = { + resource: GetResourceResponse; + updateResource: ResourceContextType["updateResource"]; +}; + +function MaintenanceSectionForm({ + resource, + updateResource +}: MaintenanceSectionFormProps) { + const { env } = useEnvContext(); + const t = useTranslations(); + const api = createApiClient({ env }); + const { isPaidUser } = usePaidStatus(); + + const MaintenanceFormSchema = z.object({ + maintenanceModeEnabled: z.boolean().optional(), + maintenanceModeType: z.enum(["forced", "automatic"]).optional(), + maintenanceTitle: z.string().max(255).optional(), + maintenanceMessage: z.string().max(2000).optional(), + maintenanceEstimatedTime: z.string().max(100).optional() + }); + + const maintenanceForm = useForm({ + resolver: zodResolver(MaintenanceFormSchema), + defaultValues: { + maintenanceModeEnabled: resource.maintenanceModeEnabled || false, + maintenanceModeType: resource.maintenanceModeType || "automatic", + maintenanceTitle: + resource.maintenanceTitle || "We'll be back soon!", + maintenanceMessage: + resource.maintenanceMessage || + "We are currently performing scheduled maintenance. Please check back soon.", + maintenanceEstimatedTime: resource.maintenanceEstimatedTime || "" + }, + mode: "onChange" + }); + + const isMaintenanceEnabled = maintenanceForm.watch( + "maintenanceModeEnabled" + ); + const maintenanceModeType = maintenanceForm.watch("maintenanceModeType"); + + const [, maintenanceFormAction, maintenanceSaveLoading] = useActionState( + onMaintenanceSubmit, + null + ); + + async function onMaintenanceSubmit() { + const isValid = await maintenanceForm.trigger(); + if (!isValid) return; + + const data = maintenanceForm.getValues(); + + const res = await api + .post>( + `resource/${resource?.resourceId}`, + { + maintenanceModeEnabled: data.maintenanceModeEnabled, + maintenanceModeType: data.maintenanceModeType, + maintenanceTitle: data.maintenanceTitle || null, + maintenanceMessage: data.maintenanceMessage || null, + maintenanceEstimatedTime: + data.maintenanceEstimatedTime || null + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("resourceErrorUpdate"), + description: formatAxiosError( + e, + t("resourceErrorUpdateDescription") + ) + }); + }); + + if (res && res.status === 200) { + updateResource({ + maintenanceModeEnabled: data.maintenanceModeEnabled, + maintenanceModeType: data.maintenanceModeType, + maintenanceTitle: data.maintenanceTitle || null, + maintenanceMessage: data.maintenanceMessage || null, + maintenanceEstimatedTime: data.maintenanceEstimatedTime || null + }); + + toast({ + title: t("resourceUpdated"), + description: t("resourceUpdatedDescription") + }); + } + } + + if (!resource.http) { + return null; + } + + return ( + + + + {t("maintenanceMode")} + + + {t("maintenanceModeDescription")} + + + + + +
+ + + { + const isDisabled = + !isPaidUser(tierMatrix.maintencePage) || + resource.http === false; + + return ( + +
+ + + + +
+ { + if ( + !isDisabled + ) { + maintenanceForm.setValue( + "maintenanceModeEnabled", + val + ); + } + }} + /> +
+
+
+
+
+
+ +
+ ); + }} + /> + + {isMaintenanceEnabled && ( +
+ ( + + + {t("maintenanceModeType")} + + + + + + + +
+ + + {t( + "automatic" + )} + {" "} + ( + {t( + "recommended" + )} + ) + + + {t( + "automaticModeDescription" + )} + +
+
+ + + + +
+ + + {t( + "forced" + )} + + + + {t( + "forcedModeDescription" + )} + +
+
+
+
+ +
+ )} + /> + + {maintenanceModeType === "forced" && ( + + + + {t("forcedeModeWarning")} + + + )} + + ( + + + {t("pageTitle")} + + + + + + {t("pageTitleDescription")} + + + + )} + /> + + ( + + + {t( + "maintenancePageMessage" + )} + + +