diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 09e406ad..38f55482 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -329,20 +329,89 @@ jobs: 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 "Waiting for multi-arch manifest to be ready..." + 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 - echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}" - skopeo copy --all --retry-times 3 \ - docker://$DOCKERHUB_IMAGE:$TAG \ - docker://$GHCR_IMAGE:$TAG + + # Determine if this is an RC release + IS_RC="false" + if echo "$TAG" | grep -qE "rc[0-9]+$"; 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) @@ -371,28 +440,62 @@ 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}" + # Determine if this is an RC release + IS_RC="false" + if echo "$TAG" | grep -qE "rc[0-9]+$"; then + IS_RC="true" + fi - DIGEST="$(skopeo inspect --retry-times 3 docker://${IMAGE}:${TAG} | jq -r '.Digest')" - REF="${IMAGE}@${DIGEST}" - echo "Resolved digest: ${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 (keyless) --recursive ${REF}" - cosign sign --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}" - echo "==> cosign sign (key) --recursive ${REF}" - cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" + 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 (public key) ${REF}" - cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text + echo "==> cosign sign (keyless) --recursive ${REF}" + cosign sign --recursive "${REF}" - echo "==> cosign verify (keyless policy) ${REF}" - cosign verify \ - --certificate-oidc-issuer "${issuer}" \ - --certificate-identity-regexp "${id_regex}" \ - "${REF}" -o text + echo "==> cosign sign (key) --recursive ${REF}" + cosign sign --key env://COSIGN_PRIVATE_KEY --recursive "${REF}" + + echo "==> cosign verify (public key) ${REF}" + cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text + + echo "==> cosign verify (keyless policy) ${REF}" + cosign verify \ + --certificate-oidc-issuer "${issuer}" \ + --certificate-identity-regexp "${id_regex}" \ + "${REF}" -o text + + echo "✓ Successfully signed and verified ${BASE_IMAGE}:${IMAGE_TAG}" + done done + + echo "All images signed and verified successfully!" shell: bash post-run: diff --git a/.github/workflows/cicd.yml.backup b/.github/workflows/cicd.yml.backup new file mode 100644 index 00000000..09e406ad --- /dev/null +++ b/.github/workflows/cicd.yml.backup @@ -0,0 +1,426 @@ +name: CI/CD 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 + +# Required secrets: +# - DOCKER_HUB_USERNAME / DOCKER_HUB_ACCESS_TOKEN: push to Docker Hub +# - GITHUB_TOKEN: used for GHCR login and OIDC keyless signing +# - COSIGN_PRIVATE_KEY / COSIGN_PASSWORD / COSIGN_PUBLIC_KEY: for key-based signing + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + - "[0-9]+.[0-9]+.[0-9]+-rc.[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@v2 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} + role-duration-seconds: 3600 + aws-region: ${{ secrets.AWS_REGION }} + + - name: Verify AWS identity + run: aws sts get-caller-identity + + - name: Start EC2 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: + # 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.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 + 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.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 + 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - name: Log in to Docker Hub + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 + + - 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 + with: + go-version: 1.24 + + - 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: Pull latest Gerbil version + id: get-gerbil-tag + run: | + LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/gerbil/tags | jq -r '.[0].name') + echo "LATEST_GERBIL_TAG=$LATEST_TAG" >> $GITHUB_ENV + shell: bash + + - name: Pull latest Badger version + id: get-badger-tag + run: | + LATEST_TAG=$(curl -s https://api.github.com/repos/fosrl/badger/tags | jq -r '.[0].name') + 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 + + - name: Upload artifacts from /install/bin + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: install-bin + path: install/bin/ + + - name: Install skopeo + jq + # skopeo: copy/inspect images between registries + # jq: JSON parsing tool used to extract digest values + run: | + sudo apt-get update -y + sudo apt-get install -y skopeo jq + skopeo --version + 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 + # 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 "Waiting for multi-arch manifest to be ready..." + sleep 30 + echo "Copying ${{ env.DOCKERHUB_IMAGE }}:${TAG} -> ${{ env.GHCR_IMAGE }}:${TAG}" + skopeo copy --all --retry-times 3 \ + docker://$DOCKERHUB_IMAGE:$TAG \ + docker://$GHCR_IMAGE:$TAG + shell: bash + + - name: Login to GitHub Container Registry (for cosign) + uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Install cosign + # cosign is used to sign and verify container images (key and keyless) + uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0 + + - name: Dual-sign and verify (GHCR & Docker Hub) + # Sign each image by digest using keyless (OIDC) and key-based signing, + # then verify both the public key signature and the keyless OIDC signature. + env: + TAG: ${{ env.TAG }} + COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + COSIGN_PUBLIC_KEY: ${{ secrets.COSIGN_PUBLIC_KEY }} + COSIGN_YES: "true" + run: | + set -euo pipefail + + 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}" + + DIGEST="$(skopeo inspect --retry-times 3 docker://${IMAGE}:${TAG} | jq -r '.Digest')" + REF="${IMAGE}@${DIGEST}" + echo "Resolved digest: ${REF}" + + 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}" + + echo "==> cosign verify (public key) ${REF}" + cosign verify --key env://COSIGN_PUBLIC_KEY "${REF}" -o text + + echo "==> cosign verify (keyless policy) ${REF}" + cosign verify \ + --certificate-oidc-issuer "${issuer}" \ + --certificate-identity-regexp "${id_regex}" \ + "${REF}" -o text + done + 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@v2 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} + role-duration-seconds: 3600 + aws-region: ${{ secrets.AWS_REGION }} + + - name: Verify AWS identity + run: aws sts get-caller-identity + + - name: 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/saas.yml b/.github/workflows/saas.yml new file mode 100644 index 00000000..0c36de25 --- /dev/null +++ b/.github/workflows/saas.yml @@ -0,0 +1,125 @@ +name: CI/CD 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@v2 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} + role-duration-seconds: 3600 + aws-region: ${{ secrets.AWS_REGION }} + + - name: Verify AWS identity + run: aws sts get-caller-identity + + - name: Start EC2 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@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.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: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: arn:aws:iam::${{ secrets.aws_account_id }}:role/${{ secrets.AWS_ROLE_NAME }} + role-duration-seconds: 3600 + aws-region: ${{ secrets.AWS_REGION }} + + - name: 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@v2 + with: + role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ secrets.AWS_ROLE_NAME }} + role-duration-seconds: 3600 + aws-region: ${{ secrets.AWS_REGION }} + + - name: Verify AWS identity + run: aws sts get-caller-identity + + - name: Stop EC2 instances + run: | + aws ec2 stop-instances --instance-ids ${{ secrets.EC2_INSTANCE_ID_ARM_RUNNER }} + echo "EC2 instances stopped" diff --git a/Makefile b/Makefile index 13dc601f..da31b6e2 100644 --- a/Makefile +++ b/Makefile @@ -90,6 +90,18 @@ build-ee-postgresql: --tag fosrl/pangolin:ee-postgresql-$(tag) \ --push . +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="; \ diff --git a/install/main.go b/install/main.go index de001df2..a231da2d 100644 --- a/install/main.go +++ b/install/main.go @@ -340,7 +340,7 @@ func collectUserInput(reader *bufio.Reader) Config { // Basic configuration fmt.Println("\n=== Basic Configuration ===") - config.IsEnterprise = readBoolNoDefault(reader, "Do you want to install the Enterprise version of Pangolin? The EE is free for persoal use or for businesses making less than 100k USD annually.") + config.IsEnterprise = readBoolNoDefault(reader, "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(reader, "Enter your base domain (no subdomain e.g. example.com)", "") diff --git a/messages/en-US.json b/messages/en-US.json index a3ceb14f..05a900ad 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1143,6 +1143,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", @@ -1160,7 +1164,7 @@ "create": "Create", "orgs": "Organizations", "loginError": "An error occurred while logging in", - "loginRequiredForDevice": "Login is required to authenticate your device.", + "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.", @@ -1231,7 +1235,7 @@ "sidebarIdentityProviders": "Identity Providers", "sidebarLicense": "License", "sidebarClients": "Clients", - "sidebarUserDevices": "Users", + "sidebarUserDevices": "User Devices", "sidebarMachineClients": "Machines", "sidebarDomains": "Domains", "sidebarGeneral": "Manage", @@ -1904,7 +1908,7 @@ "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", + "orgAuthSignInToOrg": "Use organization's identity provider", "orgAuthSelectOrgTitle": "Organization Sign In", "orgAuthSelectOrgDescription": "Enter your organization ID to continue", "orgAuthOrgIdPlaceholder": "your-organization", @@ -2272,7 +2276,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", @@ -2422,5 +2426,31 @@ "maintenanceScreenTitle": "Service Temporarily Unavailable", "maintenanceScreenMessage": "We are currently experiencing technical difficulties. Please check back soon.", "maintenanceScreenEstimatedCompletion": "Estimated Completion:", - "createInternalResourceDialogDestinationRequired": "Destination is required" + "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" } diff --git a/server/auth/actions.ts b/server/auth/actions.ts index 4df4c279..094437f4 100644 --- a/server/auth/actions.ts +++ b/server/auth/actions.ts @@ -78,6 +78,10 @@ export enum ActionsEnum { updateSiteResource = "updateSiteResource", createClient = "createClient", deleteClient = "deleteClient", + archiveClient = "archiveClient", + unarchiveClient = "unarchiveClient", + blockClient = "blockClient", + unblockClient = "unblockClient", updateClient = "updateClient", listClients = "listClients", getClient = "getClient", diff --git a/server/db/pg/schema/schema.ts b/server/db/pg/schema/schema.ts index f4c593ae..ca98a03b 100644 --- a/server/db/pg/schema/schema.ts +++ b/server/db/pg/schema/schema.ts @@ -592,7 +592,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", { @@ -690,6 +691,8 @@ export const clients = pgTable("clients", { // endpoint: varchar("endpoint"), lastHolePunch: integer("lastHolePunch"), maxConnections: integer("maxConnections"), + archived: boolean("archived").notNull().default(false), + blocked: boolean("blocked").notNull().default(false), approvalState: varchar("approvalState") .$type<"pending" | "approved" | "denied">() .default("approved") @@ -730,7 +733,8 @@ 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 olmSessions = pgTable("clientSession", { diff --git a/server/db/queries/verifySessionQueries.ts b/server/db/queries/verifySessionQueries.ts index 3c6c5420..280c8a11 100644 --- a/server/db/queries/verifySessionQueries.ts +++ b/server/db/queries/verifySessionQueries.ts @@ -1,4 +1,4 @@ -import { db, loginPage, LoginPage, loginPageOrg, Org, orgs } from "@server/db"; +import { db, loginPage, LoginPage, loginPageOrg, Org, orgs, roles } from "@server/db"; import { Resource, ResourcePassword, @@ -108,9 +108,17 @@ export async function getUserSessionWithUser( */ export async function getUserOrgRole(userId: string, orgId: string) { const userOrgRole = await db - .select() + .select({ + userId: userOrgs.userId, + orgId: userOrgs.orgId, + roleId: userOrgs.roleId, + isOwner: userOrgs.isOwner, + autoProvisioned: userOrgs.autoProvisioned, + roleName: roles.name + }) .from(userOrgs) .where(and(eq(userOrgs.userId, userId), eq(userOrgs.orgId, orgId))) + .leftJoin(roles, eq(userOrgs.roleId, roles.roleId)) .limit(1); return userOrgRole.length > 0 ? userOrgRole[0] : null; diff --git a/server/db/sqlite/schema/schema.ts b/server/db/sqlite/schema/schema.ts index ef6fff6d..eca40afc 100644 --- a/server/db/sqlite/schema/schema.ts +++ b/server/db/sqlite/schema/schema.ts @@ -385,7 +385,9 @@ 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) }); export const clientSitesAssociationsCache = sqliteTable( @@ -425,7 +427,8 @@ 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 twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", { @@ -779,7 +782,8 @@ export const idp = sqliteTable("idp", { mode: "boolean" }) .notNull() - .default(false) + .default(false), + tags: text("tags") }); // Identity Provider OAuth Configuration diff --git a/server/lib/blueprints/types.ts b/server/lib/blueprints/types.ts index 650d5b18..cba9bfa7 100644 --- a/server/lib/blueprints/types.ts +++ b/server/lib/blueprints/types.ts @@ -290,8 +290,8 @@ export const ClientResourceSchema = 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(), roles: z diff --git a/server/middlewares/integration/index.ts b/server/middlewares/integration/index.ts index 2e2e8ff0..56575191 100644 --- a/server/middlewares/integration/index.ts +++ b/server/middlewares/integration/index.ts @@ -13,3 +13,4 @@ export * from "./verifyApiKeyIsRoot"; export * from "./verifyApiKeyApiKeyAccess"; export * from "./verifyApiKeyClientAccess"; export * from "./verifyApiKeySiteResourceAccess"; +export * from "./verifyApiKeyIdpAccess"; diff --git a/server/middlewares/integration/verifyApiKeyIdpAccess.ts b/server/middlewares/integration/verifyApiKeyIdpAccess.ts new file mode 100644 index 00000000..99b7e76b --- /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/private/lib/config.ts b/server/private/lib/config.ts index 97baf1e0..ae9ca5c7 100644 --- a/server/private/lib/config.ts +++ b/server/private/lib/config.ts @@ -139,6 +139,10 @@ export class PrivateConfig { process.env.USE_PANGOLIN_DNS = this.rawPrivateConfig.flags.use_pangolin_dns.toString(); } + if (this.rawPrivateConfig.flags.use_org_only_idp) { + process.env.USE_ORG_ONLY_IDP = + this.rawPrivateConfig.flags.use_org_only_idp.toString(); + } } public getRawPrivateConfig() { diff --git a/server/private/lib/exitNodes/exitNodes.ts b/server/private/lib/exitNodes/exitNodes.ts index 556fdcf7..97c89614 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/lock.ts b/server/private/lib/lock.ts index 08496f65..7e68565e 100644 --- a/server/private/lib/lock.ts +++ b/server/private/lib/lock.ts @@ -24,7 +24,9 @@ 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; @@ -35,49 +37,67 @@ export class LockManager { }:${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 ${ + config.getRawConfig().gerbil.exit_node_name + }` + ); + 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( + `${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 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; } /** diff --git a/server/private/lib/readConfigFile.ts b/server/private/lib/readConfigFile.ts index c986e62d..374dee7c 100644 --- a/server/private/lib/readConfigFile.ts +++ b/server/private/lib/readConfigFile.ts @@ -83,7 +83,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().default(false) }) .optional() .prefault({}), diff --git a/server/private/lib/traefik/getTraefikConfig.ts b/server/private/lib/traefik/getTraefikConfig.ts index 18410e62..f0343c5d 100644 --- a/server/private/lib/traefik/getTraefikConfig.ts +++ b/server/private/lib/traefik/getTraefikConfig.ts @@ -456,11 +456,11 @@ export async function getTraefikConfig( // ); } 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) { + // logger.warn( + // `Resource ${resource.name} (${fullDomain}) has no healthy servers - showing maintenance page (AUTOMATIC mode)` + // ); + // } } } diff --git a/server/private/middlewares/verifySubscription.ts b/server/private/middlewares/verifySubscription.ts index 5249c026..8cda737e 100644 --- a/server/private/middlewares/verifySubscription.ts +++ b/server/private/middlewares/verifySubscription.ts @@ -27,7 +27,18 @@ export async function verifyValidSubscription( return next(); } - const tier = await getOrgTierData(req.params.orgId); + 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 = await getOrgTierData(orgId); if (!tier.active) { return next( diff --git a/server/private/routers/external.ts b/server/private/routers/external.ts index 7f1eb32f..44af3fe9 100644 --- a/server/private/routers/external.ts +++ b/server/private/routers/external.ts @@ -455,18 +455,18 @@ authenticated.get( authenticated.post( "/re-key/:clientId/regenerate-client-secret", + verifyClientAccess, // this is first to set the org id verifyValidLicense, verifyValidSubscription, - verifyClientAccess, 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, verifyUserHasAction(ActionsEnum.reGenerateSecret), reKey.reGenerateSiteSecret ); diff --git a/server/private/routers/integration.ts b/server/private/routers/integration.ts index 9eefff8f..25861a54 100644 --- a/server/private/routers/integration.ts +++ b/server/private/routers/integration.ts @@ -18,7 +18,8 @@ import * as logs from "#private/routers/auditLogs"; import { verifyApiKeyHasAction, verifyApiKeyIsRoot, - verifyApiKeyOrgAccess + verifyApiKeyOrgAccess, + verifyApiKeyIdpAccess } from "@server/middlewares"; import { verifyValidSubscription, @@ -31,6 +32,8 @@ import { authenticated as a } from "@server/routers/integration"; import { logActionAudit } from "#private/middlewares"; +import config from "#private/lib/config"; +import { build } from "@server/build"; export const unauthenticated = ua; export const authenticated = a; @@ -88,3 +91,49 @@ authenticated.get( logActionAudit(ActionsEnum.exportLogs), logs.exportAccessAuditLogs ); + +authenticated.put( + "/org/:orgId/idp/oidc", + verifyValidLicense, + verifyApiKeyOrgAccess, + verifyApiKeyHasAction(ActionsEnum.createIdp), + logActionAudit(ActionsEnum.createIdp), + orgIdp.createOrgOidcIdp +); + +authenticated.post( + "/org/:orgId/idp/:idpId/oidc", + verifyValidLicense, + verifyApiKeyOrgAccess, + verifyApiKeyIdpAccess, + 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 +); diff --git a/server/private/routers/loginPage/upsertLoginPageBranding.ts b/server/private/routers/loginPage/upsertLoginPageBranding.ts index f9f9d08c..4e2b666b 100644 --- a/server/private/routers/loginPage/upsertLoginPageBranding.ts +++ b/server/private/routers/loginPage/upsertLoginPageBranding.ts @@ -28,6 +28,7 @@ import { eq, InferInsertModel } from "drizzle-orm"; import { getOrgTierData } from "#private/lib/billing"; import { TierId } from "@server/lib/billing/tiers"; import { build } from "@server/build"; +import config from "@server/private/lib/config"; const paramsSchema = z.strictObject({ orgId: z.string() @@ -94,8 +95,10 @@ export async function upsertLoginPageBranding( typeof loginPageBranding >; - if (build !== "saas") { - // org branding settings are only considered in the saas build + if ( + build !== "saas" && + !config.getRawPrivateConfig().flags.use_org_only_idp + ) { const { orgTitle, orgSubtitle, ...rest } = updateData; updateData = rest; } diff --git a/server/private/routers/orgIdp/createOrgOidcIdp.ts b/server/private/routers/orgIdp/createOrgOidcIdp.ts index 709f6167..998a159f 100644 --- a/server/private/routers/orgIdp/createOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/createOrgOidcIdp.ts @@ -43,25 +43,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.Idp, OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); export async function createOrgOidcIdp( req: Request, @@ -103,7 +105,8 @@ export async function createOrgOidcIdp( name, autoProvision, variant, - roleMapping + roleMapping, + tags } = parsedBody.data; if (build === "saas") { @@ -131,7 +134,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 721b91cb..176f4238 100644 --- a/server/private/routers/orgIdp/deleteOrgIdp.ts +++ b/server/private/routers/orgIdp/deleteOrgIdp.ts @@ -32,9 +32,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.Idp, OpenAPITags.Org], request: { params: paramsSchema }, diff --git a/server/private/routers/orgIdp/getOrgIdp.ts b/server/private/routers/orgIdp/getOrgIdp.ts index 01ddc0f7..dd987c44 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.Idp, OpenAPITags.Org], + 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 36cbc627..b6cf48ac 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.Idp, OpenAPITags.Org], + 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 f29e4fc2..d8ef415c 100644 --- a/server/private/routers/orgIdp/updateOrgOidcIdp.ts +++ b/server/private/routers/orgIdp/updateOrgOidcIdp.ts @@ -46,30 +46,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.Idp, OpenAPITags.Org], + request: { + params: paramsSchema, + body: { + content: { + "application/json": { + schema: bodySchema + } + } + } + }, + responses: {} +}); export async function updateOrgOidcIdp( req: Request, @@ -109,7 +110,8 @@ export async function updateOrgOidcIdp( namePath, name, autoProvision, - roleMapping + roleMapping, + tags } = parsedBody.data; if (build === "saas") { @@ -167,7 +169,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/routers/auth/index.ts b/server/routers/auth/index.ts index 22040614..4600a4cc 100644 --- a/server/routers/auth/index.ts +++ b/server/routers/auth/index.ts @@ -16,4 +16,4 @@ export * from "./checkResourceSession"; export * from "./securityKey"; export * from "./startDeviceWebAuth"; export * from "./verifyDeviceWebAuth"; -export * from "./pollDeviceWebAuth"; +export * from "./pollDeviceWebAuth"; \ No newline at end of file diff --git a/server/routers/badger/verifySession.ts b/server/routers/badger/verifySession.ts index 8dee788a..3226755d 100644 --- a/server/routers/badger/verifySession.ts +++ b/server/routers/badger/verifySession.ts @@ -942,7 +942,7 @@ async function isUserAllowedToAccessResource( username: user.username, email: user.email, name: user.name, - role: user.role + role: userOrgRole.roleName }; } @@ -956,7 +956,7 @@ async function isUserAllowedToAccessResource( username: user.username, email: user.email, name: user.name, - role: user.role + role: userOrgRole.roleName }; } diff --git a/server/routers/client/archiveClient.ts b/server/routers/client/archiveClient.ts new file mode 100644 index 00000000..330f6ed8 --- /dev/null +++ b/server/routers/client/archiveClient.ts @@ -0,0 +1,105 @@ +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 { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { sendTerminateClient } from "./terminate"; + +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)); + + // Rebuild associations to clean up related data + await rebuildClientAssociationsFromClient(client, trx); + + // Send terminate signal if there's an associated OLM + if (client.olmId) { + await sendTerminateClient(client.clientId, client.olmId); + } + }); + + 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 00000000..e1a00ff6 --- /dev/null +++ b/server/routers/client/blockClient.ts @@ -0,0 +1,101 @@ +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"; + +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 }) + .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, 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/deleteClient.ts b/server/routers/client/deleteClient.ts index 775708ce..a16a2996 100644 --- a/server/routers/client/deleteClient.ts +++ b/server/routers/client/deleteClient.ts @@ -60,11 +60,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.` ) ); } diff --git a/server/routers/client/index.ts b/server/routers/client/index.ts index 8e88c11e..34614cc8 100644 --- a/server/routers/client/index.ts +++ b/server/routers/client/index.ts @@ -1,6 +1,10 @@ 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 "./updateClient"; export * from "./getClient"; diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index 6b43b482..6395aec6 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -137,7 +137,10 @@ function queryClients( userEmail: users.email, niceId: clients.niceId, agent: olms.agent, - approvalState: clients.approvalState + approvalState: clients.approvalState, + olmArchived: olms.archived, + archived: clients.archived, + blocked: clients.blocked }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) diff --git a/server/routers/client/unarchiveClient.ts b/server/routers/client/unarchiveClient.ts new file mode 100644 index 00000000..62c5c17c --- /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 00000000..82b608a2 --- /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 }) + .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/external.ts b/server/routers/external.ts index bdea5ae9..fd5c93da 100644 --- a/server/routers/external.ts +++ b/server/routers/external.ts @@ -174,6 +174,38 @@ authenticated.delete( client.deleteClient ); +authenticated.post( + "/client/:clientId/archive", + verifyClientAccess, + verifyUserHasAction(ActionsEnum.archiveClient), + logActionAudit(ActionsEnum.archiveClient), + client.archiveClient +); + +authenticated.post( + "/client/:clientId/unarchive", + verifyClientAccess, + verifyUserHasAction(ActionsEnum.unarchiveClient), + logActionAudit(ActionsEnum.unarchiveClient), + client.unarchiveClient +); + +authenticated.post( + "/client/:clientId/block", + verifyClientAccess, + verifyUserHasAction(ActionsEnum.blockClient), + logActionAudit(ActionsEnum.blockClient), + client.blockClient +); + +authenticated.post( + "/client/:clientId/unblock", + verifyClientAccess, + verifyUserHasAction(ActionsEnum.unblockClient), + logActionAudit(ActionsEnum.unblockClient), + client.unblockClient +); + authenticated.post( "/client/:clientId", verifyClientAccess, // this will check if the user has access to the client @@ -816,11 +848,18 @@ 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 + olm.archiveUserOlm +); + +authenticated.post( + "/user/:userId/olm/:olmId/unarchive", + verifyIsLoggedInUser, + verifyOlmAccess, + olm.unarchiveUserOlm ); authenticated.get( diff --git a/server/routers/idp/createOidcIdp.ts b/server/routers/idp/createOidcIdp.ts index c7eeaf30..083bbeb0 100644 --- a/server/routers/idp/createOidcIdp.ts +++ b/server/routers/idp/createOidcIdp.ts @@ -24,7 +24,8 @@ 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() }); export type CreateIdpResponse = { @@ -75,7 +76,8 @@ export async function createOidcIdp( emailPath, namePath, name, - autoProvision + autoProvision, + tags } = parsedBody.data; const key = config.getRawConfig().server.secret!; @@ -90,7 +92,8 @@ export async function createOidcIdp( .values({ name, autoProvision, - type: "oidc" + type: "oidc", + tags }) .returning(); diff --git a/server/routers/idp/listIdps.ts b/server/routers/idp/listIdps.ts index 20d1899e..9dda11bb 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}`) diff --git a/server/routers/idp/updateOidcIdp.ts b/server/routers/idp/updateOidcIdp.ts index a4d55187..622d3d49 100644 --- a/server/routers/idp/updateOidcIdp.ts +++ b/server/routers/idp/updateOidcIdp.ts @@ -30,7 +30,8 @@ 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() }); export type UpdateIdpResponse = { @@ -94,7 +95,8 @@ export async function updateOidcIdp( name, autoProvision, defaultRoleMapping, - defaultOrgMapping + defaultOrgMapping, + tags } = parsedBody.data; // Check if IDP exists and is of type OIDC @@ -127,7 +129,8 @@ export async function updateOidcIdp( name, autoProvision, defaultRoleMapping, - defaultOrgMapping + defaultOrgMapping, + tags }; // only update if at least one key is not undefined diff --git a/server/routers/integration.ts b/server/routers/integration.ts index 7d5a43dd..7a5a3efe 100644 --- a/server/routers/integration.ts +++ b/server/routers/integration.ts @@ -759,9 +759,10 @@ authenticated.post( ); 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 ); @@ -850,6 +851,38 @@ authenticated.delete( client.deleteClient ); +authenticated.post( + "/client/:clientId/archive", + verifyApiKeyClientAccess, + verifyApiKeyHasAction(ActionsEnum.archiveClient), + logActionAudit(ActionsEnum.archiveClient), + client.archiveClient +); + +authenticated.post( + "/client/:clientId/unarchive", + verifyApiKeyClientAccess, + verifyApiKeyHasAction(ActionsEnum.unarchiveClient), + logActionAudit(ActionsEnum.unarchiveClient), + client.unarchiveClient +); + +authenticated.post( + "/client/:clientId/block", + verifyApiKeyClientAccess, + verifyApiKeyHasAction(ActionsEnum.blockClient), + logActionAudit(ActionsEnum.blockClient), + client.blockClient +); + +authenticated.post( + "/client/:clientId/unblock", + verifyApiKeyClientAccess, + verifyApiKeyHasAction(ActionsEnum.unblockClient), + logActionAudit(ActionsEnum.unblockClient), + client.unblockClient +); + authenticated.post( "/client/:clientId", verifyApiKeyClientAccess, diff --git a/server/routers/olm/archiveUserOlm.ts b/server/routers/olm/archiveUserOlm.ts new file mode 100644 index 00000000..46abd1a1 --- /dev/null +++ b/server/routers/olm/archiveUserOlm.ts @@ -0,0 +1,81 @@ +import { NextFunction, Request, Response } from "express"; +import { db } from "@server/db"; +import { olms, clients } 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"; +import { rebuildClientAssociationsFromClient } from "@server/lib/rebuildClientAssociations"; +import { sendTerminateClient } from "../client/terminate"; + +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; + + // Archive the OLM and disconnect associated clients in a transaction + await db.transaction(async (trx) => { + // Find all clients associated with this OLM + const associatedClients = await trx + .select() + .from(clients) + .where(eq(clients.olmId, olmId)); + + // Disconnect clients from the OLM (set olmId to null) + for (const client of associatedClients) { + await trx + .update(clients) + .set({ olmId: null }) + .where(eq(clients.clientId, client.clientId)); + + await rebuildClientAssociationsFromClient(client, trx); + await sendTerminateClient(client.clientId, olmId); + } + + // Archive the OLM (set archived to true) + 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/getUserOlm.ts b/server/routers/olm/getUserOlm.ts index aa9b89af..03fa04bc 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 } from "@server/db"; import { eq, and } from "drizzle-orm"; import HttpCode from "@server/types/HttpCode"; import createHttpError from "http-errors"; @@ -17,6 +17,10 @@ const paramsSchema = z }) .strict(); +const querySchema = z.object({ + orgId: z.string().optional() +}); + // registry.registerPath({ // method: "get", // path: "/user/{userId}/olm/{olmId}", @@ -44,15 +48,56 @@ export async function getUserOlm( ); } + const parsedQuery = querySchema.safeParse(req.query); + if (!parsedQuery.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedQuery.error).toString() + ) + ); + } + const { olmId, userId } = parsedParams.data; + const { orgId } = parsedQuery.data; const [olm] = await db .select() .from(olms) .where(and(eq(olms.userId, userId), eq(olms.olmId, olmId))); + if (!olm) { + return next( + createHttpError( + HttpCode.NOT_FOUND, + "Olm not found" + ) + ); + } + + // 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; + } + + const responseData = blocked !== undefined + ? { ...olm, blocked } + : olm; + return response(res, { - data: olm, + data: responseData, success: true, error: false, message: "Successfully retrieved olm", diff --git a/server/routers/olm/handleOlmPingMessage.ts b/server/routers/olm/handleOlmPingMessage.ts index 0fa490c8..9361193d 100644 --- a/server/routers/olm/handleOlmPingMessage.ts +++ b/server/routers/olm/handleOlmPingMessage.ts @@ -1,7 +1,7 @@ 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 logger from "@server/logger"; import { validateSessionToken } from "@server/auth/sessions/app"; @@ -108,29 +108,17 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { 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 (!olm.clientId) { + logger.warn("Olm has no client ID!"); + return; + } + try { // get the client const [client] = await db .select() .from(clients) - .where( - and( - eq(clients.olmId, olm.olmId), - eq(clients.userId, olm.userId) - ) - ) + .where(eq(clients.clientId, olm.clientId)) .limit(1); if (!client) { @@ -138,38 +126,62 @@ export const handleOlmPingMessage: MessageHandler = async (context) => { 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}` - ); + 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.clientId) { - logger.warn("Olm has no client ID!"); - 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; + } + } - try { - // Update the client's last ping timestamp await db .update(clients) .set({ lastPing: Math.floor(Date.now() / 1000), - online: true + online: true, + archived: false }) .where(eq(clients.clientId, olm.clientId)); + + if (olm.archived) { + await db + .update(olms) + .set({ archived: false }) + .where(eq(olms.olmId, olm.olmId)); + } } catch (error) { logger.error("Error handling ping message", { error }); } diff --git a/server/routers/olm/handleOlmRegisterMessage.ts b/server/routers/olm/handleOlmRegisterMessage.ts index 0f71ee8b..3334101e 100644 --- a/server/routers/olm/handleOlmRegisterMessage.ts +++ b/server/routers/olm/handleOlmRegisterMessage.ts @@ -55,6 +55,11 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { return; } + if (client.blocked) { + logger.debug(`Client ${client.clientId} is blocked. Ignoring register.`); + return; + } + const [org] = await db .select() .from(orgs) @@ -112,18 +117,20 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { if ( (olmVersion && olm.version !== olmVersion) || - (olmAgent && olm.agent !== olmAgent) + (olmAgent && olm.agent !== olmAgent) || + olm.archived ) { await db .update(olms) .set({ version: olmVersion, - agent: olmAgent + agent: olmAgent, + archived: false }) .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 +138,8 @@ export const handleOlmRegisterMessage: MessageHandler = async (context) => { await db .update(clients) .set({ - pubKey: publicKey + pubKey: publicKey, + archived: false, }) .where(eq(clients.clientId, client.clientId)); diff --git a/server/routers/olm/index.ts b/server/routers/olm/index.ts index 594ef9cb..6957c18b 100644 --- a/server/routers/olm/index.ts +++ b/server/routers/olm/index.ts @@ -3,9 +3,9 @@ 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"; diff --git a/server/routers/olm/listUserOlms.ts b/server/routers/olm/listUserOlms.ts index 2756c917..16585e9f 100644 --- a/server/routers/olm/listUserOlms.ts +++ b/server/routers/olm/listUserOlms.ts @@ -51,6 +51,7 @@ export type ListUserOlmsResponse = { name: string | null; clientId: number | null; userId: string | null; + archived: boolean; }>; pagination: { total: number; @@ -89,7 +90,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,7 +98,7 @@ export async function listUserOlms( const total = totalCountResult?.count || 0; - // Get OLMs for the current user + // Get OLMs for the current user (including archived OLMs) const userOlms = await db .select({ olmId: olms.olmId, @@ -105,7 +106,8 @@ export async function listUserOlms( version: olms.version, name: olms.name, clientId: olms.clientId, - userId: olms.userId + userId: olms.userId, + archived: olms.archived }) .from(olms) .where(eq(olms.userId, userId)) diff --git a/server/routers/olm/unarchiveUserOlm.ts b/server/routers/olm/unarchiveUserOlm.ts new file mode 100644 index 00000000..28d540b8 --- /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/src/app/[orgId]/settings/(private)/idp/layout.tsx b/src/app/[orgId]/settings/(private)/idp/layout.tsx new file mode 100644 index 00000000..dcb73afb --- /dev/null +++ b/src/app/[orgId]/settings/(private)/idp/layout.tsx @@ -0,0 +1,18 @@ +import { pullEnv } from "@app/lib/pullEnv"; +import { build } from "@server/build"; +import { redirect } from "next/navigation"; + +interface LayoutProps { + children: React.ReactNode; + params: Promise<{}>; +} + +export default async function Layout(props: LayoutProps) { + const env = pullEnv(); + + if (build !== "saas" && !env.flags.useOrgOnlyIdp) { + redirect("/"); + } + + return props.children; +} diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx index f2618bc2..6c39041c 100644 --- a/src/app/[orgId]/settings/clients/machine/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -59,7 +59,9 @@ 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 }; }; diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index 423f2168..bb55d266 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -56,6 +56,8 @@ export default async function ClientsPage(props: ClientsPageProps) { userEmail: client.userEmail, niceId: client.niceId, agent: client.agent, + archived: client.archived || false, + blocked: client.blocked || false, approvalState: client.approvalState ?? "approved" }; }; diff --git a/src/app/[orgId]/settings/layout.tsx b/src/app/[orgId]/settings/layout.tsx index 8f44c23c..34ed3ac2 100644 --- a/src/app/[orgId]/settings/layout.tsx +++ b/src/app/[orgId]/settings/layout.tsx @@ -82,7 +82,7 @@ export default async function SettingsLayout(props: SettingsLayoutProps) { {children} 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 968b2700..f021076f 100644 --- a/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx +++ b/src/app/[orgId]/settings/resources/proxy/[niceId]/authentication/page.tsx @@ -36,8 +36,8 @@ import { 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 { useSubscriptionStatusContext } from "@app/hooks/useSubscriptionStatusContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; import { orgQueries, resourceQueries } from "@app/lib/queries"; @@ -95,7 +95,7 @@ export default function ResourceAuthenticationPage() { const router = useRouter(); const t = useTranslations(); - const subscription = useSubscriptionStatusContext(); + const { isPaidUser } = usePaidStatus(); const queryClient = useQueryClient(); const { data: resourceRoles = [], isLoading: isLoadingResourceRoles } = @@ -129,7 +129,8 @@ export default function ResourceAuthenticationPage() { ); const { data: orgIdps = [], isLoading: isLoadingOrgIdps } = useQuery( orgQueries.identityProviders({ - orgId: org.org.orgId + orgId: org.org.orgId, + useOrgOnlyIdp: env.flags.useOrgOnlyIdp }) ); @@ -159,7 +160,7 @@ export default function ResourceAuthenticationPage() { const allIdps = useMemo(() => { if (build === "saas") { - if (subscription?.subscribed) { + if (isPaidUser) { return orgIdps.map((idp) => ({ id: idp.idpId, text: idp.name diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index 060f18ac..44d85b99 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -11,6 +11,7 @@ import { AxiosResponse } from "axios"; import { authCookieHeader } from "@app/lib/api/cookies"; import { Layout } from "@app/components/Layout"; import { adminNavSections } from "../navigation"; +import { pullEnv } from "@app/lib/pullEnv"; export const dynamic = "force-dynamic"; @@ -27,6 +28,8 @@ export default async function AdminLayout(props: LayoutProps) { const getUser = cache(verifySession); const user = await getUser(); + const env = pullEnv(); + if (!user || !user.serverAdmin) { redirect(`/`); } @@ -48,7 +51,7 @@ export default async function AdminLayout(props: LayoutProps) { return ( - + {props.children} diff --git a/src/app/auth/layout.tsx b/src/app/auth/layout.tsx index 6a72006b..fae271f5 100644 --- a/src/app/auth/layout.tsx +++ b/src/app/auth/layout.tsx @@ -44,7 +44,7 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { return (
-
+
@@ -127,26 +127,6 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { )} - - - {t("docs")} - - - - {t("github")} -
)} diff --git a/src/app/auth/login/device/success/page.tsx b/src/app/auth/login/device/success/page.tsx index f725a867..6ee49587 100644 --- a/src/app/auth/login/device/success/page.tsx +++ b/src/app/auth/login/device/success/page.tsx @@ -7,6 +7,7 @@ import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; import { CheckCircle2 } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; +import { useEffect } from "react"; export default function DeviceAuthSuccessPage() { const { env } = useEnvContext(); @@ -20,6 +21,32 @@ export default function DeviceAuthSuccessPage() { ? env.branding.logo?.authPage?.height || 58 : 58; + useEffect(() => { + // Detect if we're on iOS or Android + const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera; + const isIOS = /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream; + const isAndroid = /android/i.test(userAgent); + + if (isAndroid) { + // For Android Chrome Custom Tabs, use intent:// scheme which works more reliably + // This explicitly tells Chrome to send an intent to the app, which will bring + // SignInCodeActivity back to the foreground (it has launchMode="singleTop") + setTimeout(() => { + window.location.href = "intent://auth-success#Intent;scheme=pangolin;package=net.pangolin.Pangolin;end"; + }, 500); + } else if (isIOS) { + // Wait 500ms then attempt to open the app + setTimeout(() => { + // Try to open the app using deep link + window.location.href = "pangolin://"; + + setTimeout(() => { + window.location.href = "https://apps.apple.com/app/pangolin/net.pangolin.Pangolin.PangoliniOS"; + }, 2000); + }, 500); + } + }, []); + return ( <> @@ -55,4 +82,4 @@ export default function DeviceAuthSuccessPage() {

); -} +} \ No newline at end of file diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index bd6327fd..0c9faafc 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -70,7 +70,7 @@ export default async function Page(props: { } let loginIdps: LoginFormIDP[] = []; - if (build !== "saas") { + if (build === "oss" || !env.flags.useOrgOnlyIdp) { const idpsRes = await cache( async () => await priv.get>("/idp") )(); @@ -103,6 +103,10 @@ export default async function Page(props: { redirect={redirectUrl} idps={loginIdps} forceLogin={forceLogin} + showOrgLogin={ + !isInvite && (build === "saas" || env.flags.useOrgOnlyIdp) + } + searchParams={searchParams} /> {(!signUpDisabled || isInvite) && ( @@ -120,35 +124,6 @@ export default async function Page(props: {

)} - - {!isInvite && build === "saas" ? ( -
- {t("needToSignInToOrg")} - - {t("orgAuthSignInToOrg")} - -
- ) : null} ); } - -function buildQueryString(searchParams: { - [key: string]: string | string[] | undefined; -}): string { - const params = new URLSearchParams(); - const redirect = searchParams.redirect; - const forceLogin = searchParams.forceLogin; - - if (redirect && typeof redirect === "string") { - params.set("redirect", redirect); - } - if (forceLogin && typeof forceLogin === "string") { - params.set("forceLogin", forceLogin); - } - const queryString = params.toString(); - return queryString ? `?${queryString}` : ""; -} diff --git a/src/app/auth/org/[orgId]/page.tsx b/src/app/auth/org/[orgId]/page.tsx index 1958a388..73e5f39b 100644 --- a/src/app/auth/org/[orgId]/page.tsx +++ b/src/app/auth/org/[orgId]/page.tsx @@ -11,6 +11,7 @@ import { } from "@server/routers/loginPage/types"; import { redirect } from "next/navigation"; import OrgLoginPage from "@app/components/OrgLoginPage"; +import { pullEnv } from "@app/lib/pullEnv"; export const dynamic = "force-dynamic"; @@ -21,7 +22,9 @@ export default async function OrgAuthPage(props: { const searchParams = await props.searchParams; const params = await props.params; - if (build !== "saas") { + const env = pullEnv(); + + if (build !== "saas" && !env.flags.useOrgOnlyIdp) { const queryString = new URLSearchParams(searchParams as any).toString(); redirect(`/auth/login${queryString ? `?${queryString}` : ""}`); } @@ -50,29 +53,25 @@ export default async function OrgAuthPage(props: { } catch (e) {} let loginIdps: LoginFormIDP[] = []; - if (build === "saas") { - const idpsRes = await priv.get>( - `/org/${orgId}/idp` - ); + const idpsRes = await priv.get>( + `/org/${orgId}/idp` + ); - loginIdps = idpsRes.data.data.idps.map((idp) => ({ - idpId: idp.idpId, - name: idp.name, - variant: idp.variant - })) as LoginFormIDP[]; - } + loginIdps = idpsRes.data.data.idps.map((idp) => ({ + idpId: idp.idpId, + name: idp.name, + variant: idp.variant + })) as LoginFormIDP[]; let branding: LoadLoginPageBrandingResponse | null = null; - if (build === "saas") { - try { - const res = await priv.get< - AxiosResponse - >(`/login-page-branding?orgId=${orgId}`); - if (res.status === 200) { - branding = res.data.data; - } - } catch (error) {} - } + try { + const res = await priv.get< + AxiosResponse + >(`/login-page-branding?orgId=${orgId}`); + if (res.status === 200) { + branding = res.data.data; + } + } catch (error) {} return ( diff --git a/src/app/globals.css b/src/app/globals.css index 731e1bff..bbb165c2 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -21,6 +21,7 @@ --accent: oklch(0.967 0.001 286.375); --accent-foreground: oklch(0.21 0.006 285.885); --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.985 0 0); --border: oklch(0.91 0.004 286.32); --input: oklch(0.92 0.004 286.32); --ring: oklch(0.705 0.213 47.604); @@ -55,6 +56,7 @@ --accent: oklch(0.274 0.006 286.033); --accent-foreground: oklch(0.985 0 0); --destructive: oklch(0.5382 0.1949 22.216); + --destructive-foreground: oklch(0.985 0 0); --border: oklch(1 0 0 / 13%); --input: oklch(1 0 0 / 18%); --ring: oklch(0.646 0.222 41.116); diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index 98bbe307..004805ad 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -1,4 +1,5 @@ import { SidebarNavItem } from "@app/components/SidebarNav"; +import { Env } from "@app/lib/types/env"; import { build } from "@server/build"; import { ChartLine, @@ -39,7 +40,7 @@ export const orgLangingNavItems: SidebarNavItem[] = [ } ]; -export const orgNavSections = (): SidebarNavSection[] => [ +export const orgNavSections = (env?: Env): SidebarNavSection[] => [ { heading: "sidebarGeneral", items: [ @@ -92,8 +93,7 @@ export const orgNavSections = (): SidebarNavSection[] => [ { title: "sidebarRemoteExitNodes", href: "/{orgId}/settings/remote-exit-nodes", - icon: , - showEE: true + icon: } ] : []) @@ -123,13 +123,21 @@ export const orgNavSections = (): SidebarNavSection[] => [ href: "/{orgId}/settings/access/roles", icon: }, - ...(build === "saas" + ...(build === "saas" || env?.flags.useOrgOnlyIdp ? [ { title: "sidebarIdentityProviders", href: "/{orgId}/settings/idp", - icon: , - showEE: true + icon: + } + ] + : []), + ...(build !== "oss" + ? [ + { + title: "sidebarApprovals", + href: "/{orgId}/settings/access/approvals", + icon: } ] : []), @@ -237,7 +245,7 @@ export const orgNavSections = (): SidebarNavSection[] => [ } ]; -export const adminNavSections: SidebarNavSection[] = [ +export const adminNavSections = (env?: Env): SidebarNavSection[] => [ { heading: "sidebarAdmin", items: [ @@ -251,11 +259,15 @@ export const adminNavSections: SidebarNavSection[] = [ href: "/admin/api-keys", icon: }, - { - title: "sidebarIdentityProviders", - href: "/admin/idp", - icon: - }, + ...(build === "oss" || !env?.flags.useOrgOnlyIdp + ? [ + { + title: "sidebarIdentityProviders", + href: "/admin/idp", + icon: + } + ] + : []), ...(build == "enterprise" ? [ { diff --git a/src/components/AuthPageBrandingForm.tsx b/src/components/AuthPageBrandingForm.tsx index 67e2232f..119a39cb 100644 --- a/src/components/AuthPageBrandingForm.tsx +++ b/src/components/AuthPageBrandingForm.tsx @@ -118,6 +118,7 @@ export default function AuthPageBrandingForm({ const brandingData = form.getValues(); if (!isValid || !isPaidUser) return; + try { const updateRes = await api.put( `/org/${orgId}/login-page-branding`, @@ -289,7 +290,8 @@ export default function AuthPageBrandingForm({
- {build === "saas" && ( + {build === "saas" || + env.env.flags.useOrgOnlyIdp ? ( <>
@@ -343,7 +345,7 @@ export default function AuthPageBrandingForm({ />
- )} + ) : null}
diff --git a/src/components/ConfirmDeleteDialog.tsx b/src/components/ConfirmDeleteDialog.tsx index 86b25bb2..4c5c6ad6 100644 --- a/src/components/ConfirmDeleteDialog.tsx +++ b/src/components/ConfirmDeleteDialog.tsx @@ -63,6 +63,8 @@ export default function ConfirmDeleteDialog({ } }); + const isConfirmed = form.watch("string") === string; + async function onSubmit() { try { await onConfirm(); @@ -139,7 +141,8 @@ export default function ConfirmDeleteDialog({ type="submit" form="confirm-delete-form" loading={loading} - disabled={loading} + disabled={loading || !isConfirmed} + className={!isConfirmed && !loading ? "opacity-50" : ""} > {buttonText} diff --git a/src/components/DashboardLoginForm.tsx b/src/components/DashboardLoginForm.tsx index ccb5c497..5082f00d 100644 --- a/src/components/DashboardLoginForm.tsx +++ b/src/components/DashboardLoginForm.tsx @@ -17,17 +17,26 @@ import { cleanRedirect } from "@app/lib/cleanRedirect"; import BrandingLogo from "@app/components/BrandingLogo"; import { useTranslations } from "next-intl"; import { useLicenseStatusContext } from "@app/hooks/useLicenseStatusContext"; +import Link from "next/link"; +import { Button } from "./ui/button"; +import { ArrowRight } from "lucide-react"; type DashboardLoginFormProps = { redirect?: string; idps?: LoginFormIDP[]; forceLogin?: boolean; + showOrgLogin?: boolean; + searchParams?: { + [key: string]: string | string[] | undefined; + }; }; export default function DashboardLoginForm({ redirect, idps, - forceLogin + forceLogin, + showOrgLogin, + searchParams }: DashboardLoginFormProps) { const router = useRouter(); const { env } = useEnvContext(); @@ -35,6 +44,9 @@ export default function DashboardLoginForm({ const { isUnlocked } = useLicenseStatusContext(); function getSubtitle() { + if (forceLogin) { + return t("loginRequiredForDevice"); + } if (isUnlocked() && env.branding?.loginPage?.subtitleText) { return env.branding.loginPage.subtitleText; } @@ -57,6 +69,22 @@ export default function DashboardLoginForm({

{getSubtitle()}

+ {showOrgLogin && ( +
+ + + +
+ )} ); } + +function buildQueryString(searchParams: { + [key: string]: string | string[] | undefined; +}): string { + const params = new URLSearchParams(); + const redirect = searchParams.redirect; + const forceLogin = searchParams.forceLogin; + + if (redirect && typeof redirect === "string") { + params.set("redirect", redirect); + } + if (forceLogin && typeof forceLogin === "string") { + params.set("forceLogin", forceLogin); + } + const queryString = params.toString(); + return queryString ? `?${queryString}` : ""; +} diff --git a/src/components/DeviceLoginForm.tsx b/src/components/DeviceLoginForm.tsx index 1eeeb5ae..777c1c03 100644 --- a/src/components/DeviceLoginForm.tsx +++ b/src/components/DeviceLoginForm.tsx @@ -85,8 +85,6 @@ export default function DeviceLoginForm({ data.code = data.code.slice(0, 4) + "-" + data.code.slice(4); } - await new Promise((resolve) => setTimeout(resolve, 300)); - // First check - get metadata const res = await api.post( "/device-web-auth/verify?forceLogin=true", @@ -117,8 +115,6 @@ export default function DeviceLoginForm({ setLoading(true); try { - await new Promise((resolve) => setTimeout(resolve, 300)); - // Final verify await api.post("/device-web-auth/verify", { code: code, diff --git a/src/components/LoginForm.tsx b/src/components/LoginForm.tsx index ce1f4dcb..49bcc69b 100644 --- a/src/components/LoginForm.tsx +++ b/src/components/LoginForm.tsx @@ -409,15 +409,6 @@ export default function LoginForm({ return (
- {forceLogin && ( - - - - {t("loginRequiredForDevice")} - - - )} - {showSecurityKeyPrompt && ( diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index cb06a75c..71117be6 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -12,7 +12,12 @@ import { import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { ArrowRight, ArrowUpDown, MoreHorizontal } from "lucide-react"; +import { + ArrowRight, + ArrowUpDown, + MoreHorizontal, + CircleSlash +} from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -35,6 +40,8 @@ export type ClientRow = { userEmail: string | null; niceId: string; agent: string | null; + archived?: boolean; + blocked?: boolean; approvalState: "approved" | "pending" | "denied"; }; @@ -52,6 +59,7 @@ export default function MachineClientsTable({ const t = useTranslations(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isBlockModalOpen, setIsBlockModalOpen] = useState(false); const [selectedClient, setSelectedClient] = useState( null ); @@ -97,6 +105,76 @@ export default function MachineClientsTable({ }); }; + const archiveClient = (clientId: number) => { + api.post(`/client/${clientId}/archive`) + .catch((e) => { + console.error("Error archiving client", e); + toast({ + variant: "destructive", + title: "Error archiving client", + description: formatAxiosError(e, "Error archiving client") + }); + }) + .then(() => { + startTransition(() => { + router.refresh(); + }); + }); + }; + + const unarchiveClient = (clientId: number) => { + api.post(`/client/${clientId}/unarchive`) + .catch((e) => { + console.error("Error unarchiving client", e); + toast({ + variant: "destructive", + title: "Error unarchiving client", + description: formatAxiosError(e, "Error unarchiving client") + }); + }) + .then(() => { + startTransition(() => { + router.refresh(); + }); + }); + }; + + const blockClient = (clientId: number) => { + api.post(`/client/${clientId}/block`) + .catch((e) => { + console.error("Error blocking client", e); + toast({ + variant: "destructive", + title: "Error blocking client", + description: formatAxiosError(e, "Error blocking client") + }); + }) + .then(() => { + startTransition(() => { + router.refresh(); + setIsBlockModalOpen(false); + setSelectedClient(null); + }); + }); + }; + + const unblockClient = (clientId: number) => { + api.post(`/client/${clientId}/unblock`) + .catch((e) => { + console.error("Error unblocking client", e); + toast({ + variant: "destructive", + title: "Error unblocking client", + description: formatAxiosError(e, "Error unblocking client") + }); + }) + .then(() => { + startTransition(() => { + router.refresh(); + }); + }); + }; + // Check if there are any rows without userIds in the current view's data const hasRowsWithoutUserId = useMemo(() => { return machineClients.some((client) => !client.userId) ?? false; @@ -122,6 +200,28 @@ export default function MachineClientsTable({ ); + }, + cell: ({ row }) => { + const r = row.original; + return ( +
+ {r.name} + {r.archived && ( + + {t("archived")} + + )} + {r.blocked && ( + + + {t("blocked")} + + )} +
+ ); } }, { @@ -301,14 +401,37 @@ export default function MachineClientsTable({ - {/* */} - {/* */} - {/* View settings */} - {/* */} - {/* */} + { + if (clientRow.archived) { + unarchiveClient(clientRow.id); + } else { + archiveClient(clientRow.id); + } + }} + > + + {clientRow.archived + ? "Unarchive" + : "Archive"} + + + { + if (clientRow.blocked) { + unblockClient(clientRow.id); + } else { + setSelectedClient(clientRow); + setIsBlockModalOpen(true); + } + }} + > + + {clientRow.blocked + ? "Unblock" + : "Block"} + + { setSelectedClient(clientRow); @@ -359,6 +482,27 @@ export default function MachineClientsTable({ title="Delete Client" /> )} + {selectedClient && ( + { + setIsBlockModalOpen(val); + if (!val) { + setSelectedClient(null); + } + }} + dialog={ +
+

{t("blockClientQuestion")}

+

{t("blockClientMessage")}

+
+ } + buttonText={t("blockClientConfirm")} + onConfirm={async () => blockClient(selectedClient!.id)} + string={selectedClient.name} + title={t("blockClient")} + /> + )} { + if (selectedValues.length === 0) return true; + const rowArchived = row.archived || false; + const rowBlocked = row.blocked || false; + const isActive = !rowArchived && !rowBlocked; + + if (selectedValues.includes("active") && isActive) + return true; + if ( + selectedValues.includes("archived") && + rowArchived + ) + return true; + if ( + selectedValues.includes("blocked") && + rowBlocked + ) + return true; + return false; + }, + defaultValues: ["active"] // Default to showing active clients + } + ]} /> ); diff --git a/src/components/PermissionsSelectBox.tsx b/src/components/PermissionsSelectBox.tsx index 9cfe2aaf..73f8a212 100644 --- a/src/components/PermissionsSelectBox.tsx +++ b/src/components/PermissionsSelectBox.tsx @@ -103,6 +103,10 @@ function getActionsCategories(root: boolean) { Client: { [t("actionCreateClient")]: "createClient", [t("actionDeleteClient")]: "deleteClient", + [t("actionArchiveClient")]: "archiveClient", + [t("actionUnarchiveClient")]: "unarchiveClient", + [t("actionBlockClient")]: "blockClient", + [t("actionUnblockClient")]: "unblockClient", [t("actionUpdateClient")]: "updateClient", [t("actionListClients")]: "listClients", [t("actionGetClient")]: "getClient" @@ -114,6 +118,16 @@ function getActionsCategories(root: boolean) { } }; + if (root || build === "saas" || env.flags.useOrgOnlyIdp) { + actionsByCategory["Identity Provider (IDP)"] = { + [t("actionCreateIdp")]: "createIdp", + [t("actionUpdateIdp")]: "updateIdp", + [t("actionDeleteIdp")]: "deleteIdp", + [t("actionListIdps")]: "listIdps", + [t("actionGetIdp")]: "getIdp" + }; + } + if (root) { actionsByCategory["Organization"] = { [t("actionListOrgs")]: "listOrgs", @@ -128,24 +142,21 @@ function getActionsCategories(root: boolean) { ...actionsByCategory["Organization"] }; - actionsByCategory["Identity Provider (IDP)"] = { - [t("actionCreateIdp")]: "createIdp", - [t("actionUpdateIdp")]: "updateIdp", - [t("actionDeleteIdp")]: "deleteIdp", - [t("actionListIdps")]: "listIdps", - [t("actionGetIdp")]: "getIdp", - [t("actionCreateIdpOrg")]: "createIdpOrg", - [t("actionDeleteIdpOrg")]: "deleteIdpOrg", - [t("actionListIdpOrgs")]: "listIdpOrgs", - [t("actionUpdateIdpOrg")]: "updateIdpOrg" - }; + actionsByCategory["Identity Provider (IDP)"][t("actionCreateIdpOrg")] = + "createIdpOrg"; + actionsByCategory["Identity Provider (IDP)"][t("actionDeleteIdpOrg")] = + "deleteIdpOrg"; + actionsByCategory["Identity Provider (IDP)"][t("actionListIdpOrgs")] = + "listIdpOrgs"; + actionsByCategory["Identity Provider (IDP)"][t("actionUpdateIdpOrg")] = + "updateIdpOrg"; actionsByCategory["User"] = { [t("actionUpdateUser")]: "updateUser", [t("actionGetUser")]: "getUser" }; - if (build == "saas") { + if (build === "saas") { actionsByCategory["SAAS"] = { ["Send Usage Notification Email"]: "sendUsageNotification" }; diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 29bb2602..3d559f46 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -16,7 +16,8 @@ import { ArrowRight, ArrowUpDown, ArrowUpRight, - MoreHorizontal + MoreHorizontal, + CircleSlash } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; @@ -45,6 +46,8 @@ export type ClientRow = { niceId: string; agent: string | null; approvalState: "approved" | "pending" | "denied"; + archived?: boolean; + blocked?: boolean; }; type ClientTableProps = { @@ -57,6 +60,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { const t = useTranslations(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isBlockModalOpen, setIsBlockModalOpen] = useState(false); const [selectedClient, setSelectedClient] = useState( null ); @@ -103,6 +107,76 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }); }; + const archiveClient = (clientId: number) => { + api.post(`/client/${clientId}/archive`) + .catch((e) => { + console.error("Error archiving client", e); + toast({ + variant: "destructive", + title: "Error archiving client", + description: formatAxiosError(e, "Error archiving client") + }); + }) + .then(() => { + startTransition(() => { + router.refresh(); + }); + }); + }; + + const unarchiveClient = (clientId: number) => { + api.post(`/client/${clientId}/unarchive`) + .catch((e) => { + console.error("Error unarchiving client", e); + toast({ + variant: "destructive", + title: "Error unarchiving client", + description: formatAxiosError(e, "Error unarchiving client") + }); + }) + .then(() => { + startTransition(() => { + router.refresh(); + }); + }); + }; + + const blockClient = (clientId: number) => { + api.post(`/client/${clientId}/block`) + .catch((e) => { + console.error("Error blocking client", e); + toast({ + variant: "destructive", + title: "Error blocking client", + description: formatAxiosError(e, "Error blocking client") + }); + }) + .then(() => { + startTransition(() => { + router.refresh(); + setIsBlockModalOpen(false); + setSelectedClient(null); + }); + }); + }; + + const unblockClient = (clientId: number) => { + api.post(`/client/${clientId}/unblock`) + .catch((e) => { + console.error("Error unblocking client", e); + toast({ + variant: "destructive", + title: "Error unblocking client", + description: formatAxiosError(e, "Error unblocking client") + }); + }) + .then(() => { + startTransition(() => { + router.refresh(); + }); + }); + }; + // Check if there are any rows without userIds in the current view's data const hasRowsWithoutUserId = useMemo(() => { return userClients.some((client) => !client.userId); @@ -128,6 +202,28 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { ); + }, + cell: ({ row }) => { + const r = row.original; + return ( +
+ {r.name} + {r.archived && ( + + {t("archived")} + + )} + {r.blocked && ( + + + {t("blocked")} + + )} +
+ ); } }, { @@ -351,7 +447,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { header: () => , cell: ({ row }) => { const clientRow = row.original; - return !clientRow.userId ? ( + return (
@@ -361,34 +457,62 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { - {/* */} - {/* */} - {/* View settings */} - {/* */} - {/* */} { - setSelectedClient(clientRow); - setIsDeleteModalOpen(true); + if (clientRow.archived) { + unarchiveClient(clientRow.id); + } else { + archiveClient(clientRow.id); + } }} > - Delete + + {clientRow.archived + ? "Unarchive" + : "Archive"} + + { + if (clientRow.blocked) { + unblockClient(clientRow.id); + } else { + setSelectedClient(clientRow); + setIsBlockModalOpen(true); + } + }} + > + + {clientRow.blocked + ? "Unblock" + : "Block"} + + + {!clientRow.userId && ( + // Machine client - also show delete option + { + setSelectedClient(clientRow); + setIsDeleteModalOpen(true); + }} + > + + Delete + + + )}
- ) : null; + ); } }); @@ -397,7 +521,7 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { return ( <> - {selectedClient && ( + {selectedClient && !selectedClient.userId && ( { @@ -416,6 +540,27 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { title="Delete Client" /> )} + {selectedClient && ( + { + setIsBlockModalOpen(val); + if (!val) { + setSelectedClient(null); + } + }} + dialog={ +
+

{t("blockClientQuestion")}

+

{t("blockClientMessage")}

+
+ } + buttonText={t("blockClientConfirm")} + onConfirm={async () => blockClient(selectedClient!.id)} + string={selectedClient.name} + title={t("blockClient")} + /> + )} @@ -432,6 +577,55 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { columnVisibility={defaultUserColumnVisibility} stickyLeftColumn="name" stickyRightColumn="actions" + filters={[ + { + id: "status", + label: t("status") || "Status", + multiSelect: true, + displayMode: "calculated", + options: [ + { + id: "active", + label: t("active") || "Active", + value: "active" + }, + { + id: "archived", + label: t("archived") || "Archived", + value: "archived" + }, + { + id: "blocked", + label: t("blocked") || "Blocked", + value: "blocked" + } + ], + filterFn: ( + row: ClientRow, + selectedValues: (string | number | boolean)[] + ) => { + if (selectedValues.length === 0) return true; + const rowArchived = row.archived || false; + const rowBlocked = row.blocked || false; + const isActive = !rowArchived && !rowBlocked; + + if (selectedValues.includes("active") && isActive) + return true; + if ( + selectedValues.includes("archived") && + rowArchived + ) + return true; + if ( + selectedValues.includes("blocked") && + rowBlocked + ) + return true; + return false; + }, + defaultValues: ["active"] // Default to showing active clients + } + ]} /> ); diff --git a/src/components/ViewDevicesDialog.tsx b/src/components/ViewDevicesDialog.tsx index 70c55ded..a54a71fd 100644 --- a/src/components/ViewDevicesDialog.tsx +++ b/src/components/ViewDevicesDialog.tsx @@ -27,6 +27,7 @@ import { TableHeader, TableRow } from "@app/components/ui/table"; +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@app/components/ui/tabs"; import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; import { Loader2, RefreshCw } from "lucide-react"; import moment from "moment"; @@ -44,6 +45,7 @@ type Device = { name: string | null; clientId: number | null; userId: string | null; + archived: boolean; }; export default function ViewDevicesDialog({ @@ -57,8 +59,9 @@ export default function ViewDevicesDialog({ const [devices, setDevices] = useState([]); const [loading, setLoading] = useState(false); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isArchiveModalOpen, setIsArchiveModalOpen] = useState(false); const [selectedDevice, setSelectedDevice] = useState(null); + const [activeTab, setActiveTab] = useState<"available" | "archived">("available"); const fetchDevices = async () => { setLoading(true); @@ -90,26 +93,59 @@ export default function ViewDevicesDialog({ } }, [open]); - const deleteDevice = async (olmId: string) => { + const archiveDevice = async (olmId: string) => { try { - await api.delete(`/user/${user?.userId}/olm/${olmId}`); + await api.post(`/user/${user?.userId}/olm/${olmId}/archive`); toast({ - title: t("deviceDeleted") || "Device deleted", + title: t("deviceArchived") || "Device archived", description: - t("deviceDeletedDescription") || - "The device has been successfully deleted." + t("deviceArchivedDescription") || + "The device has been successfully archived." }); - setDevices(devices.filter((d) => d.olmId !== olmId)); - setIsDeleteModalOpen(false); + // Update the device's archived status in the local state + setDevices( + devices.map((d) => + d.olmId === olmId ? { ...d, archived: true } : d + ) + ); + setIsArchiveModalOpen(false); setSelectedDevice(null); } catch (error: any) { - console.error("Error deleting device:", error); + console.error("Error archiving device:", error); toast({ variant: "destructive", - title: t("errorDeletingDevice") || "Error deleting device", + title: t("errorArchivingDevice"), description: formatAxiosError( error, - t("failedToDeleteDevice") || "Failed to delete device" + t("failedToArchiveDevice") + ) + }); + } + }; + + const unarchiveDevice = async (olmId: string) => { + try { + await api.post(`/user/${user?.userId}/olm/${olmId}/unarchive`); + toast({ + title: t("deviceUnarchived") || "Device unarchived", + description: + t("deviceUnarchivedDescription") || + "The device has been successfully unarchived." + }); + // Update the device's archived status in the local state + setDevices( + devices.map((d) => + d.olmId === olmId ? { ...d, archived: false } : d + ) + ); + } catch (error: any) { + console.error("Error unarchiving device:", error); + toast({ + variant: "destructive", + title: t("errorUnarchivingDevice") || "Error unarchiving device", + description: formatAxiosError( + error, + t("failedToUnarchiveDevice") || "Failed to unarchive device" ) }); } @@ -118,7 +154,7 @@ export default function ViewDevicesDialog({ function reset() { setDevices([]); setSelectedDevice(null); - setIsDeleteModalOpen(false); + setIsArchiveModalOpen(false); } return ( @@ -147,9 +183,40 @@ export default function ViewDevicesDialog({
- ) : devices.length === 0 ? ( + ) : ( + + setActiveTab(value as "available" | "archived") + } + className="w-full" + > + + + {t("available") || "Available"} ( + { + devices.filter( + (d) => !d.archived + ).length + } + ) + + + {t("archived") || "Archived"} ( + { + devices.filter( + (d) => d.archived + ).length + } + ) + + + + {devices.filter((d) => !d.archived) + .length === 0 ? (
- {t("noDevices") || "No devices found"} + {t("noDevices") || + "No devices found"}
) : (
@@ -164,22 +231,33 @@ export default function ViewDevicesDialog({ "Date Created"} - {t("actions") || "Actions"} + {t("actions") || + "Actions"} - {devices.map((device) => ( - + {devices + .filter( + (d) => !d.archived + ) + .map((device) => ( + {device.name || - t("unnamedDevice") || + t( + "unnamedDevice" + ) || "Unnamed Device"} {moment( device.dateCreated - ).format("lll")} + ).format( + "lll" + )} @@ -202,6 +282,74 @@ export default function ViewDevicesDialog({
+ )} +
+ + {devices.filter((d) => d.archived) + .length === 0 ? ( +
+ {t("noArchivedDevices") || + "No archived devices found"} +
+ ) : ( +
+ + + + + {t("name") || "Name"} + + + {t("dateCreated") || + "Date Created"} + + + {t("actions") || + "Actions"} + + + + + {devices + .filter( + (d) => d.archived + ) + .map((device) => ( + + + {device.name || + t( + "unnamedDevice" + ) || + "Unnamed Device"} + + + {moment( + device.dateCreated + ).format( + "lll" + )} + + + + + + ))} + +
+
+ )} +
+
)} @@ -216,9 +364,9 @@ export default function ViewDevicesDialog({ {selectedDevice && ( { - setIsDeleteModalOpen(val); + setIsArchiveModalOpen(val); if (!val) { setSelectedDevice(null); } @@ -226,19 +374,19 @@ export default function ViewDevicesDialog({ dialog={

- {t("deviceQuestionRemove") || - "Are you sure you want to delete this device?"} + {t("deviceQuestionArchive") || + "Are you sure you want to archive this device?"}

- {t("deviceMessageRemove") || - "This action cannot be undone."} + {t("deviceMessageArchive") || + "The device will be archived and removed from your active devices list."}

} - buttonText={t("deviceDeleteConfirm") || "Delete Device"} - onConfirm={async () => deleteDevice(selectedDevice.olmId)} + buttonText={t("deviceArchiveConfirm") || "Archive Device"} + onConfirm={async () => archiveDevice(selectedDevice.olmId)} string={selectedDevice.name || selectedDevice.olmId} - title={t("deleteDevice") || "Delete Device"} + title={t("archiveDevice") || "Archive Device"} /> )} diff --git a/src/components/private/SplashImage.tsx b/src/components/private/SplashImage.tsx index ad9d5069..576577c4 100644 --- a/src/components/private/SplashImage.tsx +++ b/src/components/private/SplashImage.tsx @@ -21,7 +21,7 @@ export default function SplashImage({ children }: SplashImageProps) { if (!env.branding.background_image_path) { return false; } - const pathsPrefixes = ["/auth/login", "/auth/signup", "/auth/resource"]; + const pathsPrefixes = ["/auth/login", "/auth/signup", "/auth/resource", "/auth/org"]; for (const prefix of pathsPrefixes) { if (pathname.startsWith(prefix)) { return true; diff --git a/src/components/private/ValidateSessionTransferToken.tsx b/src/components/private/ValidateSessionTransferToken.tsx index c83b61ba..cdd3cb34 100644 --- a/src/components/private/ValidateSessionTransferToken.tsx +++ b/src/components/private/ValidateSessionTransferToken.tsx @@ -50,9 +50,13 @@ export default function ValidateSessionTransferToken( } if (doRedirect) { - // add redirect param to dashboardUrl if provided - const fullUrl = `${env.app.dashboardUrl}${props.redirect || ""}`; - router.push(fullUrl); + if (props.redirect && props.redirect.startsWith("http")) { + router.push(props.redirect); + } else { + // add redirect param to dashboardUrl if provided + const fullUrl = `${env.app.dashboardUrl}${props.redirect || ""}`; + router.push(fullUrl); + } } } diff --git a/src/components/ui/data-table.tsx b/src/components/ui/data-table.tsx index d6339081..af61bb53 100644 --- a/src/components/ui/data-table.tsx +++ b/src/components/ui/data-table.tsx @@ -33,7 +33,7 @@ import { Button } from "@app/components/ui/button"; import { useEffect, useMemo, useRef, useState } from "react"; import { Input } from "@app/components/ui/input"; import { DataTablePagination } from "@app/components/DataTablePagination"; -import { Plus, Search, RefreshCw, Columns } from "lucide-react"; +import { Plus, Search, RefreshCw, Columns, Filter } from "lucide-react"; import { Card, CardContent, @@ -140,6 +140,22 @@ type TabFilter = { filterFn: (row: any) => boolean; }; +type FilterOption = { + id: string; + label: string; + value: string | number | boolean; +}; + +type DataTableFilter = { + id: string; + label: string; + options: FilterOption[]; + multiSelect?: boolean; + filterFn: (row: any, selectedValues: (string | number | boolean)[]) => boolean; + defaultValues?: (string | number | boolean)[]; + displayMode?: "label" | "calculated"; // How to display the filter button text +}; + type DataTableProps = { columns: ExtendedColumnDef[]; data: TData[]; @@ -156,6 +172,8 @@ type DataTableProps = { }; tabs?: TabFilter[]; defaultTab?: string; + filters?: DataTableFilter[]; + filterDisplayMode?: "label" | "calculated"; // Global filter display mode (can be overridden per filter) persistPageSize?: boolean | string; defaultPageSize?: number; columnVisibility?: Record; @@ -178,6 +196,8 @@ export function DataTable({ defaultSort, tabs, defaultTab, + filters, + filterDisplayMode = "label", persistPageSize = false, defaultPageSize = 20, columnVisibility: defaultColumnVisibility, @@ -235,6 +255,15 @@ export function DataTable({ const [activeTab, setActiveTab] = useState( defaultTab || tabs?.[0]?.id || "" ); + const [activeFilters, setActiveFilters] = useState>( + () => { + const initial: Record = {}; + filters?.forEach((filter) => { + initial[filter.id] = filter.defaultValues || []; + }); + return initial; + } + ); // Track initial values to avoid storing defaults on first render const initialPageSize = useRef(pageSize); @@ -242,19 +271,32 @@ export function DataTable({ const hasUserChangedPageSize = useRef(false); const hasUserChangedColumnVisibility = useRef(false); - // Apply tab filter to data + // Apply tab and custom filters to data const filteredData = useMemo(() => { - if (!tabs || activeTab === "") { - return data; + let result = data; + + // Apply tab filter + if (tabs && activeTab !== "") { + const activeTabFilter = tabs.find((tab) => tab.id === activeTab); + if (activeTabFilter) { + result = result.filter(activeTabFilter.filterFn); + } } - const activeTabFilter = tabs.find((tab) => tab.id === activeTab); - if (!activeTabFilter) { - return data; + // Apply custom filters + if (filters && filters.length > 0) { + filters.forEach((filter) => { + const selectedValues = activeFilters[filter.id] || []; + if (selectedValues.length > 0) { + result = result.filter((row) => + filter.filterFn(row, selectedValues) + ); + } + }); } - return data.filter(activeTabFilter.filterFn); - }, [data, tabs, activeTab]); + return result; + }, [data, tabs, activeTab, filters, activeFilters]); const table = useReactTable({ data: filteredData, @@ -318,6 +360,64 @@ export function DataTable({ setPagination((prev) => ({ ...prev, pageIndex: 0 })); }; + const handleFilterChange = ( + filterId: string, + optionValue: string | number | boolean, + checked: boolean + ) => { + setActiveFilters((prev) => { + const currentValues = prev[filterId] || []; + const filter = filters?.find((f) => f.id === filterId); + + if (!filter) return prev; + + let newValues: (string | number | boolean)[]; + + if (filter.multiSelect) { + // Multi-select: add or remove the value + if (checked) { + newValues = [...currentValues, optionValue]; + } else { + newValues = currentValues.filter((v) => v !== optionValue); + } + } else { + // Single-select: replace the value + newValues = checked ? [optionValue] : []; + } + + return { + ...prev, + [filterId]: newValues + }; + }); + // Reset to first page when changing filters + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + }; + + // Calculate display text for a filter based on selected values + const getFilterDisplayText = (filter: DataTableFilter): string => { + const selectedValues = activeFilters[filter.id] || []; + + if (selectedValues.length === 0) { + return filter.label; + } + + const selectedOptions = filter.options.filter((option) => + selectedValues.includes(option.value) + ); + + if (selectedOptions.length === 0) { + return filter.label; + } + + if (selectedOptions.length === 1) { + return selectedOptions[0].label; + } + + // Multiple selections: always join with "and" + return selectedOptions.map((opt) => opt.label).join(" and "); + }; + // Enhanced pagination component that updates our local state const handlePageSizeChange = (newPageSize: number) => { hasUserChangedPageSize.current = true; @@ -387,6 +487,63 @@ export function DataTable({ />
+ {filters && filters.length > 0 && ( +
+ {filters.map((filter) => { + const selectedValues = activeFilters[filter.id] || []; + const hasActiveFilters = selectedValues.length > 0; + const displayMode = filter.displayMode || filterDisplayMode; + const displayText = displayMode === "calculated" + ? getFilterDisplayText(filter) + : filter.label; + + return ( + + + + + + + {filter.label} + + + {filter.options.map((option) => { + const isChecked = selectedValues.includes(option.value); + return ( + + handleFilterChange( + filter.id, + option.value, + checked + ) + } + onSelect={(e) => e.preventDefault()} + > + {option.label} + + ); + })} + + + ); + })} +
+ )} {tabs && tabs.length > 0 && ( + identityProviders: ({ + orgId, + useOrgOnlyIdp + }: { + orgId: string; + useOrgOnlyIdp?: boolean; + }) => queryOptions({ queryKey: ["ORG", orgId, "IDPS"] as const, queryFn: async ({ signal, meta }) => { @@ -166,7 +172,12 @@ export const orgQueries = { AxiosResponse<{ idps: { idpId: number; name: string }[]; }> - >(build === "saas" ? `/org/${orgId}/idp` : "/idp", { signal }); + >( + build === "saas" || useOrgOnlyIdp + ? `/org/${orgId}/idp` + : "/idp", + { signal } + ); return res.data.data.idps; } }) diff --git a/src/lib/themeColors.ts b/src/lib/themeColors.ts index 7c6bb46e..66fee55b 100644 --- a/src/lib/themeColors.ts +++ b/src/lib/themeColors.ts @@ -15,6 +15,7 @@ const defaultTheme = { accent: "oklch(0.967 0.001 286.375)", "accent-foreground": "oklch(0.21 0.006 285.885)", destructive: "oklch(0.577 0.245 27.325)", + "destructive-foreground": "oklch(0.985 0 0)", border: "oklch(0.92 0.004 286.32)", input: "oklch(0.92 0.004 286.32)", ring: "oklch(0.705 0.213 47.604)", @@ -41,6 +42,7 @@ const defaultTheme = { accent: "oklch(0.274 0.006 286.033)", "accent-foreground": "oklch(0.985 0 0)", destructive: "oklch(0.704 0.191 22.216)", + "destructive-foreground": "oklch(0.985 0 0)", border: "oklch(1 0 0 / 10%)", input: "oklch(1 0 0 / 15%)", ring: "oklch(0.646 0.222 41.116)", diff --git a/src/lib/types/env.ts b/src/lib/types/env.ts index 1f54f680..f99e1994 100644 --- a/src/lib/types/env.ts +++ b/src/lib/types/env.ts @@ -34,6 +34,7 @@ export type Env = { hideSupporterKey: boolean; usePangolinDns: boolean; disableProductHelpBanners: boolean; + useOrgOnlyIdp: boolean; }; branding: { appName?: string;