diff --git a/.github/workflows/build-branch-ee.yml b/.github/workflows/build-branch-ee.yml index c69513e401..cb8913454b 100644 --- a/.github/workflows/build-branch-ee.yml +++ b/.github/workflows/build-branch-ee.yml @@ -25,6 +25,11 @@ on: required: false default: false type: boolean + airgapped_build: + description: "Build for airgapped installation" + required: false + default: false + type: boolean env: TARGET_BRANCH: ${{ github.ref_name }} @@ -32,6 +37,7 @@ env: BUILD_TYPE: ${{ github.event.inputs.build_type }} RELEASE_VERSION: ${{ github.event.inputs.releaseVersion }} IS_PRERELEASE: ${{ github.event.inputs.isPrerelease }} + AIRGAPPED_BUILD: ${{ github.event.inputs.airgapped_build }} jobs: branch_build_setup: @@ -63,6 +69,8 @@ jobs: build_release: ${{ steps.set_env_variables.outputs.BUILD_RELEASE }} build_prerelease: ${{ steps.set_env_variables.outputs.BUILD_PRERELEASE }} release_version: ${{ steps.set_env_variables.outputs.RELEASE_VERSION }} + airgapped_build: ${{ steps.set_env_variables.outputs.AIRGAPPED_BUILD }} + arm64_build: ${{ steps.set_env_variables.outputs.ARM64_BUILD }} steps: - id: set_env_variables @@ -73,11 +81,13 @@ jobs: echo "BUILDX_VERSION=lab:latest" >> $GITHUB_OUTPUT echo "BUILDX_PLATFORMS=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT echo "BUILDX_ENDPOINT=makeplane/plane-dev" >> $GITHUB_OUTPUT + echo "ARM64_BUILD=true" >> $GITHUB_OUTPUT else echo "BUILDX_DRIVER=docker-container" >> $GITHUB_OUTPUT echo "BUILDX_VERSION=latest" >> $GITHUB_OUTPUT echo "BUILDX_PLATFORMS=linux/amd64" >> $GITHUB_OUTPUT echo "BUILDX_ENDPOINT=" >> $GITHUB_OUTPUT + echo "ARM64_BUILD=false" >> $GITHUB_OUTPUT fi BR_NAME=$( echo "${{ env.TARGET_BRANCH }}" |sed 's/[^a-zA-Z0-9.-]//g') echo "TARGET_BRANCH=$BR_NAME" >> $GITHUB_OUTPUT @@ -98,6 +108,8 @@ jobs: RELVERSION="latest" HARBOR_PUSH=false + echo "AIRGAPPED_BUILD=${{ env.AIRGAPPED_BUILD }}" >> $GITHUB_OUTPUT + if [ "${{ env.BUILD_TYPE }}" == "Release" ]; then FLAT_RELEASE_VERSION=$(echo "${{ env.RELEASE_VERSION }}" | sed 's/[^a-zA-Z0-9.-]//g') echo "FLAT_RELEASE_VERSION=${FLAT_RELEASE_VERSION}" >> $GITHUB_OUTPUT @@ -400,6 +412,131 @@ jobs: buildx-platforms: ${{ needs.branch_build_setup.outputs.gh_buildx_platforms }} buildx-endpoint: ${{ needs.branch_build_setup.outputs.gh_buildx_endpoint }} + package_airgapped_artifacts: + if: ${{ needs.branch_build_setup.outputs.airgapped_build == 'true' || needs.branch_build_setup.outputs.build_type == 'Release' }} + name: Package Airgapped Artifacts + runs-on: ubuntu-22.04 + needs: [branch_build_setup, branch_build_push_admin, branch_build_push_web, branch_build_push_space, branch_build_push_live, branch_build_push_apiserver, branch_build_push_proxy, branch_build_push_monitor, branch_build_push_email, branch_build_push_silo] + env: + AWS_ACCESS_KEY_ID: ${{ secrets.SELF_HOST_BUCKET_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.SELF_HOST_BUCKET_SECRET_KEY }} + AIRGAPPED_RELEASE_VERSION: ${{ needs.branch_build_setup.outputs.release_version }} + S3_PATH_AMD: "s3://${{ vars.SELF_HOST_BUCKET_NAME }}/plane-enterprise/${{ needs.branch_build_setup.outputs.artifact_s3_suffix }}/airgapped-amd64.tar.gz" + S3_PATH_ARM: "s3://${{ vars.SELF_HOST_BUCKET_NAME }}/plane-enterprise/${{ needs.branch_build_setup.outputs.artifact_s3_suffix }}/airgapped-arm64.tar.gz" + S3_REGION: ${{ vars.SELF_HOST_BUCKET_REGION }} + COMMIT_HASH: $(git rev-parse HEAD) + PRESIGNED_URL_EXPIRY: 604800 # 7 days (max allowed by AWS) + AIRGAPPED_ARM64_BUILD: ${{ needs.branch_build_setup.outputs.arm64_build == 'true' }} + steps: + - id: checkout_files + name: Checkout Files + uses: actions/checkout@v4 + + - id: install-qemu + uses: docker/setup-qemu-action@v3 + + - name: Docker Setup + uses: docker/setup-buildx-action@v3 + + - name: Setup yq + run: | + echo "Installing yq..." + sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq + + - name: Install crane + run: | + echo "Installing crane..." + curl -sL "https://github.com/google/go-containerregistry/releases/latest/download/go-containerregistry_Linux_x86_64.tar.gz" | tar -xz -C /usr/local/bin crane + sudo chmod +x /usr/local/bin/crane + + - id: install-aws-cli + uses: unfor19/install-aws-cli-action@v1 + with: + version: 2 + + - id: package_airgapped_artifacts + name: Package Airgapped Artifacts + run: | + AIRGAPPED_RELEASE_VERSION=${{ env.AIRGAPPED_RELEASE_VERSION }} + if [ "${{ needs.branch_build_setup.outputs.build_type}}" == "Build" ]; then + AIRGAPPED_RELEASE_VERSION=${{ needs.branch_build_setup.outputs.gh_branch_name }} + # for testing + # AIRGAPPED_RELEASE_VERSION=v1.10.0 + fi + echo "AIRGAPPED_RELEASE_VERSION=$AIRGAPPED_RELEASE_VERSION" >> $GITHUB_ENV + + # build for amd64 + cd ${{ github.workspace }}/deploy/airgapped + bash ./build.sh --release=$AIRGAPPED_RELEASE_VERSION --platform=linux/amd64 + cd dist + echo "Packaging airgapped artifacts and uploading to S3" + tar -czf - * | aws s3 cp - ${{ env.S3_PATH_AMD}} + echo "Airgapped artifacts uploaded to S3 at ${{ env.S3_PATH_AMD }}" + + # create a presigned url for the airgapped artifacts for 7 days + S3_PRESIGNED_URL_AMD=$(aws s3 presign ${{ env.S3_PATH_AMD }} --expires-in ${{ env.PRESIGNED_URL_EXPIRY }} --region ${{ env.S3_REGION }}) + echo "$S3_PRESIGNED_URL_AMD" > /tmp/presigned_url_amd.txt + + echo "AIRGAPPED_ARM64_BUILD=${{ env.AIRGAPPED_ARM64_BUILD }}" >> $GITHUB_OUTPUT + # build for arm64 + if [ "${{ env.AIRGAPPED_ARM64_BUILD }}" == "true" ]; then + cd ${{ github.workspace }}/deploy/airgapped + bash ./build.sh --release=$AIRGAPPED_RELEASE_VERSION --platform=linux/arm64 + cd dist + echo "Packaging airgapped artifacts and uploading to S3" + tar -czf - * | aws s3 cp - ${{ env.S3_PATH_ARM}} + echo "Airgapped artifacts uploaded to S3 at ${{ env.S3_PATH_ARM }}" + + # create a presigned url for the airgapped artifacts for 7 days + S3_PRESIGNED_URL_ARM=$(aws s3 presign ${{ env.S3_PATH_ARM }} --expires-in ${{ env.PRESIGNED_URL_EXPIRY }} --region ${{ env.S3_REGION }}) + echo "$S3_PRESIGNED_URL_ARM" > /tmp/presigned_url_arm.txt + fi + + echo "COMMIT_HASH=$(git rev-parse HEAD)" >> $GITHUB_ENV + + - name: upload_artifact + uses: actions/upload-artifact@v4 + with: + name: pre-signed-url-amd.txt + path: /tmp/presigned_url_amd.txt + + - name: upload_artifact + if: ${{ steps.package_airgapped_artifacts.outputs.AIRGAPPED_ARM64_BUILD == 'true' }} + uses: actions/upload-artifact@v4 + with: + name: pre-signed-url-arm.txt + path: /tmp/presigned_url_arm.txt + + - name: Airgapped Package Summary + run: | + + # Print Step Build Summary + echo "# 🚀 Airgapped Build Summary" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "## 🌍 Artifacts" >> $GITHUB_STEP_SUMMARY + echo "| Key | Value |" >> $GITHUB_STEP_SUMMARY + echo "| --- | --- |" >> $GITHUB_STEP_SUMMARY + echo "| Release Version | \`${{ env.AIRGAPPED_RELEASE_VERSION }}\` |" >> $GITHUB_STEP_SUMMARY + echo "| S3 Path AMD | \`${{ env.S3_PATH_AMD }}\` |" >> $GITHUB_STEP_SUMMARY + if [ "${{ steps.package_airgapped_artifacts.outputs.AIRGAPPED_ARM64_BUILD }}" == "true" ]; then + echo "| S3 Path ARM | \`${{ env.S3_PATH_ARM }}\` |" >> $GITHUB_STEP_SUMMARY + fi + echo "| Git Branch | \`${{env.TARGET_BRANCH}}\` |" >> $GITHUB_STEP_SUMMARY + echo "| Git Commit Hash | \`${{ env.COMMIT_HASH }}\` |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + cat >> $GITHUB_STEP_SUMMARY << 'EOF' + ## 📘 Usage Instructions + ```bash + curl -fsSL -o airgapped.tar.gz "" + mkdir -p airgapped + tar -xzf airgapped.tar.gz -C airgapped + cd airgapped + bash install.sh + ``` + EOF + upload_artifacts_s3: if: ${{ needs.branch_build_setup.outputs.artifact_upload_to_s3 == 'true' }} diff --git a/apiserver/plane/db/models/asset.py b/apiserver/plane/db/models/asset.py index 7b36469f42..8a6d900be8 100644 --- a/apiserver/plane/db/models/asset.py +++ b/apiserver/plane/db/models/asset.py @@ -54,6 +54,7 @@ class FileAsset(BaseModel): WORKITEM_TEMPLATE_DESCRIPTION = "WORKITEM_TEMPLATE_DESCRIPTION" PAGE_TEMPLATE_DESCRIPTION = "PAGE_TEMPLATE_DESCRIPTION" TEMPLATE_ATTACHMENT = "TEMPLATE_ATTACHMENT" + LICENSE_FILE = "LICENSE_FILE" attributes = models.JSONField(default=dict) asset = models.FileField(upload_to=get_upload_path, max_length=800) diff --git a/apiserver/plane/license/api/views/changelog.py b/apiserver/plane/license/api/views/changelog.py index 1c4c94c78d..babbc4ddbf 100644 --- a/apiserver/plane/license/api/views/changelog.py +++ b/apiserver/plane/license/api/views/changelog.py @@ -4,6 +4,7 @@ import os # Django imports from django.utils import timezone +from django.conf import settings # Third party imports from rest_framework.response import Response @@ -26,6 +27,9 @@ class CheckUpdateEndpoint(BaseAPIView): license_key = os.environ.get("LICENSE_KEY", False) machine_signature = os.environ.get("MACHINE_SIGNATURE", False) + if settings.IS_AIRGAPPED: + return {} + response = requests.get( f"{prime_host}/api/v2/instances/me/", headers={ diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index b8d79fc215..70a27781c4 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -214,6 +214,9 @@ class InstanceEndpoint(BaseAPIView): data["is_elasticsearch_enabled"] = ELASTICSEARCH_ENABLED == "1" + # Airgapped mode + data["is_airgapped"] = settings.IS_AIRGAPPED + instance_data = serializer.data instance_data["workspaces_exist"] = Workspace.objects.count() >= 1 diff --git a/apiserver/plane/license/bgtasks/version_check_task.py b/apiserver/plane/license/bgtasks/version_check_task.py index 42d63d9702..221953ef30 100644 --- a/apiserver/plane/license/bgtasks/version_check_task.py +++ b/apiserver/plane/license/bgtasks/version_check_task.py @@ -3,6 +3,7 @@ import os import requests # Django imports +from django.conf import settings from django.utils import timezone # Third Party Imports @@ -85,7 +86,7 @@ def version_check(): # If license version is not provided then read from package.json - if prime_host and machine_signature: + if prime_host and machine_signature and not settings.IS_AIRGAPPED: # Get the instance data data = get_instance_me(machine_signature, license_key, prime_host) # Update the instance data diff --git a/apiserver/plane/license/management/commands/register_instance_ee.py b/apiserver/plane/license/management/commands/register_instance_ee.py index 5710003780..5fed8550e7 100644 --- a/apiserver/plane/license/management/commands/register_instance_ee.py +++ b/apiserver/plane/license/management/commands/register_instance_ee.py @@ -4,6 +4,7 @@ import os import requests # Django imports +from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.utils import timezone @@ -22,6 +23,9 @@ class Command(BaseCommand): def get_instance_from_prime(self, machine_signature, prime_host): try: + if settings.IS_AIRGAPPED: + return {} + response = requests.get( f"{prime_host}/api/v2/instances/me/", headers={ diff --git a/apiserver/plane/payment/urls.py b/apiserver/plane/payment/urls.py index 8bd41d4103..91ad4cddd1 100644 --- a/apiserver/plane/payment/urls.py +++ b/apiserver/plane/payment/urls.py @@ -19,6 +19,8 @@ from .views import ( LicenseDeActivateEndpoint, CancelTrialSubscriptionEndpoint, ProrationPreviewEndpoint, + LicenseActivateUploadEndpoint, + LicenseFileFetchEndpoint, ) urlpatterns = [ @@ -106,4 +108,14 @@ urlpatterns = [ ProrationPreviewEndpoint.as_view(), name="proration-preview", ), + path( + "workspaces//licenses/upload/", + LicenseActivateUploadEndpoint.as_view(), + name="license-upload", + ), + path( + "workspaces//license-file/", + LicenseFileFetchEndpoint.as_view(), + name="license-fetch", + ), ] diff --git a/apiserver/plane/payment/views/__init__.py b/apiserver/plane/payment/views/__init__.py index 123a6bfe45..fd0a86a9b5 100644 --- a/apiserver/plane/payment/views/__init__.py +++ b/apiserver/plane/payment/views/__init__.py @@ -11,7 +11,12 @@ from .payment import ( WorkspaceFreeTrialEndpoint, WorkspaceTrialUpgradeEndpoint, ) -from .license_activate import WorkspaceLicenseEndpoint, LicenseDeActivateEndpoint +from .license_activate import ( + WorkspaceLicenseEndpoint, + LicenseDeActivateEndpoint, + LicenseActivateUploadEndpoint, + LicenseFileFetchEndpoint, +) from .subscription import ( SubscriptionEndpoint, UpgradeSubscriptionEndpoint, diff --git a/apiserver/plane/payment/views/license_activate.py b/apiserver/plane/payment/views/license_activate.py index 6afeeccf96..1569752f7f 100644 --- a/apiserver/plane/payment/views/license_activate.py +++ b/apiserver/plane/payment/views/license_activate.py @@ -1,19 +1,30 @@ # Python imports import requests +import uuid +import logging +import json # Django imports from django.conf import settings from django.db.models import F +from django.core.files.base import ContentFile +from django.http import HttpResponseRedirect # Third party imports from rest_framework import status from rest_framework.response import Response +from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.permissions import AllowAny # Module imports from .base import BaseAPIView from plane.app.permissions.workspace import WorkspaceOwnerPermission -from plane.db.models import Workspace, WorkspaceMember +from plane.db.models import Workspace, WorkspaceMember, FileAsset from plane.payment.utils.workspace_license_request import resync_workspace_license +from plane.settings.storage import S3Storage + +# Logger +logger = logging.getLogger("plane.api") class WorkspaceLicenseEndpoint(BaseAPIView): @@ -118,6 +129,17 @@ class WorkspaceLicenseEndpoint(BaseAPIView): class LicenseDeActivateEndpoint(BaseAPIView): permission_classes = [WorkspaceOwnerPermission] + def delete_license_files(self, slug): + # Delete all the license files + license_assets = FileAsset.objects.filter( + workspace__slug=slug, + entity_type=FileAsset.EntityTypeContext.LICENSE_FILE, + ) + s3_storage = S3Storage(request=self.request, is_server=True) + s3_storage.delete_files([asset.asset.name for asset in license_assets]) + license_assets.delete() + return True + def post(self, request, slug): # Check the multi-tenant environment if settings.IS_MULTI_TENANT: @@ -145,13 +167,20 @@ class LicenseDeActivateEndpoint(BaseAPIView): # Force resync the workspace licenses resync_workspace_license(workspace_slug=slug, force=True) + # Delete the license file if in airgapped mode + if settings.IS_AIRGAPPED: + # Delete all the license files + self.delete_license_files(slug) + # Return the response return Response(response.json(), status=status.HTTP_200_OK) except requests.exceptions.RequestException as e: - if e.response.status_code == 400: - return Response( - e.response.json(), status=status.HTTP_400_BAD_REQUEST - ) + if ( + hasattr(e, "response") + and e.response.status_code == 400 + or e.response.status_code == 500 + ): + return Response(e.response.json(), status=e.response.status_code) return Response( {"error": "Invalid license key"}, status=status.HTTP_400_BAD_REQUEST ) @@ -160,3 +189,207 @@ class LicenseDeActivateEndpoint(BaseAPIView): {"error": "Payment server is not configured"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class LicenseActivateUploadEndpoint(BaseAPIView): + """ + License Activate Upload Endpoint | Airgapped Mode + + This endpoint is used to upload a license file to the payment server. + The file is uploaded to S3 and then forwarded to the payment server. + The payment server will then activate the license and return the response. + The response is then returned to the client. + """ + + permission_classes = [WorkspaceOwnerPermission] + parser_classes = (MultiPartParser, FormParser) + + def post(self, request, slug): + # Check the multi-tenant environment + if settings.IS_MULTI_TENANT: + return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) + + if not settings.IS_AIRGAPPED: + return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) + + # Get the file from request + file = request.FILES.get("license_file") + if not file: + return Response( + {"error": "No file provided"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Read file content first before any operations + file_content = file.read() + + # Get workspace + workspace = Workspace.objects.get(slug=slug) + + # Generate a unique file name + file_name = f"{workspace.id}/license-{uuid.uuid4().hex}-{file.name}" + + # Create a FileAsset record + asset = FileAsset.objects.create( + attributes={ + "name": file.name, + "type": file.content_type, + "size": file.size, + }, + asset=file_name, + size=file.size, + workspace=workspace, + entity_type=FileAsset.EntityTypeContext.LICENSE_FILE, + ) + + s3_file = ContentFile(file_content, name=file.name) + # Upload the file to S3 + s3_storage = S3Storage(request=request, is_server=True) + is_uploaded = s3_storage.upload_file(s3_file, file_name, file.content_type) + + if not is_uploaded: + logger.error(f"Failed to upload file to storage: {file_name}") + return Response( + {"error": "Failed to upload file to storage"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Update the FileAsset record + asset.is_uploaded = True + asset.storage_metadata = { + "file_name": file_name, + "file_size": file.size, + "file_type": file.content_type, + } + asset.save() + + # Get all active workspace members + workspace_members = ( + WorkspaceMember.objects.filter( + workspace_id=workspace.id, is_active=True, member__is_bot=False + ) + .annotate( + user_email=F("member__email"), + user_id=F("member__id"), + user_role=F("role"), + ) + .values("user_email", "user_id", "user_role") + ) + + # Convert user_id to string + for member in workspace_members: + member["user_id"] = str(member["user_id"]) + + # Prepare form data + form_data = { + "workspace_slug": workspace.slug, + "workspace_id": str(workspace.id), + "members_list": json.dumps(list(workspace_members)), + "owner_email": workspace.owner.email, + } + + new_file = ContentFile(file_content, name=file.name) + + # Prepare files + files = {"activation_file": (file.name, new_file, file.content_type)} + + # Forward to payment server + try: + payment_response = requests.post( + f"{settings.PAYMENT_SERVER_BASE_URL}/api/licenses/activate/upload/", + data=form_data, + files=files, + headers={ + "x-api-key": settings.PAYMENT_SERVER_AUTH_TOKEN, + }, + ) + payment_response.raise_for_status() + + # Force resync the workspace licenses + resync_workspace_license(workspace_slug=slug, force=True) + + # Return the response + return Response(payment_response.json(), status=status.HTTP_200_OK) + except requests.exceptions.RequestException as e: + if ( + hasattr(e, "response") + and e.response.status_code == 400 + or e.response.status_code == 500 + ): + return Response(e.response.json(), status=e.response.status_code) + return Response( + {"error": "Failed to forward file to payment server"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class LicenseFileFetchEndpoint(BaseAPIView): + """ + License File Fetch Endpoint | Airgapped Mode + + This endpoint is used to fetch the license for a workspace from the storage + The file url is returned to the client. + """ + + permission_classes = [ + AllowAny, + ] + + def get(self, request, slug): + # Check the multi-tenant environment + if settings.IS_MULTI_TENANT: + return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) + + if not settings.IS_AIRGAPPED: + return Response({"error": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) + + logger.info(f"Fetching license file for workspace: {slug}") + + # Get the workspace + workspace = Workspace.objects.get(slug=slug) + + # Get the license file + license_file = FileAsset.objects.filter( + workspace=workspace, entity_type=FileAsset.EntityTypeContext.LICENSE_FILE + ).first() + + if not license_file: + logger.error(f"License file not found for workspace: {slug}") + return Response( + {"error": "License file not found"}, status=status.HTTP_404_NOT_FOUND + ) + + # Get the file from S3 + s3_storage = S3Storage(request=request, is_server=True) + file_url = s3_storage.generate_presigned_url( + object_name=license_file.asset.name, + filename="license_key", + disposition="attachment", + ) + + # Get all active workspace members + workspace_members = ( + WorkspaceMember.objects.filter( + workspace_id=workspace.id, is_active=True, member__is_bot=False + ) + .annotate( + user_email=F("member__email"), + user_id=F("member__id"), + user_role=F("role"), + ) + .values("user_email", "user_id", "user_role") + ) + + # Convert user_id to string + for member in workspace_members: + member["user_id"] = str(member["user_id"]) + + logger.info(f"License file fetched for workspace: {slug}") + + # Return the license file + return Response( + { + "url": file_url, + "members_list": workspace_members, + }, + status=status.HTTP_200_OK, + ) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 23056b1c82..f6c40413e7 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -585,3 +585,5 @@ WEB_URL = os.environ.get("WEB_URL", "http://localhost:3000") # Intake Email Domain INTAKE_EMAIL_DOMAIN = os.environ.get("INTAKE_EMAIL_DOMAIN", "example.com") + +IS_AIRGAPPED = os.environ.get("IS_AIRGAPPED", "0") == "1" diff --git a/apiserver/plane/settings/storage.py b/apiserver/plane/settings/storage.py index c4523f71bb..2520d941ab 100644 --- a/apiserver/plane/settings/storage.py +++ b/apiserver/plane/settings/storage.py @@ -185,3 +185,40 @@ class S3Storage(S3Boto3Storage): return None return response + + def upload_file( + self, + file_obj, + object_name: str, + content_type: str = None, + extra_args: dict = {}, + ) -> bool: + """Upload a file directly to S3""" + try: + if content_type: + extra_args["ContentType"] = content_type + + self.s3_client.upload_fileobj( + file_obj, + self.aws_storage_bucket_name, + object_name, + ExtraArgs=extra_args, + ) + return True + except ClientError as e: + log_exception(e) + return False + + def delete_files(self, object_names): + """Delete an S3 object""" + try: + self.s3_client.delete_objects( + Bucket=self.aws_storage_bucket_name, + Delete={ + "Objects": [{"Key": object_name} for object_name in object_names] + }, + ) + return True + except ClientError as e: + log_exception(e) + return False diff --git a/deploy/airgapped/README.md b/deploy/airgapped/README.md new file mode 100644 index 0000000000..0dd6a76a49 --- /dev/null +++ b/deploy/airgapped/README.md @@ -0,0 +1,99 @@ +# Airgapped Installation Instructions + +This build allows for Plane to be installed locally in an offline environment. + +## Prerequisites + +- Docker installed and running + - Version 24 or higher required + - Must be running and accessible +- Docker Compose + - Either `docker-compose` or `docker compose` command must be available +- Required files: + - A tarball of all the images (all-images.tar) + - A docker-compose.yml file + - A plane.env file + +## Required Files + +- `docker-compose.yml` - Docker Compose configuration for service orchestration +- `plane.env` - Default configuration file containing environment variables +- `admin-commercial-.tar` - Docker image for admin service +- `backend-commercial-.tar` - Docker image for api/worker/beat-worker/migrator service +- `email-commercial-.tar` - Docker image for email service +- `live-commercial-.tar` - Docker image for live service +- `monitor-commercial-.tar` - Docker image for monitor service +- `proxy-commercial-.tar` - Docker image for plane-proxy service +- `silo-commercial-.tar` - Docker image for silo service +- `space-commercial-.tar` - Docker image for space service +- `web-commercial-.tar` - Docker image for web service +- `minio-latest.tar` - Docker image for plane-minio service +- `postgres-15.7-alpine.tar` - Docker image for plane-db service +- `rabbitmq-3.13.6-management-alpine.tar` - Docker image for plane-mq service +- `valkey-7.2.5-alpine.tar` - Docker image for plane-redis service + +## Installation Process + +1. Run the installation script: + ```bash + bash ./install.sh + ``` + +2. The script will: + - Check for required prerequisites + - Ask for installation directory (default: ./plane) + - Ask for domain/IP address (default: 127.0.0.1) + - Create necessary directory structure + - Load Docker images + - Configure environment variables + +3. The installation will create the following directory structure: + ``` + / + ├── docker-compose.yml + ├── plane.env + ├── data/ + └── logs/ + ``` + +## Environment Variables + +The following key environment variables are automatically configured during installation: + +- `MACHINE_SIGNATURE` - A unique UUID generated for your installation +- `DOMAIN_NAME` - The domain or IP address where Plane will be accessible +- `WEB_URL` - The full URL where Plane will be accessible (e.g., http://your-domain) +- `CORS_ALLOWED_ORIGINS` - Allowed origins for CORS (Cross-Origin Resource Sharing) + +## Post-Installation Steps + +After installation completes, follow these steps to start Plane: + +1. Switch to the installation directory: + ```bash + cd + ``` + +2. Start the services: + ```bash + docker compose -f docker-compose.yml --env-file plane.env up -d + ``` + +3. Monitor the migration process: + ```bash + docker compose logs -f migrator + ``` + +4. Once migration completes, monitor the API service: + ```bash + docker compose logs -f api + ``` + +5. Access Plane at http://your-domain-or-ip + +## Support + +If you encounter any issues during installation or need assistance: + +- Read our Docs: [Docs](https://docs.plane.so/) +- Email our support team: support@plane.so diff --git a/deploy/airgapped/build.sh b/deploy/airgapped/build.sh new file mode 100755 index 0000000000..40c275f565 --- /dev/null +++ b/deploy/airgapped/build.sh @@ -0,0 +1,195 @@ +#!/bin/bash + +set -e + +BUILD_PLATFORM=${BUILD_PLATFORM:-linux/amd64} + +# loop though all flags and set the variables +for arg in "$@"; do + case $arg in + --platform) + BUILD_PLATFORM="$2" + shift + shift + ;; + --platform=*) + BUILD_PLATFORM="${arg#*=}" + shift + ;; + --release) + APP_RELEASE_VERSION="$2" + shift + shift + ;; + --release=*) + APP_RELEASE_VERSION="${arg#*=}" + shift + ;; + esac +done + + +if [ -z "$APP_RELEASE_VERSION" ]; then + echo "" + echo "Usage: " + echo " ./build.sh [flags]" + echo "" + echo "Flags:" + echo " --release= required (e.g. v1.10.0)" + echo " --platform= optional (default: linux/amd64)" + echo "" + echo "Example: ./build.sh --release=v1.9.2 --platform=linux/amd64" + exit 1 +fi + +echo "Building airgapped artifacts for $APP_RELEASE_VERSION on $BUILD_PLATFORM" + +# Install yq if not present +if ! command -v yq &> /dev/null; then + echo "Installing yq..." + sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 + sudo chmod +x /usr/local/bin/yq +fi + + +# Ensure we're in the correct directory +cd $(dirname $0) + +rm -rf dist +mkdir -p dist + +# Copy variables.env and docker-compose.yml out of cli-install +cp ../cli-install/portainer-compose.yml ./dist/docker-compose.yml +cp ../cli-install/variables.env ./dist/plane.env +cp ./install.sh ./dist/install.sh +cp ./README.md ./dist/README.md + +if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' 's@APP_RELEASE_VERSION=.*@APP_RELEASE_VERSION='${APP_RELEASE_VERSION}'@' ./dist/plane.env + sed -i '' 's@${APP_RELEASE_VERSION.*@'${APP_RELEASE_VERSION}'@' ./dist/docker-compose.yml +else + sed -i 's@APP_RELEASE_VERSION=.*@APP_RELEASE_VERSION='${APP_RELEASE_VERSION}'@' ./dist/plane.env + sed -i 's@${APP_RELEASE_VERSION.*@'${APP_RELEASE_VERSION}'@' ./dist/docker-compose.yml +fi + +cd dist + + +# LOAD ENV +source plane.env + +cp docker-compose.yml new-docker-compose.yml + +# insert "name: Plane-airgapped" in the first line of docker-compose.yml using yq +yq -i '. = {"name": "plane-airgapped"} + .' "new-docker-compose.yml" + +# add new env "IS_AIRGAPPED=1" to docker-compose.yml in x-monitor-env and x-proxy-env +yq -i '."x-monitor-env"."IS_AIRGAPPED" = "1"' "new-docker-compose.yml" +yq -i '."x-app-env"."IS_AIRGAPPED" = "1"' "new-docker-compose.yml" +yq -i '."x-plane"."APP_VERSION" = "'${APP_RELEASE_VERSION}'"' "new-docker-compose.yml" + +# get all services from docker-compose.yml +services=$(yq '.services | keys | .[]' "docker-compose.yml") +services=($services) +# loop through services and add platform=$BUILD_PLATFORM to the service +for service in "${services[@]}"; do + yq '.services.'$service'.platform = "'$BUILD_PLATFORM'"' -i "new-docker-compose.yml" + + # if $service == monitor, add command: ["prime-monitor", "start-airgapped"] + if [ "$service" == "monitor" ]; then + yq '.services.'$service'.command = ["prime-monitor", "start-airgapped"]' -i "new-docker-compose.yml" + fi + + # Check if service has volumes defined + has_volumes=$(yq '.services.'\"$service\"'.volumes != null' "docker-compose.yml") + + if [ "$has_volumes" == "true" ]; then + # Get the number of volumes for this service + volume_count=$(yq '.services.'\"$service\"'.volumes | length' "docker-compose.yml") + + # Reset volumes array in the new file to avoid mix-ups + yq -i '.services.'\"$service\"'.volumes = []' "new-docker-compose.yml" + + # Process each volume + for (( i=0; i<$volume_count; i++ )); do + # Get the volume definition at this index + volume_def=$(yq '.services.'\"$service\"'.volumes['$i']' "docker-compose.yml") + + # Check if it's a volume:path mapping or just a path + if [[ "$volume_def" == *":"* ]]; then + # It's a volume:path mapping, extract the parts + volume_name=${volume_def%%:*} + container_path=${volume_def#*:} + # Determine the host path based on volume name + if [[ "$volume_name" == *"_logs" ]]; then + service_name=${volume_name%_logs} + host_path="./logs/$service_name" + else + host_path="./data/$volume_name" + fi + # Add the new mapping to the service + new_mapping="$host_path:$container_path" + yq -i '.services.'\"$service\"'.volumes += ["'"$new_mapping"'"]' "new-docker-compose.yml" + else + # It's something else, keep it as is + yq -i '.services.'\"$service\"'.volumes += ["'"$volume_def"'"]' "new-docker-compose.yml" + fi + done + fi +done + +# Remove the global volumes section as it's no longer needed +yq -i 'del(.volumes)' "new-docker-compose.yml" + +if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' 's/!!merge //g' "new-docker-compose.yml" +else + sed -i 's/!!merge //g' "new-docker-compose.yml" +fi + +mv new-docker-compose.yml docker-compose.yml + +images=$(docker compose --env-file plane.env config | grep image: | awk '{print $2}' | uniq | xargs | tr ' ' '\n' | sort -u | tr '\n' ' ') + +# if crane does not exists, print a warning +if !command -v crane &> /dev/null; then + echo "****************************************************" + echo "Install \"crane\" to speed up image pulling and saving" + echo "****************************************************" +fi + + +# loop through images and save them to a tarball +for image in $images; do + # get the last part of the image name after the last "/" + img_name=$(echo $image | awk -F'/' '{print $NF}') + # remove trailing ":" from image + img_name=$(echo $img_name | sed 's/:/-/g') + tarfile="./${img_name}.tar" + + # if crane exists + if command -v crane &> /dev/null; then + echo "Pulling and Saving $image" + crane pull --platform "$BUILD_PLATFORM" $image $tarfile + if [ $? -ne 0 ]; then + echo "Failed to pull $image" + exit 1 + fi + echo "Image saved to $tarfile" + else + echo "Pulling $image" + docker pull -q --platform "$BUILD_PLATFORM" $image > /dev/null + if [ $? -ne 0 ]; then + echo "Failed to pull $image" + exit 1 + fi + echo "Saving $image" + docker save -o $tarfile $image + echo "Image saved to $tarfile" + echo "Removing $image" + docker rmi $image > /dev/null 2>&1 || true + fi +done + +echo "Images pulled successfully" + diff --git a/deploy/airgapped/install.sh b/deploy/airgapped/install.sh new file mode 100755 index 0000000000..bcce487fc9 --- /dev/null +++ b/deploy/airgapped/install.sh @@ -0,0 +1,246 @@ +#!/bin/bash + +# Setup a new machine that imports our tarball Docker images +set -e + +MIN_DOCKER_VERSION=24 +# set the default directory as $HOME/plane +DEFAULT_SETUP_DIR="$HOME/planeairgapped" +DEFAULT_APP_DOMAIN="127.0.0.1" +SETUP_DIR=$DEFAULT_SETUP_DIR + +sed_handler(){ + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "$1" "$2" + else + sed -i "$1" "$2" + fi +} + +update_env(){ + file=$1 + key=$2 + value=$3 + sed_handler 's|^'$key'=.*|'$key'='$value'|' $file +} + +# check docker version 24+ +docker_version=$(docker version --format '{{.Server.Version}}') +if [ $? -ne 0 ]; then + echo "Failed to check docker version" + exit 1 +fi +if [[ $docker_version < $MIN_DOCKER_VERSION ]]; then + echo "Docker version must be $MIN_DOCKER_VERSION or higher" + exit 1 +fi + +# Ensure Docker is installed and running +if ! docker info > /dev/null 2>&1; then + echo "Docker is not running" +fi + + +# check docker-compose or `docker compose` is installed +COMPOSE_CMD="docker-compose" +if docker-compose version > /dev/null 2>&1; then + COMPOSE_CMD="docker-compose" +elif docker compose version > /dev/null 2>&1; then + COMPOSE_CMD="docker compose" +else + echo "docker-compose or docker compose is not installed" + exit 1 +fi + +# check docker-compose version +docker_compose_version=$($COMPOSE_CMD version) +if [ $? -ne 0 ]; then + echo "Failed to check docker-compose version" + exit 1 +fi + +echo "**********************************************************" +echo "You are about to install/upgrade Plane as airgapped setup" +echo "" +echo "Pre-requisites:" +echo " - Docker installed and running" +echo " - Docker version 24 or higher" +echo " - docker-compose or docker compose installed" +echo " - A tarball of all the images" +echo " - A docker-compose.yml file (docker-compose.yml)" +echo " - A plane.env file (plane.env)" +echo "**********************************************************" + +INSTALLATION_TYPE="New" + +echo "" +read -p "Enter the directory to install Plane (default: $DEFAULT_SETUP_DIR):" SETUP_DIR + +if [ -z "$SETUP_DIR" ]; then + SETUP_DIR=$DEFAULT_SETUP_DIR +fi + +# if SETUP_DIR exists and not empty +if [ -d "$SETUP_DIR" ] && [ -n "$(ls -A $SETUP_DIR)" ]; then + echo "" + echo "Current installation is found in $SETUP_DIR and it is not empty" + echo "" + read -p "Are you sure you want to proceed with UPGRADE the current installation? (y/n):" confirm + if [ "$confirm" != "y" ]; then + echo "Installation cancelled" + exit 1 + fi + + # check `docker compose ls` for `plane-airgapped` and if it is running, exit + if $COMPOSE_CMD ls | grep "plane-airgapped" | grep "running" > /dev/null; then + echo "Currently installed instance is running. Please stop the instance before upgrading" + exit 1 + fi + + INSTALLATION_TYPE="Upgrade" + # backup current plane.env + # check if plane.env exists + if [ -f "$SETUP_DIR/plane.env" ]; then + cp $SETUP_DIR/plane.env $SETUP_DIR/plane.env.backup || true + # read DOMAIN_NAME from plane.env + APP_DOMAIN=$(grep "^DOMAIN_NAME=" $SETUP_DIR/plane.env | cut -d '=' -f 2) + else + echo "plane.env not found in $SETUP_DIR. It will be created during the upgrade" + INSTALLATION_TYPE="New" + fi +fi + +if [[ "$INSTALLATION_TYPE" == "New" ]]; then + # ask for domain or ip address for new + echo "" + read -p "Enter the domain or ip address to access Plane (default: $DEFAULT_APP_DOMAIN):" APP_DOMAIN + if [ -z "$APP_DOMAIN" ]; then + APP_DOMAIN=$DEFAULT_APP_DOMAIN + fi +fi + +# Display the final configuration +echo "" +echo "**********************************************************" +echo "Verify the final configuration:" +echo " - Setup Directory: $SETUP_DIR" +echo " - App Domain: $APP_DOMAIN" +echo " - Installation Type: $INSTALLATION_TYPE" +echo "**********************************************************" +# ask for confirmation before proceeding +echo "" +read -p "Confirm to proceed with setup? (y/n):" confirm +if [ "$confirm" != "y" ]; then + echo "Installation cancelled" + exit 1 +fi + +mkdir -p $SETUP_DIR +# if error creating directory, exit +if [ $? -ne 0 ]; then + echo "Failed to create $SETUP_DIR" + exit 1 +fi + +# move docker-compose.yml, plane.env +cp $(dirname $0)/docker-compose.yml $SETUP_DIR/docker-compose.yml +if [ $? -ne 0 ]; then + echo "Failed to move docker-compose.yml" + exit 1 +fi + +cp $(dirname $0)/plane.env $SETUP_DIR/plane.env +if [ $? -ne 0 ]; then + echo "Failed to move plane.env" + exit 1 +fi + +# create data, logs directories +mkdir -p $SETUP_DIR/data +mkdir -p $SETUP_DIR/logs + + +# update plane.env for MACHINE_SIGNATURE with uuid +MACHINE_SIGNATURE=$(uuidgen) + +# consider it as fresh install if the plane.env.backup does not exist + +if [[ "$INSTALLATION_TYPE" == "Upgrade" ]] && [[ -f "$SETUP_DIR/plane.env.backup" ]]; then + # read the backup and update all the plane.env.bak key values from backup to plane.env + # loop through all the keys in plane.env.bak and update the plane.env + while IFS='=' read -r line; do + # trim spaces + line=$(echo "$line" | xargs) + + # if line starts with # or empty, skip + if [[ "$line" == "#"* ]] || [[ -z "$line" ]]; then + continue + fi + + # if line contains =, then update the env + if [[ "$line" == *"="* ]]; then + key=$(echo "$line" | cut -d '=' -f 1) + value=$(echo "$line" | cut -d '=' -f 2) + else + key="$line" + value="" + fi + + # echo "$key=$value" + update_env "$SETUP_DIR/plane.env" "$key" "$value" + done < "$SETUP_DIR/plane.env.backup" +elif [[ ! -f "$SETUP_DIR/plane.env.backup" ]]; then + sed_handler 's/^MACHINE_SIGNATURE=.*/MACHINE_SIGNATURE='$MACHINE_SIGNATURE'/' $SETUP_DIR/plane.env + sed_handler 's/^DOMAIN_NAME=.*/DOMAIN_NAME='$APP_DOMAIN'/' $SETUP_DIR/plane.env + sed_handler 's/^SITE_ADDRESS=.*/SITE_ADDRESS=:80/' $SETUP_DIR/plane.env + sed_handler 's/^WEB_URL=.*/WEB_URL=http:\/\/'$APP_DOMAIN'/' $SETUP_DIR/plane.env + sed_handler 's/^CORS_ALLOWED_ORIGINS=.*/CORS_ALLOWED_ORIGINS=http:\/\/'$APP_DOMAIN'/' $SETUP_DIR/plane.env + sed_handler 's|^INSTALL_DIR=.*|INSTALL_DIR='$SETUP_DIR'|' $SETUP_DIR/plane.env +else + echo "Invalid installation type" + exit 1 +fi + +# Load the images into Docker +echo "Loading images into Docker" + +# find all the images in the tarball +tarfiles=$(find . -name "*.tar") +# loop through all the images in the tarball and load them into Docker +for tarfile in $tarfiles; do + echo "Loading $tarfile" + docker load -i $tarfile + if [ $? -ne 0 ]; then + echo "Failed to load $tarfile" + exit 1 + fi +done + +echo "Images loaded successfully" + +echo "" +echo "**********************************************************" +echo "Plane Setup is ready to configure and start" +echo "" +echo "Use below commands to configure and start Plane" +echo "" +echo "Switch to the setup directory" +echo -e " \033[94mcd $SETUP_DIR\033[0m" +echo "" +echo "Start the services" +echo -e " \033[94m$COMPOSE_CMD -f docker-compose.yml --env-file plane.env up -d\033[0m" +echo "" +echo "Check logs of migrator service and wait for it to finish using below command" +echo -e " \033[94m$COMPOSE_CMD logs -f migrator\033[0m" +echo "" +echo "Check logs of api service and wait for it to start using below command" +echo -e " \033[94m$COMPOSE_CMD logs -f api\033[0m" +echo "" +echo "Once the api service is started, you can access Plane at http://$APP_DOMAIN" +echo "" +echo "**********************************************************" + +echo "Installation completed successfully" +echo "" +echo -e "You can access Plane at \033[94mhttp://$APP_DOMAIN\033[0m" +echo "" diff --git a/deploy/cli-install/coolify-compose.yml b/deploy/cli-install/coolify-compose.yml index 8c8cd6c715..0879722acc 100644 --- a/deploy/cli-install/coolify-compose.yml +++ b/deploy/cli-install/coolify-compose.yml @@ -4,7 +4,7 @@ # logo: svgs/plane.svg x-plane-commercial: &plane-commercial - APP_VERSION: ${APP_RELEASE_VERSION:-v1.9.0} + APP_VERSION: ${APP_RELEASE_VERSION:-v1.10.0} APP_DOMAIN: ${SERVICE_URL_PLANE} MACHINE_SIGNATURE: ${SERVICE_PASSWORD_64_MACHINESIGNATURE} DEPLOY_PLATFORM: coolify @@ -19,6 +19,7 @@ x-monitor-env: &monitor-env SERVICE_TCP_REDIS: plane-redis:6379 SERVICE_TCP_POSTGRES: plane-db:5432 TRUSTED_PROXIES: ${TRUSTED_PROXIES:-0.0.0.0/0} + API_HOSTNAME: ${API_HOSTNAME:-http://api:8000} x-proxy-env: &proxy-env SERVICE_FQDN_PLANE_80: / diff --git a/deploy/cli-install/docker-compose-caddy.yml b/deploy/cli-install/docker-compose-caddy.yml index a2e55d86df..dc1b53c8b0 100644 --- a/deploy/cli-install/docker-compose-caddy.yml +++ b/deploy/cli-install/docker-compose-caddy.yml @@ -1,5 +1,5 @@ x-plane: &plane - APP_VERSION: ${APP_RELEASE_VERSION:-v1.9.0} + APP_VERSION: ${APP_RELEASE_VERSION:-v1.10.0} APP_DOMAIN: ${DOMAIN_NAME:-localhost} MACHINE_SIGNATURE: ${MACHINE_SIGNATURE} DEPLOY_PLATFORM: docker_compose @@ -14,6 +14,7 @@ x-monitor-env: &monitor-env SERVICE_TCP_REDIS: plane-redis:6379 SERVICE_TCP_POSTGRES: plane-db:5432 TRUSTED_PROXIES: ${TRUSTED_PROXIES:-0.0.0.0/0} + API_HOSTNAME: ${API_HOSTNAME:-http://api:8000} x-email-env: &email-env SMTP_DOMAIN: ${SMTP_DOMAIN:-0.0.0.0} diff --git a/deploy/cli-install/portainer-compose.yml b/deploy/cli-install/portainer-compose.yml index 2d2bf08e6b..8dd54d7177 100644 --- a/deploy/cli-install/portainer-compose.yml +++ b/deploy/cli-install/portainer-compose.yml @@ -1,5 +1,5 @@ x-plane: &plane - APP_VERSION: ${APP_RELEASE_VERSION:-v1.9.0} + APP_VERSION: ${APP_RELEASE_VERSION:-v1.10.0} APP_DOMAIN: ${DOMAIN_NAME:-localhost} MACHINE_SIGNATURE: ${MACHINE_SIGNATURE} DEPLOY_PLATFORM: docker_compose @@ -14,6 +14,7 @@ x-monitor-env: &monitor-env SERVICE_TCP_REDIS: plane-redis:6379 SERVICE_TCP_POSTGRES: plane-db:5432 TRUSTED_PROXIES: ${TRUSTED_PROXIES:-0.0.0.0/0} + API_HOSTNAME: ${API_HOSTNAME:-http://api:8000} x-proxy-env: &proxy-env SITE_ADDRESS: ${SITE_ADDRESS:-localhost:80} diff --git a/deploy/cli-install/variables.env b/deploy/cli-install/variables.env index ffff7449b2..3eaac6911a 100644 --- a/deploy/cli-install/variables.env +++ b/deploy/cli-install/variables.env @@ -1,6 +1,6 @@ INSTALL_DIR=/opt/plane DOMAIN_NAME=localhost -APP_RELEASE_VERSION=v1.8.3 +APP_RELEASE_VERSION=v1.10.0 WEB_REPLICAS=1 SPACE_REPLICAS=1 @@ -16,6 +16,7 @@ LISTEN_HTTP_PORT=80 LISTEN_HTTPS_PORT=443 APP_PROTOCOL=http TRUSTED_PROXIES=0.0.0.0/0 +API_HOSTNAME=http://api:8000 # If SSL Cert to be generated, set CERT_EMAIL and APP_PROTOCOL to https CERT_EMAIL=admin@example.com diff --git a/monitor/cli/cmd/root.go b/monitor/cli/cmd/root.go index a078c688e2..7c0993cea0 100644 --- a/monitor/cli/cmd/root.go +++ b/monitor/cli/cmd/root.go @@ -6,6 +6,7 @@ package cmd import ( "fmt" "os" + prime_api "github.com/makeplane/plane-ee/monitor/lib/api" "github.com/makeplane/plane-ee/monitor/lib/logger" "github.com/makeplane/plane-ee/monitor/pkg/constants" @@ -18,6 +19,7 @@ var CmdLogger = logger.NewHandler(nil) var MACHINE_SIGNATURE = "" var APP_DOMAIN = "" var APP_VERSION = "" +var API_HOSTNAME = "" var INSTANCE_ID = "" var PORT = "8080" var HOST = "https://prime.plane.so" @@ -28,6 +30,27 @@ var rootCmd = &cobra.Command{ Use: descriptors.PRIME_MONITOR_USAGE, Short: descriptors.PRIME_MONITOR_USAGE_DESC, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + // Let's check for the port + if port := os.Getenv(constants.PORT); port != "" { + PORT = port + } + + if appVersion := os.Getenv(constants.APP_VERSION); appVersion == "" { + return fmt.Errorf(error_msgs.APP_VERSION_ABSENT) + } else { + APP_VERSION = appVersion + } + + // Let's not check for anything in the case the system is in airgapped mode + if cmd.Name() == "start-airgapped" { + if apiHostname := os.Getenv(constants.API_HOSTNAME); apiHostname == "" { + return fmt.Errorf(error_msgs.API_HOSTNAME_ABSENT) + } else { + API_HOSTNAME = apiHostname + } + return nil + } + if host := os.Getenv(constants.PRIME_HOST); host != "" { HOST = host } @@ -38,12 +61,6 @@ var rootCmd = &cobra.Command{ APP_DOMAIN = appDomain } - if appVersion := os.Getenv(constants.APP_VERSION); appVersion == "" { - return fmt.Errorf(error_msgs.APP_VERSION_ABSENT) - } else { - APP_VERSION = appVersion - } - if port := os.Getenv(constants.PORT); port != "" { PORT = port } @@ -96,4 +113,4 @@ func Execute(privateKey string) { func init() { rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle") -} \ No newline at end of file +} diff --git a/monitor/cli/cmd/start_airgapped.go b/monitor/cli/cmd/start_airgapped.go new file mode 100644 index 0000000000..58c4fba15c --- /dev/null +++ b/monitor/cli/cmd/start_airgapped.go @@ -0,0 +1,94 @@ +/* +Copyright © 2024 plane.so engineering@plane.so +*/ +package cmd + +import ( + "context" + "log" + "os" + "os/signal" + "syscall" + "time" + + prime_api "github.com/makeplane/plane-ee/monitor/lib/api" + "github.com/makeplane/plane-ee/monitor/lib/feat_flag" + "github.com/makeplane/plane-ee/monitor/pkg/constants/descriptors" + "github.com/makeplane/plane-ee/monitor/pkg/db" + "github.com/makeplane/plane-ee/monitor/pkg/handlers" + "github.com/makeplane/plane-ee/monitor/pkg/types" + "github.com/makeplane/plane-ee/monitor/pkg/worker" + "github.com/spf13/cobra" +) + +var StartAirgappedCmd = &cobra.Command{ + Use: "start-airgapped", // TODO: Move this to constants + Short: descriptors.CMD_START_USAGE_DESC, + RunE: func(cmd *cobra.Command, args []string) error { + db.Initialize() + + _, err := feat_flag.ParsePrivateKey(PRIVATE_KEY) + if err != nil { + return err + } + + // Establish signals for catching signal interrupts + sigs := make(chan os.Signal, 1) + signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM) + + // Pass the airgapped api to the http handler + api := prime_api.NewAirgappedApi(PRIVATE_KEY, API_HOSTNAME, APP_VERSION) + + // Initializing the http handler for addressing requests + httpHandler := handlers.NewHttpHandler(handlers.HTTPHandlerOptions{ + Host: "0.0.0.0", + Port: PORT, + Logger: *CmdLogger, + PrivateKey: PRIVATE_KEY, + Api: &api, + }) + + // Initializing cronhandler for cron tasks + cronHandler := handlers.NewCronHandler(types.Credentials{ + InstanceId: INSTANCE_ID, + Host: HOST, + MachineSignature: MACHINE_SIGNATURE, + AppDomain: APP_DOMAIN, + AppVersion: APP_VERSION, + }, CmdLogger) + + cronHandler.ScheduleAirgappedCronJobs(handlers.SchedulerOptions{ + ResyncFlagsInterval: int64(time.Hour), + }) + + // Creating a new instance of the worker to register cron + worker := worker.NewPrimeWorker(CmdLogger) + worker.RegisterJob("Prime Scheduler", cronHandler.Start) + + // Registering the Jobs to the cron handler + worker.RegisterJob("Resync Instance Licenses", func(ctx context.Context) { + err := handlers.UpdateFlagsHandler(ctx, api) + if err != nil { + CmdLogger.Error(ctx, err.Error()) + } + }) + + // Notify the channel on interrupt signals (Ctrl+C) + go func() { + signal := <-sigs + log.Printf("Received signal: %v", signal) + worker.Shutdown() + }() + + worker.RegisterJob("Prime Monitor Router", httpHandler.StartHttpServer) + // Starting the Jobs in background + worker.StartJobsInBackground() + worker.Wait() + + return nil + }, +} + +func init() { + rootCmd.AddCommand(StartAirgappedCmd) +} diff --git a/monitor/cli/pkg/constants/constants.go b/monitor/cli/pkg/constants/constants.go index 923dbc3417..28e18c1139 100644 --- a/monitor/cli/pkg/constants/constants.go +++ b/monitor/cli/pkg/constants/constants.go @@ -13,10 +13,11 @@ const ( PRIME_HOST = "PRIME_HOST" APP_DOMAIN = "APP_DOMAIN" APP_VERSION = "APP_VERSION" + API_HOSTNAME = "API_HOSTNAME" INSTANCE_ID = "INSTANCE_ID" PORT = "PORT" DEPLOY_PLATFORM = "DEPLOY_PLATFORM" DOCKER_COMPOSE = "DOCKER_COMPOSE" - KUBERNETES = "KUBERNETES" - COOLIFY = "COOLIFY" + KUBERNETES = "KUBERNETES" + COOLIFY = "COOLIFY" ) diff --git a/monitor/cli/pkg/constants/errors/errors.go b/monitor/cli/pkg/constants/errors/errors.go index c88b790d2f..94e50092d2 100644 --- a/monitor/cli/pkg/constants/errors/errors.go +++ b/monitor/cli/pkg/constants/errors/errors.go @@ -3,6 +3,7 @@ package error_msgs // ------------------ CMD Errors ---------------------- const ( APP_VERSION_ABSENT = "expecting a version to be available in OS Env under 'APP_VERSION', none found" + API_HOSTNAME_ABSENT = "expecting a hostname to be available in OS Env under 'API_HOSTNAME', none found" INSTANCE_ID_ABSENT = "expecting an instance id to be available in OS Env under 'INSTANCE_ID', none found" APP_DOMAIN_ABSENT = "expecting a domain to be available in OS Env under 'APP_DOMAIN', none found" MACHINE_SIG_ABSENT = "expecting a signature to be available in OS Env under 'MACHINE_SIGNATURE', none found" diff --git a/monitor/cli/pkg/db/models.go b/monitor/cli/pkg/db/models.go index 8d94ffa29d..3274033ca2 100644 --- a/monitor/cli/pkg/db/models.go +++ b/monitor/cli/pkg/db/models.go @@ -25,6 +25,7 @@ type License struct { HasAddedPaymentMethod bool `json:"has_added_payment_method" gorm:"not null;default:false"` HasActivatedFreeTrial bool `json:"has_activated_free_trial" gorm:"not null;default:false"` Subscription string `json:"subscription" gorm:"not null"` + IsAirgapped bool `json:"is_airgapped" gorm:"not null;default:false"` LastVerifiedAt *time.Time `json:"last_verified_at"` LastPaymentFailedDate *time.Time `json:"last_payment_failed_date"` LastPaymentFailedCount int `json:"last_payment_failed_count" gorm:"not null;default:0"` @@ -34,7 +35,10 @@ type License struct { } func (license *License) BeforeCreate(scope *gorm.DB) error { - license.ID = uuid.New() + // If the license id is not set, generate a new uuid + if license.ID == uuid.Nil { + license.ID = uuid.New() + } return nil } @@ -52,7 +56,10 @@ type UserLicense struct { } func (userLicense *UserLicense) BeforeCreate(scope *gorm.DB) error { - userLicense.ID = uuid.New() + // If the user license id is not set, generate a new uuid + if userLicense.ID == uuid.Nil { + userLicense.ID = uuid.New() + } return nil } @@ -71,6 +78,9 @@ type Flags struct { } func (flags *Flags) BeforeCreate(scope *gorm.DB) error { - flags.ID = uuid.New() + // If the flags id is not set, generate a new uuid + if flags.ID == uuid.Nil { + flags.ID = uuid.New() + } return nil } diff --git a/monitor/cli/pkg/handlers/cron_start_handler.go b/monitor/cli/pkg/handlers/cron_start_handler.go index 579ce32938..b47cee835c 100644 --- a/monitor/cli/pkg/handlers/cron_start_handler.go +++ b/monitor/cli/pkg/handlers/cron_start_handler.go @@ -62,6 +62,24 @@ func (h *CronHandler) ScheduleCronJobs(options SchedulerOptions) { ) } +// Schedules the cron jobs available for the instance of the prime scheduler +func (h *CronHandler) ScheduleAirgappedCronJobs(options SchedulerOptions) { + + // Schedule License Refresh Job for Instances + h.primeScheduler.RegisterLicenseRefreshJob( + context.Background(), + gocron.DurationJob(time.Duration(options.ResyncFlagsInterval)*time.Minute), + func(ctx context.Context) { + credentials := h.GetCredentials() + api := prime_api.NewMonitorApi(credentials.Host, credentials.MachineSignature, credentials.InstanceId, credentials.AppVersion) + err := UpdateFlagsHandler(context.Background(), api) + if err != nil { + h.GetLogger().Error(ctx, err.Error()) + } + }, + ) +} + // Get the logger associated with the cron handler func (h *CronHandler) GetLogger() *logger.Handler { return h.logger diff --git a/monitor/cli/pkg/handlers/fetch_latest_flags.go b/monitor/cli/pkg/handlers/fetch_latest_flags.go index 736a3effa4..98c8abe454 100644 --- a/monitor/cli/pkg/handlers/fetch_latest_flags.go +++ b/monitor/cli/pkg/handlers/fetch_latest_flags.go @@ -11,12 +11,6 @@ import ( "gorm.io/gorm" ) -/* -1. On boot up, go to prime and hit the license initialize v2 endpoint, which is activate free workspace endpoint. -2. Fetch the feature flags for all the licenses fetch the FF for the pain ones. -3. Resync license information to the API, once it's up and running. -*/ - // LICENSE_VERIFICATION_FAILED_THRESHOLD is the threshold for the license verification for moving to the free plan const LICENSE_VERIFICATION_FAILED_THRESHOLD = 7 * 24 * time.Hour @@ -39,6 +33,11 @@ func UpdateFlagsHandler(ctx context.Context, api prime_api.IPrimeMonitorApi) err return err } + // If the license is airgapped, pause the execution of the function and return nil + if api.IsAirgapped() { + return nil + } + // Job Two: Update the users with the member list provided if err := RefreshLicenseUsers(ctx, updatedLicense, *activationReponse, tx); err != nil { return err @@ -88,12 +87,21 @@ func RefreshLicense(ctx context.Context, api prime_api.IPrimeMonitorApi, license MembersList: workspaceMembers, }) - // If the license sync failed, we need to check the last verified date + verificationThreshhold := LICENSE_VERIFICATION_FAILED_THRESHOLD + + shouldDeactivate := false + // If the license is airgapped, we need to check the current period end date + if api.IsAirgapped() && license.CurrentPeriodEndDate != nil { + shouldDeactivate = time.Since(*license.CurrentPeriodEndDate) > 0 + } else { + shouldDeactivate = license.LastVerifiedAt != nil && time.Since(*license.LastVerifiedAt) > verificationThreshhold + } + if err != nil { // The license sync failed, we need to check the last verified date if license.LastVerifiedAt != nil && license.ProductType != "FREE" { // Check if the last verified date is greater than 14 days - if time.Since(*license.LastVerifiedAt) > LICENSE_VERIFICATION_FAILED_THRESHOLD { + if shouldDeactivate { // Deactivate the license newLicense := &db.License{ ID: license.ID, @@ -145,13 +153,25 @@ func RefreshLicense(ctx context.Context, api prime_api.IPrimeMonitorApi, license FreeSeats: 12, Interval: "MONTHLY", IsOfflinePayment: false, - IsCancelled: true, + IsCancelled: false, MemberList: []prime_api.WorkspaceMember{}, }, nil } } - - // Return the error + // if the license is airgapped, we need to return nil, nil, nil + if api.IsAirgapped() { + return license, &prime_api.WorkspaceActivationResponse{ + Product: license.Product, + ProductType: license.ProductType, + WorkspaceSlug: license.WorkspaceSlug, + Seats: license.Seats, + FreeSeats: license.FreeSeats, + Interval: license.Interval, + IsOfflinePayment: license.IsOfflinePayment, + IsCancelled: license.IsCancelled, + MemberList: []prime_api.WorkspaceMember{}, + }, nil + } return nil, nil, fmt.Errorf(F2_LICENSE_VERFICATION_FAILED, license.LicenseKey, license.WorkspaceSlug) } @@ -174,8 +194,9 @@ func RefreshLicense(ctx context.Context, api prime_api.IPrimeMonitorApi, license workspaceUUID, _ := uuid.Parse(data.WorkspaceID) instanceUUID, _ := uuid.Parse(data.InstanceID) - // Update the db for license with the new data now := time.Now() + + // Update the db for license with the new data licenseNew := &db.License{ ID: license.ID, LicenseKey: data.LicenceKey, @@ -250,7 +271,6 @@ func RefreshLicenseUsers(ctx context.Context, license *db.License, activationRep func RefreshFeatureFlags(ctx context.Context, api prime_api.IPrimeMonitorApi, license db.License, tx *gorm.DB) error { flags, err := api.GetFeatureFlags(license.LicenseKey) if err != nil { - fmt.Println("Failed to fetch flags for license", license.LicenseKey) return fmt.Errorf(F2_LICENSE_VERFICATION_FAILED, license.LicenseKey, license.WorkspaceSlug) } @@ -273,3 +293,74 @@ func RefreshFeatureFlags(ctx context.Context, api prime_api.IPrimeMonitorApi, li return nil } + +func ConvertWorkspaceActivationResponseToLicense(data *prime_api.WorkspaceActivationResponse) (*db.License, error) { + workspaceUUID, _ := uuid.Parse(data.WorkspaceID) + instanceUUID, _ := uuid.Parse(data.InstanceID) + + // Check for the current period end date + var currentPeriodEndDate *time.Time + if data.CurrentPeriodEndDate.IsZero() { + currentPeriodEndDate = nil + } else { + currentPeriodEndDate = &data.CurrentPeriodEndDate + } + + // Check for the trial end date + var trialEndDate *time.Time + if data.TrialEndDate.IsZero() { + trialEndDate = nil + } else { + trialEndDate = &data.TrialEndDate + } + + now := time.Now() + license := &db.License{ + LicenseKey: data.LicenceKey, + InstanceID: instanceUUID, + WorkspaceID: workspaceUUID, + Product: data.Product, + ProductType: data.ProductType, + WorkspaceSlug: data.WorkspaceSlug, + Seats: data.Seats, + FreeSeats: data.FreeSeats, + Interval: data.Interval, + IsOfflinePayment: data.IsOfflinePayment, + IsCancelled: data.IsCancelled, + Subscription: data.Subscription, + CurrentPeriodEndDate: currentPeriodEndDate, + TrialEndDate: trialEndDate, + HasAddedPaymentMethod: data.HasAddedPayment, + HasActivatedFreeTrial: data.HasActivatedFree, + LastVerifiedAt: &now, + LastPaymentFailedDate: data.LastPaymentFailedDate, + LastPaymentFailedCount: data.LastPaymentFailedCount, + } + + return license, nil +} + +func ConvertLicenseToWorkspaceActivationPayload(license *db.License, userLicenses []db.UserLicense) (*prime_api.WorkspaceActivationPayload, error) { + // Convert WorkspaceID to string + workspaceIDStr := license.WorkspaceID.String() + + // Create MembersList from UserLicenses + membersList := make([]prime_api.WorkspaceMember, len(userLicenses)) + for i, userLicense := range userLicenses { + membersList[i] = prime_api.WorkspaceMember{ + UserId: userLicense.UserID.String(), + UserRole: userLicense.Role, + IsActive: userLicense.IsActive, + } + } + + // Create and return the WorkspaceActivationPayload + payload := &prime_api.WorkspaceActivationPayload{ + WorkspaceSlug: license.WorkspaceSlug, + WorkspaceID: workspaceIDStr, + MembersList: membersList, + LicenceKey: license.LicenseKey, + } + + return payload, nil +} diff --git a/monitor/lib/api/airgapped_api.go b/monitor/lib/api/airgapped_api.go new file mode 100644 index 0000000000..483e372a74 --- /dev/null +++ b/monitor/lib/api/airgapped_api.go @@ -0,0 +1,342 @@ +package prime_api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/makeplane/plane-ee/monitor/lib/feat_flag" +) + +type AirgappedPrimeApi struct { + PRIVATE_KEY string + API_HOSTNAME string + APP_VERSION string +} + +const ( + WORKSPACE_ACTIVATION_FILE_PATH = "%s/api/payments/workspaces/%s/license-file/" +) + +type WorkspaceActivationFileResponse struct { + URL string `json:"url"` + MembersList []WorkspaceMember `json:"members_list"` +} + +type EncryptedFlagsWithVersion struct { + feat_flag.EncryptedData + Version string `json:"app_version"` +} + +type AirgappedLicensePayload struct { + WorkspaceActivationResponse + Flags EncryptedFlagsWithVersion `json:"flags"` +} + +/* + We will be writing separate implementation for the airgapped api. + As in airgapped mode, the plan of action will be different from the + normal mode. +*/ +func NewAirgappedApi(privateKey string, apiHostname string, appVersion string) IPrimeMonitorApi { + return &AirgappedPrimeApi{ + PRIVATE_KEY: privateKey, + API_HOSTNAME: apiHostname, + APP_VERSION: appVersion, + } +} + +func (a *AirgappedPrimeApi) IsAirgapped() bool { + return true +} + +func (a *AirgappedPrimeApi) AppVersion() string { + return a.APP_VERSION +} + +func (a *AirgappedPrimeApi) ApiHostname() string { + return a.API_HOSTNAME +} + +func (a *AirgappedPrimeApi) PostServiceStatus(payload StatusPayload) ErrorCode { + return ErrorCode(0) // Assuming 0 means success +} + +/* +Feature flags will be provided by means of file, hence the api will throw error +that the feature flags are not supported in airgapped mode. +*/ +func (a *AirgappedPrimeApi) GetFeatureFlags(licenseKey string) (*FlagDataResponse, *APIError) { + return nil, &APIError{ + Error: "Feature flags not supported, running in airgapped mode", + Success: false, + } +} + +/* + Instance activation and deactivation are not applicable in airgapped mode. + hence, these are just mock implementation functions. +*/ +func (a *AirgappedPrimeApi) ActivateInstance() *APIError { + return nil +} + +func (a *AirgappedPrimeApi) DeactivateInstance() *APIError { + return nil +} + +func (a *AirgappedPrimeApi) InitializeInstance(payload CredentialsPayload) (SetupResponse, *APIError) { + return SetupResponse{}, &APIError{ + Error: "Initialize Instance not supported, running in airgapped mode", + Success: false, + } +} + +/* + We don't support free workspace activation in airgapped mode, + the route handler should support operation +*/ +func (a *AirgappedPrimeApi) ActivateFreeWorkspace(payload WorkspaceActivationPayload) (*WorkspaceActivationResponse, *APIError) { + return nil, &APIError{ + Error: "Activation Method not supported, running in airgapped mode", + Success: false, + } +} + +// Disable this method and throw error as this is not applicable in airgapped mode +func (a *AirgappedPrimeApi) ActivateWorkspace(payload WorkspaceActivationPayload) (*WorkspaceActivationResponse, *APIError) { + return nil, &APIError{ + Error: "Activation Method not supported, running in airgapped mode", + Success: false, + } +} + +/* +Downloads the workspace activation file from the server and returns the response and error +*/ +func (a *AirgappedPrimeApi) SyncWorkspace(payload WorkspaceSyncPayload) (*WorkspaceActivationResponse, *APIError) { + // Step 1: Get the file URL and members list from the server + fileResponse, err := a.getWorkspaceActivationFile(payload.WorkspaceSlug) + if err != nil { + return nil, &APIError{ + Error: fmt.Sprintf("Failed to get activation file URL: %v", err), + Success: false, + } + } + + // Step 2: Download the file content from the URL + fileContent, err := a.downloadFileFromURL(fileResponse.URL) + if err != nil { + return nil, &APIError{ + Error: fmt.Sprintf("Failed to download activation file: %v", err), + Success: false, + } + } + + // Step 3: Process the file content (decrypt and parse) + licensePayload, err := a.processActivationFile(fileContent) + if err != nil { + return nil, &APIError{ + Error: fmt.Sprintf("Failed to process activation file: %v", err), + Success: false, + } + } + + // Step 4: Set workspace information and return response + licensePayload.WorkspaceID = payload.WorkspaceID + licensePayload.WorkspaceSlug = payload.WorkspaceSlug + + response := &licensePayload.WorkspaceActivationResponse + response.MemberList = fileResponse.MembersList + + return response, nil +} + +// Helper method to get workspace activation file URL and members list +func (a *AirgappedPrimeApi) getWorkspaceActivationFile(workspaceSlug string) (*WorkspaceActivationFileResponse, error) { + // Construct the API endpoint URL + url := fmt.Sprintf(WORKSPACE_ACTIVATION_FILE_PATH, a.API_HOSTNAME, workspaceSlug) + + // Make HTTP GET request + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned status %d", resp.StatusCode) + } + + // Parse the response + var fileResponse WorkspaceActivationFileResponse + if err := json.NewDecoder(resp.Body).Decode(&fileResponse); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &fileResponse, nil +} + +// Helper method to download file content from URL +func (a *AirgappedPrimeApi) downloadFileFromURL(url string) ([]byte, error) { + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to download file: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to download file, status: %d", resp.StatusCode) + } + + content, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read file content: %w", err) + } + + return content, nil +} + +// Helper method to process (decrypt and parse) the activation file +func (a *AirgappedPrimeApi) processActivationFile(fileContent []byte) (*AirgappedLicensePayload, error) { + // Parse the encrypted data from file content + var encryptedData feat_flag.EncryptedData + if err := json.Unmarshal(fileContent, &encryptedData); err != nil { + return nil, fmt.Errorf("failed to parse encrypted data: %w", err) + } + + // Decrypt the data + var looseDecryptedData interface{} + if err := feat_flag.GetDecryptedJson(a.PRIVATE_KEY, encryptedData, &looseDecryptedData); err != nil { + return nil, fmt.Errorf("failed to decrypt file: %w", err) + } + + // Convert to AirgappedLicensePayload + licensePayload, err := a.convertToAirgappedLicensePayload(looseDecryptedData) + if err != nil { + return nil, fmt.Errorf("failed to convert to license payload: %w", err) + } + + return licensePayload, nil +} + +// Helper method to convert interface{} to AirgappedLicensePayload (similar to the handler logic) +func (a *AirgappedPrimeApi) convertToAirgappedLicensePayload(data interface{}) (*AirgappedLicensePayload, error) { + // First, marshal the interface{} back to JSON bytes + jsonBytes, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal decrypted data: %w", err) + } + + // Fix timestamp format and "None" values + fixedJson := a.fixTimestampFormat(string(jsonBytes)) + + // Unmarshal to AirgappedLicensePayload + var payload AirgappedLicensePayload + if err := json.Unmarshal([]byte(fixedJson), &payload); err != nil { + return nil, fmt.Errorf("failed to unmarshal to AirgappedLicensePayload: %w", err) + } + + return &payload, nil +} + +// Helper method to fix timestamp format (copied from handler logic) +func (a *AirgappedPrimeApi) fixTimestampFormat(jsonStr string) string { + // Replace "None" with null + jsonStr = strings.ReplaceAll(jsonStr, `"None"`, "null") + + // Fix timestamp format: replace space between date and time with T + i := 0 + for i < len(jsonStr) { + // Find opening quote + start := strings.Index(jsonStr[i:], `"`) + if start == -1 { + break + } + start += i + + // Find closing quote + end := strings.Index(jsonStr[start+1:], `"`) + if end == -1 { + break + } + end = start + 1 + end + + // Extract the content between quotes + content := jsonStr[start+1 : end] + + // Check if it looks like a timestamp with space: YYYY-MM-DD HH:MM:SS + if len(content) >= 19 && + content[4] == '-' && content[7] == '-' && content[10] == ' ' && + content[13] == ':' && content[16] == ':' { + // Replace space with T + fixedContent := content[:10] + "T" + content[11:] + jsonStr = jsonStr[:start+1] + fixedContent + jsonStr[end:] + } + + i = end + 1 + } + + return jsonStr +} + +/* +We provide a non nil error here, as the route handler handles the deactivate as +if the deactivation is successfull, it will proceed to delete the license from the database +*/ +func (a *AirgappedPrimeApi) DeactivateLicense(payload LicenseDeactivatePayload) (*WorkspaceActivationResponse, *APIError) { + response := GetMockWorkspaceActivationResponse(payload, true) + return response, nil +} + +func (a *AirgappedPrimeApi) RetrievePlans(planId string) (*[]Product, *APIError) { + plans := &[]Product{} + return plans, nil +} + +func (a *AirgappedPrimeApi) RetrievePaymentLink(payload RetrievePaymentLinkPayload) (*RetrievePaymentLinkResponse, *APIError) { + response := &RetrievePaymentLinkResponse{} + return response, nil +} + +func (a *AirgappedPrimeApi) UpdateSubcription(payload SeatUpdatePayload) (*SeatUpdateResponse, *APIError) { + response := &SeatUpdateResponse{} + return response, nil +} + +func (a *AirgappedPrimeApi) GetSubscriptionDetails(payload WorkspaceSubscriptionPayload) (*WorkspaceSubscriptionResponse, *APIError) { + response := &WorkspaceSubscriptionResponse{} + return response, nil +} + +func (a *AirgappedPrimeApi) GetProrationPreview(payload ProrationPreviewPayload) (*ProrationPreviewResponse, *APIError) { + response := &ProrationPreviewResponse{} + return response, nil +} + +func GetMockWorkspaceActivationResponse(payload LicenseDeactivatePayload, isFree bool) *WorkspaceActivationResponse { + response := &WorkspaceActivationResponse{ + WorkspaceID: payload.WorkspaceID, + InstanceID: payload.WorkspaceID, + LicenceKey: generateRandomLicenseKey(), + Product: "FREE", + ProductType: "FREE", + WorkspaceSlug: payload.WorkspaceSlug, + Seats: 1, + FreeSeats: 12, + Interval: "month", + IsOfflinePayment: false, + IsCancelled: false, + Subscription: "FREE", + CurrentPeriodEndDate: time.Now(), + } + + if isFree { + response.FreeSeats = 12 + } + + return response +} diff --git a/monitor/lib/api/api.go b/monitor/lib/api/api.go index c283a0fa54..2e8808c253 100644 --- a/monitor/lib/api/api.go +++ b/monitor/lib/api/api.go @@ -10,6 +10,11 @@ import ( ) type IPrimeMonitorApi interface { + // Getters for the api + IsAirgapped() bool + AppVersion() string + ApiHostname() string + PostServiceStatus(StatusPayload) ErrorCode GetFeatureFlags(licenseKey string) (*FlagDataResponse, *APIError) ActivateInstance() *APIError @@ -104,6 +109,19 @@ func NewMonitorApi(host, machineSignature, instanceId, appVersion string) IPrime } } +// We don't need this for the normal api, but we need it for the airgapped api +func (api *PrimeMonitorApi) ApiHostname() string { + return "" +} + +func (api *PrimeMonitorApi) AppVersion() string { + return api.appVersion +} + +func (api *PrimeMonitorApi) IsAirgapped() bool { + return false +} + func (api *PrimeMonitorApi) SetClient(client string) { api.client = client } diff --git a/monitor/lib/api/helpers.go b/monitor/lib/api/helpers.go new file mode 100644 index 0000000000..34f050bf97 --- /dev/null +++ b/monitor/lib/api/helpers.go @@ -0,0 +1,25 @@ +package prime_api + +import "math/rand" + +func generateRandomLicenseKey() string { + const ( + charset = "0123456789abcdef" + length = 4 + ) + + // Create a byte slice to store the key + key := make([]byte, 14) // 12 chars + 2 hyphens + + // Generate random bytes for each group + for i := 0; i < 3; i++ { + for j := 0; j < length; j++ { + key[i*5+j] = charset[rand.Intn(len(charset))] + } + if i < 2 { + key[i*5+length] = '-' + } + } + + return string(key) +} diff --git a/monitor/lib/api/types.go b/monitor/lib/api/types.go index 7f483c1dab..dd6c956007 100644 --- a/monitor/lib/api/types.go +++ b/monitor/lib/api/types.go @@ -29,27 +29,28 @@ type WorkspaceMember struct { } type WorkspaceActivationResponse struct { - WorkspaceID string `json:"workspace_id"` - WorkspaceSlug string `json:"workspace_slug"` - LicenceKey string `json:"license"` - Product string `json:"product"` - ProductType string `json:"product_type"` - MemberList []WorkspaceMember `json:"license_users"` - OwnerEmail string `json:"owner_email"` - Seats int `json:"purchased_seats"` - UserCount int `json:"user_count"` - InstanceID string `json:"instance_id"` - Interval string `json:"interval"` - FreeSeats int `json:"free_seats"` - IsOfflinePayment bool `json:"is_offline_payment"` - IsCancelled bool `json:"is_cancelled"` - Subscription string `json:"subscription"` - CurrentPeriodEndDate time.Time `json:"current_period_end_date"` - TrialEndDate time.Time `json:"trial_end_date"` - HasAddedPayment bool `json:"has_added_payment_method"` - HasActivatedFree bool `json:"has_activated_free_trial"` - LastPaymentFailedDate *time.Time `json:"last_payment_failed_date"` - LastPaymentFailedCount int `json:"last_payment_failed_count"` + WorkspaceID string `json:"workspace_id"` + WorkspaceSlug string `json:"workspace_slug"` + LicenceKey string `json:"license"` + Product string `json:"product"` + ProductType string `json:"product_type"` + MemberList []WorkspaceMember `json:"license_users"` + OwnerEmail string `json:"owner_email"` + Seats int `json:"purchased_seats"` + UserCount int `json:"user_count"` + InstanceID string `json:"instance_id"` + Interval string `json:"interval"` + FreeSeats int `json:"free_seats"` + IsOfflinePayment bool `json:"is_offline_payment"` + IsCancelled bool `json:"is_cancelled"` + Subscription string `json:"subscription"` + CurrentPeriodEndDate time.Time `json:"current_period_end_date"` + TrialEndDate time.Time `json:"trial_end_date"` + HasAddedPayment bool `json:"has_added_payment_method"` + HasActivatedFree bool `json:"has_activated_free_trial"` + LastPaymentFailedDate *time.Time `json:"last_payment_failed_date"` + LastPaymentFailedCount int `json:"last_payment_failed_count"` + Flags *EncryptedFlagsWithVersion `json:"flags"` } type WorkspaceActivationPayload struct { @@ -78,6 +79,7 @@ type EncyptedFlagData struct { Nonce string `json:"nonce"` CipherText string `json:"ciphertext"` Tag string `json:"tag"` + Version string `json:"version"` } type FlagDataResponse struct { diff --git a/monitor/lib/router/airgapped_handlers.go/airgapped_activation.go b/monitor/lib/router/airgapped_handlers.go/airgapped_activation.go new file mode 100644 index 0000000000..b52c540e2e --- /dev/null +++ b/monitor/lib/router/airgapped_handlers.go/airgapped_activation.go @@ -0,0 +1,337 @@ +package airgapped_handlers + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + prime_api "github.com/makeplane/plane-ee/monitor/lib/api" + "github.com/makeplane/plane-ee/monitor/lib/feat_flag" + router_helpers "github.com/makeplane/plane-ee/monitor/lib/router/helpers" + "github.com/makeplane/plane-ee/monitor/pkg/db" + "gorm.io/gorm" +) + +const ( + AdminRole = 15 + OwnerRole = 20 +) + +type EncryptedFlagsWithVersion struct { + feat_flag.EncryptedData + Version string `json:"app_version"` +} + +type AirgappedLicensePayload struct { + prime_api.WorkspaceActivationResponse + Flags EncryptedFlagsWithVersion `json:"flags"` + Version string `json:"version"` +} + +func GetAirgappedActivationHandler(api prime_api.IPrimeMonitorApi, key string) func(*fiber.Ctx) error { + return func(ctx *fiber.Ctx) error { + // Get the file from the request + file, err := ctx.FormFile("activation_file") + workspaceId := ctx.FormValue("workspace_id", "") + workspaceSlug := ctx.FormValue("workspace_slug", "") + membersListValue := ctx.FormValue("members_list", "[]") + + if workspaceId == "" || workspaceSlug == "" || membersListValue == "[]" { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "No workspace id, slug or members list provided", + }) + } + + if err != nil { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "No file uploaded. Please upload the activation file.", + }) + } + + // If the extension of the file is not `.json`, return error for unsupported file type + if file.Header.Get("Content-Type") != "application/json" { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Unsupported file type. Please upload a .json file.", + }) + } + + // Open the file and get the file content + fileContent, err := file.Open() + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to read the file", + }) + } + + // Read the file content + content, err := io.ReadAll(fileContent) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to read the file", + }) + } + + // Parse the json text file for the encrypted data + var encryptedData feat_flag.EncryptedData + + /* + Why use `interface{}` instead of `AirgappedLicensePayload` directly? + The issue is that the `AirgappedLicensePayload` struct has to use custom unmarshaler that + handles the timestamp format conversion and "None" values, as the timestamp format is not + standard and the "None" values are not handled by the default unmarshaler. + So we use `interface{}` to unmarshal the data and then convert it to `AirgappedLicensePayload` + using the custom unmarshaler. + */ + var looseDecryptedData interface{} + err = json.Unmarshal(content, &encryptedData) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to parse the file", + }) + } + + err = feat_flag.GetDecryptedJson(key, encryptedData, &looseDecryptedData) + if err != nil { + fmt.Println("Failed to decrypt the file", err) + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to decrypt the file", + }) + } + + decryptedData, err := ConvertToAirgappedLicensePayload(looseDecryptedData) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to parse the file", + }) + } + + decryptedData.WorkspaceID = workspaceId + decryptedData.WorkspaceSlug = workspaceSlug + + // In the Go code, when receiving: + var memberList []prime_api.WorkspaceMember + err = json.Unmarshal([]byte(membersListValue), &memberList) + if err != nil { + return ctx.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to parse the members list", + }) + } + + // Warm up the database with the payload + if err := PopulateDatabaseWithFilePayload(decryptedData, memberList); err != nil { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + return ctx.Status(fiber.StatusOK).JSON(fiber.Map{ + "message": "Workspace activated successfully", + }) + } +} + +/* +We need to warm up the database with the payload of the file +which includes the license, feature flags and workspace details +*/ +func PopulateDatabaseWithFilePayload(payload *AirgappedLicensePayload, memberList []prime_api.WorkspaceMember) error { + activationPayload := payload.WorkspaceActivationResponse + featureFlags := payload.Flags + + // Create the license payload from the activation payload + license, err := router_helpers.ConvertWorkspaceActivationResponseToLicense(&activationPayload) + if err != nil { + return fmt.Errorf("failed to convert workspace activation response to license: %w", err) + } + + /* + Exit Cases + - If the current license already exists for any workspace which is not a free license, return an error + - If the current workspace is already associated with a license, which is not the current license, return error + - If the number of seats in the license is less than the number of members, return error + + Entry Case + - If we are fine with the above cases, we need to, + - Delete the existing license, feature flags and user licenses + - Create the new license and associated feature flags and user licenses + */ + return db.Db.Transaction(func(tx *gorm.DB) error { + // === EXIT CASES VALIDATION === + + // Exit Case 1: Check if license already exists for a different workspace (and is not free) + var existingLicense db.License + err := tx.Where("license_key = ?", license.LicenseKey).First(&existingLicense).Error + if err == nil { + // License exists - check if it's for a different workspace + if existingLicense.WorkspaceID != license.WorkspaceID { + return fmt.Errorf("license already exists for another workspace") + } + } + + // Exit Case 2: Check if workspace is already associated with a different license + var workspaceLicense db.License + err = tx.Where("workspace_id = ? AND workspace_slug = ?", license.WorkspaceID, license.WorkspaceSlug).First(&workspaceLicense).Error + if err == nil { + // Workspace has a license - check if it's different from the current one + if workspaceLicense.LicenseKey != license.LicenseKey { + return fmt.Errorf("workspace is already associated with a different license") + } + } + + // Exit Case 3: Check if license has enough seats for members + billedMembers := 0 + for _, member := range memberList { + if member.UserRole == AdminRole || member.UserRole == OwnerRole { + billedMembers++ + } + } + + if license.Seats < billedMembers { + return fmt.Errorf("license has less seats (%d) than the number of billed members (%d)", license.Seats, billedMembers) + } + + // === ENTRY CASE: CLEANUP AND CREATION === + + // Step 1: Delete existing license, feature flags and user licenses for this license key + if err := tx.Where("license_id IN (SELECT id FROM licenses WHERE license_key = ?)", license.LicenseKey).Delete(&db.Flags{}).Error; err != nil { + return fmt.Errorf("failed to delete existing feature flags: %w", err) + } + + if err := tx.Where("license_id IN (SELECT id FROM licenses WHERE license_key = ?)", license.LicenseKey).Delete(&db.UserLicense{}).Error; err != nil { + return fmt.Errorf("failed to delete existing user licenses: %w", err) + } + + if err := tx.Where("license_key = ?", license.LicenseKey).Delete(&db.License{}).Error; err != nil { + return fmt.Errorf("failed to delete existing license: %w", err) + } + + // Step 2: Delete any existing license for this workspace (if different) + if err := tx.Where("workspace_id = ? AND workspace_slug = ? AND license_key != ?", + license.WorkspaceID, license.WorkspaceSlug, license.LicenseKey).Delete(&db.License{}).Error; err != nil { + return fmt.Errorf("failed to delete workspace's existing license: %w", err) + } + + // We are explicitly setting the license to be airgapped, as we are not making any calls to the prime server + license.IsAirgapped = true + + // Step 3: Create the new license + if err := tx.Create(license).Error; err != nil { + return fmt.Errorf("failed to create new license: %w", err) + } + + // Step 4: Create feature flags + featureFlagsPayload := db.Flags{ + LicenseID: license.ID, + Version: payload.Version, + AesKey: featureFlags.AesKey, + Nonce: featureFlags.Nonce, + CipherText: featureFlags.CipherText, + Tag: featureFlags.Tag, + } + + if err := tx.Create(&featureFlagsPayload).Error; err != nil { + return fmt.Errorf("failed to create feature flags: %w", err) + } + + // Step 5: Create user licenses + if len(memberList) > 0 { + userLicenses := make([]*db.UserLicense, 0, len(memberList)) + for _, member := range memberList { + userLicenses = append(userLicenses, &db.UserLicense{ + LicenseID: license.ID, + UserID: uuid.MustParse(member.UserId), + Role: member.UserRole, + IsActive: true, + Synced: true, + }) + } + + if err := tx.Create(&userLicenses).Error; err != nil { + return fmt.Errorf("failed to create user licenses: %w", err) + } + } + + return nil + }) +} + +func fixTimestampFormat(jsonStr string) string { + // Replace "None" with null + jsonStr = strings.ReplaceAll(jsonStr, `"None"`, "null") + + // Fix timestamp format: replace space between date and time with T + // This handles the pattern: "YYYY-MM-DD HH:MM:SS+/-TZ:TZ" -> "YYYY-MM-DDTHH:MM:SS+/-TZ:TZ" + // Use a simple approach: look for the pattern " HH:MM:SS" in quoted strings and replace space with T + i := 0 + for i < len(jsonStr) { + // Find opening quote + start := strings.Index(jsonStr[i:], `"`) + if start == -1 { + break + } + start += i + + // Find closing quote + end := strings.Index(jsonStr[start+1:], `"`) + if end == -1 { + break + } + end = start + 1 + end + + // Extract the content between quotes + content := jsonStr[start+1 : end] + + // Check if it looks like a timestamp with space: YYYY-MM-DD HH:MM:SS + if len(content) >= 19 && + content[4] == '-' && content[7] == '-' && content[10] == ' ' && + content[13] == ':' && content[16] == ':' { + // Replace space with T + fixedContent := content[:10] + "T" + content[11:] + jsonStr = jsonStr[:start+1] + fixedContent + jsonStr[end:] + } + + i = end + 1 + } + + return jsonStr +} + +// UnmarshalJSON implements custom JSON unmarshaling for AirgappedLicensePayload +// to handle timestamp format conversion and "None" values +func (a *AirgappedLicensePayload) UnmarshalJSON(data []byte) error { + // Fix the JSON format before unmarshaling + fixedJson := fixTimestampFormat(string(data)) + + // Create a temporary struct to unmarshal into + type TempPayload AirgappedLicensePayload + temp := &TempPayload{} + + // Unmarshal the fixed JSON + if err := json.Unmarshal([]byte(fixedJson), temp); err != nil { + return err + } + + // Copy the data back to the original struct + *a = AirgappedLicensePayload(*temp) + return nil +} + +// Helper function to convert interface{} to AirgappedLicensePayload +func ConvertToAirgappedLicensePayload(data interface{}) (*AirgappedLicensePayload, error) { + // First, marshal the interface{} back to JSON bytes + jsonBytes, err := json.Marshal(data) + if err != nil { + return nil, fmt.Errorf("failed to marshal decrypted data: %w", err) + } + + // Then unmarshal using our custom logic + var payload AirgappedLicensePayload + if err := payload.UnmarshalJSON(jsonBytes); err != nil { + return nil, fmt.Errorf("failed to unmarshal to AirgappedLicensePayload: %w", err) + } + + return &payload, nil +} diff --git a/monitor/lib/router/handlers/feature_flag_handler.go b/monitor/lib/router/handlers/feature_flag_handler.go index 6a4379b4fe..16199906b1 100644 --- a/monitor/lib/router/handlers/feature_flag_handler.go +++ b/monitor/lib/router/handlers/feature_flag_handler.go @@ -2,7 +2,6 @@ package handlers import ( "fmt" - "os" "github.com/gofiber/fiber/v2" prime_api "github.com/makeplane/plane-ee/monitor/lib/api" @@ -10,6 +9,9 @@ import ( "github.com/makeplane/plane-ee/monitor/pkg/db" ) +/* +This endpoint will give the feature flags from the database, it won't make any calls to the prime server. +*/ func GetFeatureFlagHandler(api prime_api.IPrimeMonitorApi, key string) func(*fiber.Ctx) error { return func(ctx *fiber.Ctx) error { var payload prime_api.GetFlagsPayload @@ -28,13 +30,13 @@ func GetFeatureFlagHandler(api prime_api.IPrimeMonitorApi, key string) func(*fib */ switch { case payload.WorkspaceSlug != "" && payload.UserID != "" && payload.FeatureKey != "": - return handleUserFeatureFlag(ctx, payload, key) + return handleUserFeatureFlag(ctx, api, payload, key) case payload.WorkspaceSlug != "" && payload.UserID != "": - return handleUserAllFeatureFlags(ctx, payload, key) + return handleUserAllFeatureFlags(ctx, api, payload, key) case payload.WorkspaceSlug != "" && payload.FeatureKey != "": - return handleWorkspaceFeatureFlag(ctx, payload, key) + return handleWorkspaceFeatureFlag(ctx, api, payload, key) case payload.WorkspaceSlug != "": - return handleWorkspaceAllFeatureFlags(ctx, payload, key) + return handleWorkspaceAllFeatureFlags(ctx, api, payload, key) default: ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "error": "Invalid request parameters", @@ -44,7 +46,7 @@ func GetFeatureFlagHandler(api prime_api.IPrimeMonitorApi, key string) func(*fib } } -func handleUserFeatureFlag(ctx *fiber.Ctx, payload prime_api.GetFlagsPayload, key string) error { +func handleUserFeatureFlag(ctx *fiber.Ctx, api prime_api.IPrimeMonitorApi, payload prime_api.GetFlagsPayload, key string) error { var license db.License record := db.Db.Model(&db.License{}).Where("workspace_slug = ?", payload.WorkspaceSlug).First(&license) if record.Error != nil { @@ -88,7 +90,7 @@ func handleUserFeatureFlag(ctx *fiber.Ctx, payload prime_api.GetFlagsPayload, ke } // Taking precondition that APP_VERSION will be verfied at the time of startup - APP_VERSION := os.Getenv("APP_VERSION") + APP_VERSION := api.AppVersion() var flags db.Flags record = db.Db.Model(&db.Flags{}).Where("license_id = ? AND version = ?", license.ID, APP_VERSION).First(&flags) @@ -135,7 +137,7 @@ func handleUserFeatureFlag(ctx *fiber.Ctx, payload prime_api.GetFlagsPayload, ke return nil } -func handleUserAllFeatureFlags(ctx *fiber.Ctx, payload prime_api.GetFlagsPayload, key string) error { +func handleUserAllFeatureFlags(ctx *fiber.Ctx, api prime_api.IPrimeMonitorApi, payload prime_api.GetFlagsPayload, key string) error { var license db.License record := db.Db.Model(&db.License{}).Where("workspace_slug = ?", payload.WorkspaceSlug).First(&license) if record.Error != nil { @@ -172,7 +174,7 @@ func handleUserAllFeatureFlags(ctx *fiber.Ctx, payload prime_api.GetFlagsPayload return nil } - APP_VERSION := os.Getenv("APP_VERSION") + APP_VERSION := api.AppVersion() var flags db.Flags record = db.Db.Model(&db.Flags{}).Where("license_id = ? AND version = ?", license.ID, APP_VERSION).First(&flags) @@ -205,7 +207,7 @@ func handleUserAllFeatureFlags(ctx *fiber.Ctx, payload prime_api.GetFlagsPayload return nil } -func handleWorkspaceFeatureFlag(ctx *fiber.Ctx, payload prime_api.GetFlagsPayload, key string) error { +func handleWorkspaceFeatureFlag(ctx *fiber.Ctx, api prime_api.IPrimeMonitorApi, payload prime_api.GetFlagsPayload, key string) error { var license db.License record := db.Db.Model(&db.License{}).Where("workspace_slug = ?", payload.WorkspaceSlug).First(&license) if record.Error != nil { @@ -217,7 +219,7 @@ func handleWorkspaceFeatureFlag(ctx *fiber.Ctx, payload prime_api.GetFlagsPayloa return nil } - APP_VERSION := os.Getenv("APP_VERSION") + APP_VERSION := api.AppVersion() var flags db.Flags record = db.Db.Model(&db.Flags{}).Where("license_id = ? AND version = ?", license.ID, APP_VERSION).First(&flags) @@ -264,7 +266,7 @@ func handleWorkspaceFeatureFlag(ctx *fiber.Ctx, payload prime_api.GetFlagsPayloa return nil } -func handleWorkspaceAllFeatureFlags(ctx *fiber.Ctx, payload prime_api.GetFlagsPayload, key string) error { +func handleWorkspaceAllFeatureFlags(ctx *fiber.Ctx, api prime_api.IPrimeMonitorApi, payload prime_api.GetFlagsPayload, key string) error { var license db.License record := db.Db.Model(&db.License{}).Where("workspace_slug = ?", payload.WorkspaceSlug).First(&license) if record.Error != nil { @@ -274,7 +276,7 @@ func handleWorkspaceAllFeatureFlags(ctx *fiber.Ctx, payload prime_api.GetFlagsPa return nil } - APP_VERSION := os.Getenv("APP_VERSION") + APP_VERSION := api.AppVersion() var flags db.Flags record = db.Db.Model(&db.Flags{}).Where("license_id = ? AND version = ?", license.ID, APP_VERSION).First(&flags) diff --git a/monitor/lib/router/handlers/workspace_activation_handler.go b/monitor/lib/router/handlers/workspace_activation_handler.go index 1bb78853c3..6d8cf0b5b2 100644 --- a/monitor/lib/router/handlers/workspace_activation_handler.go +++ b/monitor/lib/router/handlers/workspace_activation_handler.go @@ -8,6 +8,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/google/uuid" prime_api "github.com/makeplane/plane-ee/monitor/lib/api" + router_helpers "github.com/makeplane/plane-ee/monitor/lib/router/helpers" "github.com/makeplane/plane-ee/monitor/pkg/db" "gorm.io/gorm" ) @@ -61,7 +62,7 @@ func InitializeFreeWorkspace(api prime_api.IPrimeMonitorApi, key string) func(*f } } - license, _ := convertWorkspaceActivationResponseToLicense(data) + license, _ := router_helpers.ConvertWorkspaceActivationResponseToLicense(data) db.Db.Create(license) // Send the workspace activation message back to the client @@ -143,8 +144,8 @@ func GetSyncFeatureFlagHandler(api prime_api.IPrimeMonitorApi, key string) func( IsOfflinePayment: workspaceLicense.IsOfflinePayment, IsCancelled: workspaceLicense.IsCancelled, Subscription: workspaceLicense.Subscription, - CurrentPeriodEndDate: time.Time{}, - TrialEndDate: time.Time{}, + CurrentPeriodEndDate: *workspaceLicense.CurrentPeriodEndDate, + TrialEndDate: *workspaceLicense.TrialEndDate, HasAddedPayment: workspaceLicense.HasAddedPaymentMethod, HasActivatedFree: workspaceLicense.HasActivatedFreeTrial, MemberList: payload.MembersList, @@ -155,7 +156,7 @@ func GetSyncFeatureFlagHandler(api prime_api.IPrimeMonitorApi, key string) func( isSynced = false } else { // Update the existing license from the recieved data - updateLicense, _ := convertWorkspaceActivationResponseToLicense(data) + updateLicense, _ := router_helpers.ConvertWorkspaceActivationResponseToLicense(data) updateLicense.ID = workspaceLicense.ID if err := db.Db.Transaction(func(tx *gorm.DB) error { @@ -170,7 +171,7 @@ func GetSyncFeatureFlagHandler(api prime_api.IPrimeMonitorApi, key string) func( } if updateLicense.ProductType != "FREE" { - if err := RefreshFeatureFlags(context.Background(), api, *updateLicense, tx); err != nil { + if err := router_helpers.RefreshFeatureFlags(context.Background(), api, *updateLicense, tx); err != nil { return err } } @@ -374,17 +375,26 @@ func GetManualSyncHandler(api prime_api.IPrimeMonitorApi, key string) func(*fibe } err := db.Db.Transaction(func(tx *gorm.DB) error { - updatedLicense, activationReponse, err := RefreshLicense(context.Background(), api, license, tx) + updatedLicense, activationReponse, err := router_helpers.RefreshLicense(context.Background(), api, license, tx) if err != nil { + fmt.Println("Failed to refresh the license", err) return err } - if err := RefreshLicenseUsers(context.Background(), updatedLicense, *activationReponse, tx); err != nil { + if api.IsAirgapped() { + return ctx.Status(fiber.StatusOK).JSON(fiber.Map{ + "message": "Workspace synchronized successfully", + }) + } + + if err := router_helpers.RefreshLicenseUsers(context.Background(), updatedLicense, *activationReponse, tx); err != nil { + fmt.Println("Failed to refresh the license users", err) return err } if updatedLicense.ProductType != "FREE" { - if err := RefreshFeatureFlags(context.Background(), api, *updatedLicense, tx); err != nil { + if err := router_helpers.RefreshFeatureFlags(context.Background(), api, *updatedLicense, tx); err != nil { + fmt.Println("Failed to refresh the feature flags", err) return err } } @@ -404,6 +414,7 @@ func GetManualSyncHandler(api prime_api.IPrimeMonitorApi, key string) func(*fibe } } +// Not going to be used for the airgapped instances, as we are using the airgapped activation handler func GetActivateFeatureFlagHandler(api prime_api.IPrimeMonitorApi, key string) func(*fiber.Ctx) error { return func(ctx *fiber.Ctx) error { // Get the data from the request and forward the request to the API @@ -427,7 +438,7 @@ func GetActivateFeatureFlagHandler(api prime_api.IPrimeMonitorApi, key string) f } workspaceUUID, _ := uuid.Parse(data.WorkspaceID) - license, _ := convertWorkspaceActivationResponseToLicense(data) + license, _ := router_helpers.ConvertWorkspaceActivationResponseToLicense(data) // Remove the existing license for the workspace db.Db.Where("workspace_id = ?", workspaceUUID).Delete(&db.License{}) @@ -600,7 +611,7 @@ func DeactivateLicense(api prime_api.IPrimeMonitorApi, key string) func(*fiber.C } // Create the payload for reactivating the license - reactivatePayload, err := convertLicenseToWorkspaceActivationPayload(&license, members) + reactivatePayload, err := router_helpers.ConvertLicenseToWorkspaceActivationPayload(&license, members) if err != nil { return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "error": "Failed to convert the license to workspace activation payload", @@ -621,7 +632,7 @@ func DeactivateLicense(api prime_api.IPrimeMonitorApi, key string) func(*fiber.C }) } - freeLicense, err := convertWorkspaceActivationResponseToLicense(freeLicensePayload) + freeLicense, err := router_helpers.ConvertWorkspaceActivationResponseToLicense(freeLicensePayload) if err != nil { return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ diff --git a/monitor/lib/router/handlers/workspace_product_handler.go b/monitor/lib/router/handlers/workspace_product_handler.go index cb549f1cb5..e90de910e4 100644 --- a/monitor/lib/router/handlers/workspace_product_handler.go +++ b/monitor/lib/router/handlers/workspace_product_handler.go @@ -8,6 +8,7 @@ import ( "github.com/google/uuid" prime_api "github.com/makeplane/plane-ee/monitor/lib/api" "github.com/makeplane/plane-ee/monitor/pkg/db" + "gorm.io/gorm" ) type WorkspaceProductResponse struct { @@ -62,21 +63,35 @@ func GetWorkspaceProductHandler(api prime_api.IPrimeMonitorApi, key string) func license := db.License{} record := db.Db.Model(&db.License{}).Where("workspace_id = ? AND workspace_slug = ?", workspaceId, payload.WorkspaceSlug).First(&license) + isAirgapped := api.IsAirgapped() + if record.Error != nil { // If the record is not found, create a free workspace on the prime server /** We are trying to hit this endpoint in the case when we don't know the product of the license we initiate the free workspace with prime the prime returns us with the license details and the product type according to which we proceed with creating users and fetching the feature flags. */ - data, err := api.ActivateFreeWorkspace(prime_api.WorkspaceActivationPayload{ - WorkspaceSlug: payload.WorkspaceSlug, - WorkspaceID: workspaceId, - MembersList: payload.MembersList, - OwnerEmail: payload.OwnerEmail, - }) + + var data *prime_api.WorkspaceActivationResponse + var apiError *prime_api.APIError + + if isAirgapped { + data, apiError = api.SyncWorkspace(prime_api.WorkspaceSyncPayload{ + WorkspaceSlug: payload.WorkspaceSlug, + WorkspaceID: workspaceId, + MembersList: payload.MembersList, + }) + } else { + data, apiError = api.ActivateFreeWorkspace(prime_api.WorkspaceActivationPayload{ + WorkspaceSlug: payload.WorkspaceSlug, + WorkspaceID: workspaceId, + MembersList: payload.MembersList, + OwnerEmail: payload.OwnerEmail, + }) + } // If the workspace could not be created, return a free workspace - if err != nil { + if apiError != nil { now := time.Now() // Send the response back to the client ctx.Status(fiber.StatusOK).JSON(WorkspaceProductResponse{ @@ -95,7 +110,7 @@ func GetWorkspaceProductHandler(api prime_api.IPrimeMonitorApi, key string) func }) // Return the error return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": err.Error, + "error": apiError.Error, }) } @@ -124,12 +139,12 @@ func GetWorkspaceProductHandler(api prime_api.IPrimeMonitorApi, key string) func LastPaymentFailedCount: data.LastPaymentFailedCount, } // Save the license record to the database - db.Db.Create(license) - // Fetch the feature flags if the product is not free + members := make([]db.UserLicense, 0) + var flagData *db.Flags + if data.ProductType != "FREE" { // Create the members for the workspace - members := make([]db.UserLicense, 0) for _, member := range data.MemberList { userUUID, _ := uuid.Parse(member.UserId) members = append(members, db.UserLicense{ @@ -140,17 +155,28 @@ func GetWorkspaceProductHandler(api prime_api.IPrimeMonitorApi, key string) func IsActive: member.IsActive, }) } - db.Db.CreateInBatches(members, 100) + var flags *prime_api.FlagDataResponse - flags, err := api.GetFeatureFlags(data.LicenceKey) - - if err != nil { - return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ - "error": err.Error, - }) + if isAirgapped { + flags = &prime_api.FlagDataResponse{ + Version: data.Flags.Version, + EncyptedData: prime_api.EncyptedFlagData{ + AesKey: data.Flags.AesKey, + Nonce: data.Flags.Nonce, + CipherText: data.Flags.CipherText, + Tag: data.Flags.Tag, + }, + } + } else { + flags, apiError = api.GetFeatureFlags(data.LicenceKey) + if apiError != nil { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": apiError.Error, + }) + } } - flagData := &db.Flags{ + flagData = &db.Flags{ LicenseID: license.ID, Version: flags.Version, AesKey: flags.EncyptedData.AesKey, @@ -158,8 +184,22 @@ func GetWorkspaceProductHandler(api prime_api.IPrimeMonitorApi, key string) func CipherText: flags.EncyptedData.CipherText, Tag: flags.EncyptedData.Tag, } - db.Db.Create(flagData) + } + err := db.Db.Transaction(func(tx *gorm.DB) error { + tx.Create(license) + tx.CreateInBatches(members, 100) + tx.Create(flagData) + return nil + }) + + if err != nil { + return ctx.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": err.Error(), + }) + } + + if data.ProductType != "FREE" { // Send the workspace activation message back to the client return ctx.Status(fiber.StatusOK).JSON(fiber.Map{ "message": "License activated successfully for the workspace", @@ -168,6 +208,8 @@ func GetWorkspaceProductHandler(api prime_api.IPrimeMonitorApi, key string) func }) } + // Fetch the feature flags if the product is not free + // Send the response back to the client ctx.Status(fiber.StatusOK).JSON(WorkspaceProductResponse{ Plan: license.ProductType, diff --git a/monitor/lib/router/handlers/helpers.go b/monitor/lib/router/helpers/license_helpers.go similarity index 88% rename from monitor/lib/router/handlers/helpers.go rename to monitor/lib/router/helpers/license_helpers.go index 170cb81819..6787fb1199 100644 --- a/monitor/lib/router/handlers/helpers.go +++ b/monitor/lib/router/helpers/license_helpers.go @@ -1,4 +1,4 @@ -package handlers +package router_helpers import ( "context" @@ -42,11 +42,21 @@ func RefreshLicense(ctx context.Context, api prime_api.IPrimeMonitorApi, license MembersList: workspaceMembers, }) + verificationThreshhold := LICENSE_VERIFICATION_FAILED_THRESHOLD + + shouldDeactivate := false + // If the license is airgapped, we need to check the current period end date + if api.IsAirgapped() && license.CurrentPeriodEndDate != nil { + shouldDeactivate = time.Since(*license.CurrentPeriodEndDate) > 0 + } else { + shouldDeactivate = license.LastVerifiedAt != nil && time.Since(*license.LastVerifiedAt) > verificationThreshhold + } + if err != nil { // The license sync failed, we need to check the last verified date if license.LastVerifiedAt != nil && license.ProductType != "FREE" { // Check if the last verified date is greater than 14 days - if time.Since(*license.LastVerifiedAt) > LICENSE_VERIFICATION_FAILED_THRESHOLD { + if shouldDeactivate { // Deactivate the license newLicense := &db.License{ ID: license.ID, @@ -103,6 +113,20 @@ func RefreshLicense(ctx context.Context, api prime_api.IPrimeMonitorApi, license }, nil } } + // if the license is airgapped, we need to return nil, nil, nil + if api.IsAirgapped() { + return license, &prime_api.WorkspaceActivationResponse{ + Product: license.Product, + ProductType: license.ProductType, + WorkspaceSlug: license.WorkspaceSlug, + Seats: license.Seats, + FreeSeats: license.FreeSeats, + Interval: license.Interval, + IsOfflinePayment: license.IsOfflinePayment, + IsCancelled: license.IsCancelled, + MemberList: []prime_api.WorkspaceMember{}, + }, nil + } return nil, nil, fmt.Errorf(F2_LICENSE_VERFICATION_FAILED, license.LicenseKey, license.WorkspaceSlug) } @@ -225,7 +249,7 @@ func RefreshFeatureFlags(ctx context.Context, api prime_api.IPrimeMonitorApi, li return nil } -func convertWorkspaceActivationResponseToLicense(data *prime_api.WorkspaceActivationResponse) (*db.License, error) { +func ConvertWorkspaceActivationResponseToLicense(data *prime_api.WorkspaceActivationResponse) (*db.License, error) { workspaceUUID, _ := uuid.Parse(data.WorkspaceID) instanceUUID, _ := uuid.Parse(data.InstanceID) @@ -271,7 +295,7 @@ func convertWorkspaceActivationResponseToLicense(data *prime_api.WorkspaceActiva return license, nil } -func convertLicenseToWorkspaceActivationPayload(license *db.License, userLicenses []db.UserLicense) (*prime_api.WorkspaceActivationPayload, error) { +func ConvertLicenseToWorkspaceActivationPayload(license *db.License, userLicenses []db.UserLicense) (*prime_api.WorkspaceActivationPayload, error) { // Convert WorkspaceID to string workspaceIDStr := license.WorkspaceID.String() diff --git a/monitor/lib/router/routes/feature_flags.go b/monitor/lib/router/routes/feature_flags.go index 11d1d965c9..f9132bb0b2 100644 --- a/monitor/lib/router/routes/feature_flags.go +++ b/monitor/lib/router/routes/feature_flags.go @@ -8,6 +8,7 @@ import ( "github.com/gofiber/fiber/v2/middleware/logger" prime_api "github.com/makeplane/plane-ee/monitor/lib/api" primelogger "github.com/makeplane/plane-ee/monitor/lib/logger" + "github.com/makeplane/plane-ee/monitor/lib/router/airgapped_handlers.go" "github.com/makeplane/plane-ee/monitor/lib/router/handlers" ) @@ -25,6 +26,7 @@ func RegisterFeatureFlags(router *fiber.Router, plogger *primelogger.Handler, ap })) (*router).Mount("/", ffController) + addAirgappedActivation(ffController, api, key) addFreeWorkspace(ffController, api, key) addActivateRoutes(ffController, api, key) addFeatureFlagRoutes(ffController, api, key) @@ -40,6 +42,10 @@ func addFreeWorkspace(routes fiber.Router, api *prime_api.IPrimeMonitorApi, key routes.Post("/licenses/initialize/", handlers.InitializeFreeWorkspace(*api, key)) } +func addAirgappedActivation(routes fiber.Router, api *prime_api.IPrimeMonitorApi, key string) { + routes.Post("/licenses/activate/upload/", airgapped_handlers.GetAirgappedActivationHandler(*api, key)) +} + func addActivateRoutes(controller *fiber.App, api *prime_api.IPrimeMonitorApi, key string) { controller.Post("/licenses/activate/", handlers.GetActivateFeatureFlagHandler(*api, key)) controller.Post("/licenses/modify-seats/", handlers.UpdateLicenseSeats(*api, key)) diff --git a/packages/types/src/instance/base-extended.d.ts b/packages/types/src/instance/base-extended.d.ts new file mode 100644 index 0000000000..ce924449ce --- /dev/null +++ b/packages/types/src/instance/base-extended.d.ts @@ -0,0 +1,17 @@ +export interface IInstanceConfigExtended { + // instance + is_airgapped: boolean; + // auth + is_oidc_enabled: boolean; + oidc_provider_name: string | undefined; + is_saml_enabled: boolean; + saml_provider_name: string | undefined; + // feature flags + payment_server_base_url?: string; + prime_server_base_url?: string; + feature_flag_server_base_url?: string; + // silo + silo_base_url: string | undefined; + // elasticsearch + is_elasticsearch_enabled: boolean; +} diff --git a/packages/types/src/instance/base.d.ts b/packages/types/src/instance/base.d.ts index 7bd3f37d29..2085251386 100644 --- a/packages/types/src/instance/base.d.ts +++ b/packages/types/src/instance/base.d.ts @@ -8,6 +8,8 @@ import { // enterprise TInstanceEnterpriseAuthenticationKeys, } from "./"; +// extended +import { IInstanceConfigExtended } from "./base-extended"; type TProductType = "plane-ce" | "plane-one"; @@ -42,7 +44,7 @@ export interface IInstance { workspaces_exist: boolean; } -export interface IInstanceConfig { +export interface IInstanceConfig extends IInstanceConfigExtended { enable_signup: boolean; is_workspace_creation_disabled: boolean; is_google_enabled: boolean; @@ -65,18 +67,6 @@ export interface IInstanceConfig { is_intercom_enabled: boolean; intercom_app_id: string | undefined; instance_changelog_url?: string; - // enterprise - is_oidc_enabled: boolean; - oidc_provider_name: string | undefined; - is_saml_enabled: boolean; - saml_provider_name: string | undefined; - payment_server_base_url?: string; - prime_server_base_url?: string; - feature_flag_server_base_url?: string; - // silo - silo_base_url: string | undefined; - // elasticsearch - is_elasticsearch_enabled: boolean; } export interface IInstanceUpdate { diff --git a/packages/ui/src/tabs/tab-list.tsx b/packages/ui/src/tabs/tab-list.tsx index 0e9eb2dbd5..ac3cb81736 100644 --- a/packages/ui/src/tabs/tab-list.tsx +++ b/packages/ui/src/tabs/tab-list.tsx @@ -18,10 +18,20 @@ type TTabListProps = { tabClassName?: string; size?: "sm" | "md" | "lg"; selectedTab?: string; + autoWrap?: boolean; onTabChange?: (key: string) => void; }; -export const TabList: FC = ({ +export const TabList: FC = ({ autoWrap = true, ...props }) => + autoWrap ? ( + + + + ) : ( + + ); + +const TabListInner: FC = ({ tabs, tabListClassName, tabClassName, @@ -63,7 +73,9 @@ export const TabList: FC = ({ }} disabled={tab.disabled} > - {tab.icon && } + {tab.icon && ( + + )} {tab.label} ))} diff --git a/packages/ui/src/tabs/tabs.tsx b/packages/ui/src/tabs/tabs.tsx index b2791c0b8c..32b5c23d67 100644 --- a/packages/ui/src/tabs/tabs.tsx +++ b/packages/ui/src/tabs/tabs.tsx @@ -71,6 +71,7 @@ export const Tabs: FC = (props: TTabsProps) => { tabClassName={tabClassName} size={size} onTabChange={handleTabChange} + autoWrap={false} /> {actions &&
{actions}
} diff --git a/web/ee/components/command-palette/modals/workspace-level.tsx b/web/ee/components/command-palette/modals/workspace-level.tsx index e1390903c6..c24448254d 100644 --- a/web/ee/components/command-palette/modals/workspace-level.tsx +++ b/web/ee/components/command-palette/modals/workspace-level.tsx @@ -1,6 +1,4 @@ import { observer } from "mobx-react"; -// plane imports -import { SUBSCRIPTION_WITH_SEATS_MANAGEMENT } from "@plane/constants"; // ce components import { WorkspaceLevelModals as BaseWorkspaceLevelModals, @@ -22,7 +20,7 @@ export const WorkspaceLevelModals = observer((props: TWorkspaceLevelModalsProps) const { workspaceSlug } = props; // store hooks const { - currentWorkspaceSubscribedPlanDetail: subscriptionDetail, + isSeatManagementEnabled, addWorkspaceSeatsModal, removeUnusedSeatsConfirmationModal, toggleAddWorkspaceSeatsModal, @@ -46,12 +44,6 @@ export const WorkspaceLevelModals = observer((props: TWorkspaceLevelModalsProps) updateCreateUpdateModalPayload: updateWorkspaceDashboardModalPayload, }, } = useDashboards(); - // derived values - const isOfflineSubscription = subscriptionDetail?.is_offline_payment; - const isSeatsManagementEnabled = - subscriptionDetail && - !isOfflineSubscription && - SUBSCRIPTION_WITH_SEATS_MANAGEMENT.includes(subscriptionDetail?.product); return ( <> @@ -88,7 +80,7 @@ export const WorkspaceLevelModals = observer((props: TWorkspaceLevelModalsProps) customerId={createUpdateCustomerModal.customerId} onClose={() => toggleCreateCustomerModal({ isOpen: false, customerId: undefined })} /> - {isSeatsManagementEnabled && ( + {isSeatManagementEnabled && ( { @@ -96,7 +88,7 @@ export const WorkspaceLevelModals = observer((props: TWorkspaceLevelModalsProps) }} /> )} - {isSeatsManagementEnabled && ( + {isSeatManagementEnabled && ( toggleRemoveUnusedSeatsConfirmationModal()} diff --git a/web/ee/components/common/subscription/subscription-pill.tsx b/web/ee/components/common/subscription/subscription-pill.tsx index a9eb4fb927..b40a04751c 100644 --- a/web/ee/components/common/subscription/subscription-pill.tsx +++ b/web/ee/components/common/subscription/subscription-pill.tsx @@ -2,18 +2,23 @@ import { EProductSubscriptionEnum } from "@plane/constants"; import { IWorkspace } from "@plane/types"; import { cn, getSubscriptionName } from "@plane/utils"; +// components import { getSubscriptionTextAndBackgroundColor } from "@/components/workspace/billing/subscription"; +// plane web hooks +import { useWorkspaceSubscription } from "@/plane-web/hooks/store"; type TProps = { workspace: IWorkspace }; export const SubscriptionPill = (props: TProps) => { const { workspace } = props; + // store hooks + const { getIsInTrialPeriod } = useWorkspaceSubscription(); // derived values const subscriptionName = getSubscriptionName(workspace.current_plan ?? EProductSubscriptionEnum.FREE); const subscriptionColor = getSubscriptionTextAndBackgroundColor( workspace.current_plan ?? EProductSubscriptionEnum.FREE ); - const isOnTrial = workspace.is_on_trial; + const isOnTrial = getIsInTrialPeriod(false); return (
= (props) => { + const { label, value, description } = props; + + const handleCopy = () => { + copyTextToClipboard(value); + setToast({ + type: TOAST_TYPE.INFO, + title: "Copied to clipboard", + message: `The ${label} has been successfully copied to your clipboard`, + }); + }; + + return ( +
+

{label}

+
+
+

{value}

+ +
+
+ {description &&
{description}
} +
+ ); +}; + +type TInstanceDetailsForLicenseActivationProps = { + workspaceSlug: string; +}; + +export const InstanceDetailsForLicenseActivation = observer((props: TInstanceDetailsForLicenseActivationProps) => { + const { workspaceSlug } = props; + // store hooks + const { instance } = useInstance(); + const { getWorkspaceBySlug } = useWorkspace(); + // derived values + const instanceId = instance?.id; + const workspace = getWorkspaceBySlug(workspaceSlug); + const workspaceId = workspace?.id; + const domain = window.location.origin; + const appVersion = instance?.current_version; + + const PLANE_PROVIDED_DETAILS = useMemo( + () => ({ + title: "Plane provided details", + description: + "These details are auto-generated for your instance and workspace. You can copy and use them as needed.", + fields: [ + { + row: 1, + fields: [ + { + label: "Instance ID", + value: instanceId, + }, + { + label: "Workspace ID", + value: workspaceId, + }, + ], + }, + { + row: 2, + fields: [ + { + label: "Workspace slug", + value: workspaceSlug, + }, + { + label: "Domain", + value: domain, + }, + ], + }, + { + row: 3, + fields: [ + { + label: "Instance app version", + value: appVersion, + }, + null, + ], + }, + ], + }), + [instanceId, workspaceId, workspaceSlug, domain, appVersion] + ); + + return ( +
+
+

{PLANE_PROVIDED_DETAILS.title}

+

{PLANE_PROVIDED_DETAILS.description}

+
+
+ {PLANE_PROVIDED_DETAILS.fields.map((row) => ( +
+ {row.fields.map((field, index) => ( +
+ {field && field.value && } +
+ ))} +
+ ))} +
+
+ ); +}); diff --git a/web/ee/components/license/activation/index.ts b/web/ee/components/license/activation/index.ts new file mode 100644 index 0000000000..031608e25f --- /dev/null +++ b/web/ee/components/license/activation/index.ts @@ -0,0 +1 @@ +export * from "./modal"; diff --git a/web/ee/components/license/activation/license-file-form.tsx b/web/ee/components/license/activation/license-file-form.tsx new file mode 100644 index 0000000000..d0a295c240 --- /dev/null +++ b/web/ee/components/license/activation/license-file-form.tsx @@ -0,0 +1,150 @@ +"use client"; + +import { FC, useState, useCallback } from "react"; +import { observer } from "mobx-react"; +import { useDropzone } from "react-dropzone"; +import { Upload, FileText, X } from "lucide-react"; +// plane imports +import { Button } from "@plane/ui"; +import { cn } from "@plane/utils"; +// plane web imports +import { useSelfHostedSubscription } from "@/plane-web/hooks/store"; +// local imports +import { InstanceDetailsForLicenseActivation } from "./helper"; + +export type TLicenseFileFormProps = { + workspaceSlug: string; + hasPermission: boolean; + onSuccess?: (message: string) => void; + onError?: (error: string) => void; + handleClose: () => void; +}; + +export const LicenseFileForm: FC = observer((props) => { + const { workspaceSlug, hasPermission, onSuccess, onError, handleClose } = props; + // hooks + const { activateUsingLicenseFile } = useSelfHostedSubscription(); + // states + const [selectedFile, setSelectedFile] = useState(null); + const [errors, setErrors] = useState(undefined); + const [loader, setLoader] = useState(false); + // derived + const hasError = Boolean(errors); + + const onDrop = useCallback((acceptedFiles: File[]) => { + setErrors(undefined); + if (acceptedFiles.length > 0) { + const file = acceptedFiles[0]; + // Validate file type + if (!file.name.endsWith(".json")) { + setErrors("Please upload a valid license file (.json)"); + return; + } + setSelectedFile(file); + } + }, []); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { + "application/json": [".json"], + }, + maxFiles: 1, + disabled: !hasPermission, + }); + + const removeFile = () => { + setSelectedFile(null); + setErrors(undefined); + }; + + const submitActivateLicense = async (event: React.FormEvent) => { + event.preventDefault(); + if (!workspaceSlug) return; + + if (!selectedFile) { + const errorMessage = "Please select a license file"; + setErrors(errorMessage); + onError?.(errorMessage); + return; + } + + try { + setLoader(true); + const subscriptionResponse = await activateUsingLicenseFile(workspaceSlug, selectedFile); + onSuccess?.(subscriptionResponse?.message || "Workspace subscription activated successfully."); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + const errorMessage = + error?.error ?? "Your license file is invalid or already in use. For any queries contact support@plane.so"; + setErrors(errorMessage); + onError?.(errorMessage); + } finally { + setLoader(false); + } + }; + + return ( +
+
+
+
+

Upload a license file

+ {!selectedFile ? ( +
+ +
+ +

+ {isDragActive + ? "Drop the license file here" + : "Drag & drop or click to browse. Supports .json files."} +

+
+
+ ) : ( + <> +
+
+
+ +

{selectedFile.name}

+
+ +
+
+ {hasError &&
{errors}
} + + )} +
+
+ +
+
+
+ {!hasPermission && ( +
+ You don't have permission to perform this action. Please contact the workspace admin. +
+ )} +
+
+ + +
+
+
+ ); +}); diff --git a/web/ee/components/license/activation/license-key-form.tsx b/web/ee/components/license/activation/license-key-form.tsx new file mode 100644 index 0000000000..45dd34b567 --- /dev/null +++ b/web/ee/components/license/activation/license-key-form.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { FC, useState, FormEvent } from "react"; +import { observer } from "mobx-react"; +// plane imports +import { Button, Input } from "@plane/ui"; +import { cn } from "@plane/utils"; +// plane web hooks +import { useSelfHostedSubscription } from "@/plane-web/hooks/store"; + +export type TLicenseKeyFormProps = { + workspaceSlug: string; + hasPermission: boolean; + onSuccess?: (message: string) => void; + onError?: (error: string) => void; + handleClose: () => void; +}; + +export const LicenseKeyForm: FC = observer((props) => { + const { workspaceSlug, hasPermission, onSuccess, onError, handleClose } = props; + // hooks + const { activateUsingLicenseKey } = useSelfHostedSubscription(); + // states + const [activationKey, setActivationKey] = useState(undefined); + const [errors, setErrors] = useState(undefined); + const [loader, setLoader] = useState(false); + + const handleActivateLicense = async (event: FormEvent) => { + setErrors(undefined); + setActivationKey(event.currentTarget.value); + }; + + const submitActivateLicense = async (event: FormEvent) => { + event.preventDefault(); + if (!workspaceSlug) return; + + if (!activationKey || activationKey.length <= 0) { + const errorMessage = "Please enter a valid license key"; + setErrors(errorMessage); + onError?.(errorMessage); + return; + } + + try { + setLoader(true); + const subscriptionResponse = await activateUsingLicenseKey(workspaceSlug, activationKey); + onSuccess?.(subscriptionResponse?.message || "Workspace subscription activated successfully."); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + const errorMessage = + error?.error ?? "Your license is invalid or already in use. For any queries contact support@plane.so"; + setErrors(errorMessage); + onError?.(errorMessage); + } finally { + setLoader(false); + } + }; + + return ( +
+
+ + {errors &&
{errors}
} +
+
+
+ {!hasPermission && ( +
+ You don't have permission to perform this action. Please contact the workspace admin. +
+ )} +
+
+ + +
+
+
+ ); +}); diff --git a/web/ee/components/license/activation/modal.tsx b/web/ee/components/license/activation/modal.tsx new file mode 100644 index 0000000000..95add1513c --- /dev/null +++ b/web/ee/components/license/activation/modal.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { FC, useCallback, useMemo } from "react"; +import { observer } from "mobx-react"; +import { useParams } from "next/navigation"; +// plane imports +import { EUserPermissionsLevel, EUserWorkspaceRoles } from "@plane/constants"; +import { EModalPosition, EModalWidth, ModalCore, setToast, TOAST_TYPE } from "@plane/ui"; +// components +import { WorkspaceLogo } from "@/components/workspace"; +// helpers +import { getFileURL } from "@/helpers/file.helper"; +// hooks +import { useInstance, useUserPermissions, useWorkspace } from "@/hooks/store"; +// plane web imports +import { useWorkspaceSubscription } from "@/plane-web/hooks/store"; +// local imports +import { LicenseFileForm } from "./license-file-form"; +import { LicenseKeyForm } from "./license-key-form"; + +type TSubscriptionActivationModal = { + isOpen: boolean; + handleClose: () => void; +}; + +export const SubscriptionActivationModal: FC = observer((props) => { + const { isOpen, handleClose } = props; + // params + const { workspaceSlug } = useParams(); + // hooks + const { currentWorkspace } = useWorkspace(); + const { handleSuccessModalToggle } = useWorkspaceSubscription(); + const { config } = useInstance(); + const { allowPermissions } = useUserPermissions(); + // derived values + const currentWorkspaceLogoUrl = currentWorkspace?.logo_url ? getFileURL(currentWorkspace?.logo_url) : undefined; + const isAirGapped = config?.is_airgapped; + const hasActivateLicensePermission = allowPermissions( + [EUserWorkspaceRoles.ADMIN], + EUserPermissionsLevel.WORKSPACE, + workspaceSlug?.toString() + ); + + const handleSuccess = useCallback( + (message: string) => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Done!", + message: message || "Workspace subscription activated successfully.", + }); + handleSuccessModalToggle(true); + handleClose(); + }, + [handleClose, handleSuccessModalToggle] + ); + + const commonProps = useMemo( + () => ({ + workspaceSlug: workspaceSlug?.toString(), + hasPermission: hasActivateLicensePermission, + onSuccess: handleSuccess, + handleClose, + }), + [workspaceSlug, handleSuccess, handleClose, hasActivateLicensePermission] + ); + + if (!isOpen) return null; + return ( + +
+
+

+ Activate +
+ +
+ {currentWorkspace?.name} +

+
+ {isAirGapped + ? "Upload a license file to activate the plan you subscribed to on this workspace. Any other workspaces without a license key on this instance will continue to be on the Free plan." + : "Enter a license key to activate the plan you subscribed to on this workspace. Any other workspaces without a license key on this instance will continue to be on the Free plan."} +
+
+ {isAirGapped ? : } +
+
+ ); +}); diff --git a/web/ee/components/license/badge/cloud-badge.tsx b/web/ee/components/license/badge/cloud-badge.tsx index 02029dffc9..bd5db2dee3 100644 --- a/web/ee/components/license/badge/cloud-badge.tsx +++ b/web/ee/components/license/badge/cloud-badge.tsx @@ -25,6 +25,7 @@ export const CloudEditionBadge = observer(() => { const { isPaidPlanModalOpen, currentWorkspaceSubscribedPlanDetail: subscriptionDetail, + getIsInTrialPeriod, togglePaidPlanModal, handleSuccessModalToggle, } = useWorkspaceSubscription(); @@ -32,7 +33,7 @@ export const CloudEditionBadge = observer(() => { const currentSubscription = subscriptionDetail?.product; const remainingTrialDays = subscriptionDetail?.remaining_trial_days; const showPaymentButton = !!subscriptionDetail?.show_payment_button; - const isOnTrial = !!subscriptionDetail?.is_on_trial; + const isOnTrial = getIsInTrialPeriod(false); useEffect(() => { const paymentStatus = searchParams.get("payment"); diff --git a/web/ee/components/license/badge/self-hosted-badge.tsx b/web/ee/components/license/badge/self-hosted-badge.tsx index 79567f8d68..b258be9819 100644 --- a/web/ee/components/license/badge/self-hosted-badge.tsx +++ b/web/ee/components/license/badge/self-hosted-badge.tsx @@ -9,7 +9,7 @@ import { cn, getSubscriptionName } from "@plane/utils"; // plane web imports import { SubscriptionButton } from "@/plane-web/components/common"; import { PaidPlanUpgradeModal, PlaneOneEditionBadge } from "@/plane-web/components/license"; -import { SubscriptionActivationModal } from "@/plane-web/components/workspace"; +import { SubscriptionActivationModal } from "@/plane-web/components/license/activation"; import { useSelfHostedSubscription, useWorkspaceSubscription } from "@/plane-web/hooks/store"; export const SelfHostedEditionBadge = observer(() => { diff --git a/web/ee/components/license/modal/upgrade-modal.tsx b/web/ee/components/license/modal/upgrade-modal.tsx index 281c3a7558..5643171520 100644 --- a/web/ee/components/license/modal/upgrade-modal.tsx +++ b/web/ee/components/license/modal/upgrade-modal.tsx @@ -40,7 +40,7 @@ export const PaidPlanUpgradeModal: FC = observer((pro // router const { workspaceSlug } = useParams(); // hooks - const { currentWorkspaceSubscribedPlanDetail: subscriptionDetail } = useWorkspaceSubscription(); + const { currentWorkspaceSubscribedPlanDetail: subscriptionDetail, getIsInTrialPeriod } = useWorkspaceSubscription(); const { toggleLicenseActivationModal } = useSelfHostedSubscription(); // states const [upgradeLoaderType, setUpgradeLoaderType] = useState(undefined); @@ -57,7 +57,7 @@ export const PaidPlanUpgradeModal: FC = observer((pro // derived values const isSelfHosted = subscriptionDetail?.is_self_managed; const isTrialAllowed = subscriptionDetail?.is_trial_allowed; - const isOnTrial = subscriptionDetail?.is_on_trial; + const isOnTrial = getIsInTrialPeriod(false); const isTrialEnded = subscriptionDetail?.is_trial_ended; const currentPlan = subscriptionDetail?.product; const planeName = diff --git a/web/ee/components/license/plans-card/business.tsx b/web/ee/components/license/plans-card/business.tsx index 4c78457dd2..c5793992ce 100644 --- a/web/ee/components/license/plans-card/business.tsx +++ b/web/ee/components/license/plans-card/business.tsx @@ -29,13 +29,17 @@ export const BusinessPlanCard: React.FC = observer((prop // states const [isLoading, setIsLoading] = useState(false); // hooks - const { currentWorkspaceSubscribedPlanDetail: subscriptionDetail } = useWorkspaceSubscription(); + const { + currentWorkspaceSubscribedPlanDetail: subscriptionDetail, + isSubscriptionManagementEnabled, + getIsInTrialPeriod, + } = useWorkspaceSubscription(); // derived values const isSelfManaged = subscriptionDetail?.is_self_managed; const startDate = subscriptionDetail?.current_period_start_date; const endDate = subscriptionDetail?.current_period_end_date; const isSubscriptionCancelled = subscriptionDetail?.is_cancelled; - const isInTrialPeriod = !isSelfManaged && subscriptionDetail?.is_on_trial && !subscriptionDetail?.has_upgraded; + const isInTrialPeriod = getIsInTrialPeriod(true); useEffect(() => { setIsLoading(upgradeLoader === EProductSubscriptionEnum.BUSINESS); @@ -94,7 +98,7 @@ export const BusinessPlanCard: React.FC = observer((prop } button={ - !subscriptionDetail.is_offline_payment && ( + isSubscriptionManagementEnabled && (
- -
-
- - - ); -}); diff --git a/web/ee/components/workspace/billing/billing-actions-button.tsx b/web/ee/components/workspace/billing/billing-actions-button.tsx index c816ffaadc..7c26f47509 100644 --- a/web/ee/components/workspace/billing/billing-actions-button.tsx +++ b/web/ee/components/workspace/billing/billing-actions-button.tsx @@ -3,7 +3,6 @@ import { observer } from "mobx-react"; import { ChevronDown } from "lucide-react"; // plane imports -import { SUBSCRIPTION_WITH_SEATS_MANAGEMENT } from "@plane/constants"; import { Button, CustomMenu } from "@plane/ui"; // ce imports import { TBillingActionsButtonProps } from "@/ce/components/workspace/billing/billing-actions-button"; @@ -14,20 +13,14 @@ export const BillingActionsButton = observer((props: TBillingActionsButtonProps) const { canPerformWorkspaceAdminActions } = props; // store hooks const { - currentWorkspaceSubscribedPlanDetail: subscriptionDetail, + isSeatManagementEnabled, toggleAddWorkspaceSeatsModal, toggleRemoveUnusedSeatsConfirmationModal, } = useWorkspaceSubscription(); - // derived values - const isOfflineSubscription = subscriptionDetail?.is_offline_payment; - const isSeatsManagementEnabled = - subscriptionDetail && - !isOfflineSubscription && - SUBSCRIPTION_WITH_SEATS_MANAGEMENT.includes(subscriptionDetail?.product); return ( <> - {isSeatsManagementEnabled && canPerformWorkspaceAdminActions && ( + {isSeatManagementEnabled && canPerformWorkspaceAdminActions && ( diff --git a/web/ee/components/workspace/billing/comparison/plan-detail.tsx b/web/ee/components/workspace/billing/comparison/plan-detail.tsx index 6225cff661..54ef031ac1 100644 --- a/web/ee/components/workspace/billing/comparison/plan-detail.tsx +++ b/web/ee/components/workspace/billing/comparison/plan-detail.tsx @@ -52,14 +52,14 @@ export const PlanDetail: FC = observer((props) => { handleUpgrade, } = props; // store hooks - const { currentWorkspaceSubscribedPlanDetail: subscriptionDetail } = useWorkspaceSubscription(); + const { currentWorkspaceSubscribedPlanDetail: subscriptionDetail, getIsInTrialPeriod } = useWorkspaceSubscription(); // subscription details const subscriptionName = getSubscriptionName(subscriptionType); const currentPlan = subscriptionDetail?.product; const isSelfHosted = !!subscriptionDetail?.is_self_managed; const canStartTrial = !!subscriptionDetail?.is_trial_allowed; - const isInTrial = !!subscriptionDetail?.is_on_trial; + const isInTrial = getIsInTrialPeriod(false); const hasTrialEnded = !!subscriptionDetail?.is_trial_ended; const shouldShowTrialDetails = !isSelfHosted && (canStartTrial || isInTrial || hasTrialEnded); diff --git a/web/ee/components/workspace/billing/comparison/subscription-button.tsx b/web/ee/components/workspace/billing/comparison/subscription-button.tsx index d1496d2ae3..669ba552b6 100644 --- a/web/ee/components/workspace/billing/comparison/subscription-button.tsx +++ b/web/ee/components/workspace/billing/comparison/subscription-button.tsx @@ -26,11 +26,11 @@ export const SubscriptionButton: FC = observer((props) // plane hooks const { t } = useTranslation(); // store hooks - const { currentWorkspaceSubscribedPlanDetail: subscriptionDetail } = useWorkspaceSubscription(); + const { currentWorkspaceSubscribedPlanDetail: subscriptionDetail, getIsInTrialPeriod } = useWorkspaceSubscription(); // derived values const currentPlan = subscriptionDetail?.product ?? EProductSubscriptionEnum.FREE; const subscriptionName = getSubscriptionName(subscriptionType); - const isOnTrialPeriod = !!subscriptionDetail?.is_on_trial && !subscriptionDetail?.has_added_payment_method; + const isOnTrialPeriod = getIsInTrialPeriod(true); const showCurrentSubscriptionButton = currentPlan === subscriptionType && !isOnTrialPeriod; const isHigherTierPlan = EProductSubscriptionTier[subscriptionType] >= EProductSubscriptionTier[currentPlan]; const showUpgradeButton = isHigherTierPlan && (isOnTrialPeriod || currentPlan !== subscriptionType); diff --git a/web/ee/components/workspace/billing/comparison/trial-detail.tsx b/web/ee/components/workspace/billing/comparison/trial-detail.tsx index 396d946ca9..2bd26b3c5a 100644 --- a/web/ee/components/workspace/billing/comparison/trial-detail.tsx +++ b/web/ee/components/workspace/billing/comparison/trial-detail.tsx @@ -19,13 +19,13 @@ type TTrialDetailsProps = { export const TrialDetails: FC = observer((props) => { const { subscriptionType, trialLoader, upgradeLoader, isProductsAPILoading, handleTrial } = props; // store hooks - const { currentWorkspaceSubscribedPlanDetail: subscriptionDetail } = useWorkspaceSubscription(); + const { currentWorkspaceSubscribedPlanDetail: subscriptionDetail, getIsInTrialPeriod } = useWorkspaceSubscription(); // derived values const planName = getSubscriptionName(subscriptionType); const currentSubscription = subscriptionDetail?.product; const isSelfManaged = !!subscriptionDetail?.is_self_managed; const isTrialAllowed = !!subscriptionDetail?.is_trial_allowed; - const isOnTrialPeriod = !!subscriptionDetail?.is_on_trial && currentSubscription === subscriptionType; + const isOnTrialPeriod = getIsInTrialPeriod(false) && currentSubscription === subscriptionType; const isTrialEnded = !!subscriptionDetail?.is_trial_ended && currentSubscription === subscriptionType; if (isSelfManaged) return null; diff --git a/web/ee/components/workspace/billing/manage-seats/add-seats/form.tsx b/web/ee/components/workspace/billing/manage-seats/add-seats/form.tsx index 3cb3e04f1d..6ab90a081f 100644 --- a/web/ee/components/workspace/billing/manage-seats/add-seats/form.tsx +++ b/web/ee/components/workspace/billing/manage-seats/add-seats/form.tsx @@ -30,7 +30,11 @@ export const AddSeatsForm: React.FC = observer((props) => { // router const { workspaceSlug } = useParams(); // mobx store - const { currentWorkspaceSubscribedPlanDetail: subscribedPlan, updateSubscribedPlan } = useWorkspaceSubscription(); + const { + currentWorkspaceSubscribedPlanDetail: subscribedPlan, + getIsInTrialPeriod, + updateSubscribedPlan, + } = useWorkspaceSubscription(); // states const [currentStep, setCurrentStep] = useState("SELECT_SEATS"); const [isSubmitting, setIsSubmitting] = useState(false); @@ -40,7 +44,7 @@ export const AddSeatsForm: React.FC = observer((props) => { const [error, setError] = useState(""); // derived values const isSelfHosted = subscribedPlan?.is_self_managed; - const isOnTrial = subscribedPlan?.is_on_trial; + const isOnTrial = getIsInTrialPeriod(false); const planeName = subscribedPlan?.product ? getSubscriptionName(subscribedPlan?.product) : ""; useEffect(() => { @@ -178,14 +182,14 @@ export const AddSeatsForm: React.FC = observer((props) => { error={error} setError={setError} handleSeatChange={handleSeatChange} - isLoading={!!isOnTrial ? isSubmitting : isFetchingProrationPreview} + isLoading={isOnTrial ? isSubmitting : isFetchingProrationPreview} handleNextStep={handleNextStep} handleClose={handleClose} onPreviousStep={onPreviousStep ? handleOnPreviousStep : undefined} planeName={planeName} purchasedSeats={subscribedPlan?.purchased_seats || 0} isSelfHosted={!!isSelfHosted} - isOnTrial={!!isOnTrial} + isOnTrial={isOnTrial} /> ) : ( prorationPreview && ( diff --git a/web/ee/components/workspace/delete-workspace-modal.tsx b/web/ee/components/workspace/delete-workspace-modal.tsx index 098c873306..f071c9a5e3 100644 --- a/web/ee/components/workspace/delete-workspace-modal.tsx +++ b/web/ee/components/workspace/delete-workspace-modal.tsx @@ -21,11 +21,13 @@ type Props = { export const DeleteWorkspaceModal: React.FC = observer((props) => { const { isOpen, data, onClose } = props; // store hooks - const { currentWorkspaceSubscribedPlanDetail } = useWorkspaceSubscription(); + const { getIsInTrialPeriod } = useWorkspaceSubscription(); + // derived values + const isInTrialPeriod = getIsInTrialPeriod(false); return ( onClose()} position={EModalPosition.CENTER} width={EModalWidth.XL}> - {currentWorkspaceSubscribedPlanDetail?.is_on_trial ? ( + {isInTrialPeriod ? ( ) : ( diff --git a/web/ee/components/workspace/index.ts b/web/ee/components/workspace/index.ts index d20cc1773e..7948801575 100644 --- a/web/ee/components/workspace/index.ts +++ b/web/ee/components/workspace/index.ts @@ -4,7 +4,6 @@ export * from "./billing"; export * from "./upgrade-toast"; export * from "./delete-workspace-section"; export * from "./sidebar"; -export * from "./activation-modal"; export * from "./sidebar"; export * from "./search"; export * from "./upgrade-empty-state-button"; diff --git a/web/ee/services/self-hosted-subscription.service.ts b/web/ee/services/self-hosted-subscription.service.ts index 73bbfeb74e..6125a282a0 100644 --- a/web/ee/services/self-hosted-subscription.service.ts +++ b/web/ee/services/self-hosted-subscription.service.ts @@ -1,5 +1,6 @@ /* eslint-disable no-useless-catch */ // helpers +import { AxiosError } from "axios"; import { API_BASE_URL } from "@/helpers/common.helper"; // plane web types import { TSelfHostedSubscription } from "@/plane-web/types/self-hosted-subscription"; @@ -31,7 +32,7 @@ export class SelfHostedSubscriptionService extends APIService { * @param { { license_key: string } } payload * @returns { TSelfHostedSubscription | undefined } */ - async activateSubscription( + async activateUsingLicenseKey( workspaceSlug: string, payload: { license_key: string } ): Promise { @@ -39,7 +40,32 @@ export class SelfHostedSubscriptionService extends APIService { const { data } = await this.post(`/api/payments/workspaces/${workspaceSlug}/licenses/`, payload); return data || undefined; } catch (error) { - throw error; + if (error instanceof AxiosError) { + throw error.response?.data; + } + } + } + + /** + * @description activating workspace license using license file + * @param { string } workspaceSlug + * @param { File } file + * @returns { TSelfHostedSubscription | undefined } + */ + async activateUsingLicenseFile(workspaceSlug: string, file: File): Promise { + try { + const formData = new FormData(); + formData.append("license_file", file); + const { data } = await this.post(`/api/payments/workspaces/${workspaceSlug}/licenses/upload/`, formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + return data || undefined; + } catch (error) { + if (error instanceof AxiosError) { + throw error.response?.data; + } } } diff --git a/web/ee/store/subscription/self-hosted-subscription.store.ts b/web/ee/store/subscription/self-hosted-subscription.store.ts index 6410f525fe..1c1b8b150e 100644 --- a/web/ee/store/subscription/self-hosted-subscription.store.ts +++ b/web/ee/store/subscription/self-hosted-subscription.store.ts @@ -24,7 +24,8 @@ export interface ISelfHostedSubscriptionStore { toggleLicenseActivationModal: (isOpen?: boolean) => void; // actions fetchSubscription: (workspaceSlug: string) => Promise; - activateSubscription: (workspaceSlug: string, license_key: string) => Promise; + activateUsingLicenseKey: (workspaceSlug: string, license_key: string) => Promise; + activateUsingLicenseFile: (workspaceSlug: string, file: File) => Promise; syncLicense: (workspaceSlug: string) => Promise; deactivateLicense: (workspaceSlug: string) => Promise; } @@ -43,7 +44,8 @@ export class SelfHostedSubscriptionStore implements ISelfHostedSubscriptionStore toggleLicenseActivationModal: action, // actions fetchSubscription: action, - activateSubscription: action, + activateUsingLicenseKey: action, + activateUsingLicenseFile: action, syncLicense: action, deactivateLicense: action, }); @@ -88,13 +90,25 @@ export class SelfHostedSubscriptionStore implements ISelfHostedSubscriptionStore } }; + private processLicenseActivation = async (workspaceSlug: string, license: TSelfHostedSubscription | undefined) => { + await Promise.all([ + this.rootStore.workspaceSubscription.fetchWorkspaceSubscribedPlan(workspaceSlug), + this.rootStore.featureFlags.fetchFeatureFlags(workspaceSlug), + ]); + if (license) { + runInAction(() => { + set(this.licenses, workspaceSlug, license); + }); + } + }; + /** * @description update worklog * @param { string } workspaceSlug * @param { string } license_key * @returns { TWorklog | undefined } */ - activateSubscription = async ( + activateUsingLicenseKey = async ( workspaceSlug: string, license_key: string ): Promise => { @@ -104,16 +118,8 @@ export class SelfHostedSubscriptionStore implements ISelfHostedSubscriptionStore const payload = { license_key: license_key, }; - const license = await selfHostedSubscriptionService.activateSubscription(workspaceSlug, payload); - await Promise.all([ - this.rootStore.workspaceSubscription.fetchWorkspaceSubscribedPlan(workspaceSlug), - this.rootStore.featureFlags.fetchFeatureFlags(workspaceSlug), - ]); - if (license) { - runInAction(() => { - set(this.licenses, workspaceSlug, license); - }); - } + const license = await selfHostedSubscriptionService.activateUsingLicenseKey(workspaceSlug, payload); + await this.processLicenseActivation(workspaceSlug, license); return license; } catch (error) { console.error("worklog -> updateWorklogById -> error", error); @@ -121,6 +127,28 @@ export class SelfHostedSubscriptionStore implements ISelfHostedSubscriptionStore } }; + /** + * @description activate workspace license using license file + * @param { string } workspaceSlug + * @param { File } file + * @returns { TSelfHostedSubscription | undefined } + */ + activateUsingLicenseFile = async ( + workspaceSlug: string, + file: File + ): Promise => { + if (!workspaceSlug) return undefined; + + try { + const license = await selfHostedSubscriptionService.activateUsingLicenseFile(workspaceSlug, file); + await this.processLicenseActivation(workspaceSlug, license); + return license; + } catch (error) { + console.error("selfHostedSubscriptionService -> activateUsingLicenseFile -> error", error); + throw error; + } + }; + /** * @description sync license * @param { string } workspaceSlug diff --git a/web/ee/store/subscription/subscription.store.ts b/web/ee/store/subscription/subscription.store.ts index 2c4fcbb844..c091e7e1aa 100644 --- a/web/ee/store/subscription/subscription.store.ts +++ b/web/ee/store/subscription/subscription.store.ts @@ -2,7 +2,11 @@ import set from "lodash/set"; import { action, computed, makeObservable, observable, reaction, runInAction } from "mobx"; // plane imports -import { DEFAULT_ADD_WORKSPACE_SEATS_MODAL_DATA, EProductSubscriptionEnum } from "@plane/constants"; +import { + DEFAULT_ADD_WORKSPACE_SEATS_MODAL_DATA, + EProductSubscriptionEnum, + SUBSCRIPTION_WITH_SEATS_MANAGEMENT, +} from "@plane/constants"; import { IWorkspaceProductSubscription, TAddWorkspaceSeatsModal } from "@plane/types"; // services import { PaymentService } from "@/plane-web/services/payment.service"; @@ -25,6 +29,10 @@ export interface IWorkspaceSubscriptionStore { // computed currentWorkspaceSubscribedPlanDetail: IWorkspaceProductSubscription | undefined; currentWorkspaceSubscriptionAvailableSeats: number; + isSubscriptionManagementEnabled: boolean; + isSeatManagementEnabled: boolean; + // computed function + getIsInTrialPeriod: (checkForUpgrade: boolean) => boolean; // helper actions togglePaidPlanModal: (value?: boolean) => void; toggleAddWorkspaceSeatsModal: (value?: TAddWorkspaceSeatsModal) => void; @@ -59,6 +67,8 @@ export class WorkspaceSubscriptionStore implements IWorkspaceSubscriptionStore { // computed currentWorkspaceSubscribedPlanDetail: computed, currentWorkspaceSubscriptionAvailableSeats: computed, + isSubscriptionManagementEnabled: computed, + isSeatManagementEnabled: computed, // helper actions togglePaidPlanModal: action, handleSuccessModalToggle: action, @@ -118,6 +128,41 @@ export class WorkspaceSubscriptionStore implements IWorkspaceSubscriptionStore { } } + /** + * Get the subscription management enabled for the current workspace + * @returns boolean + */ + get isSubscriptionManagementEnabled() { + const subscriptionDetail = this.currentWorkspaceSubscribedPlanDetail; + if (!subscriptionDetail) return false; + if (subscriptionDetail.is_offline_payment || this.rootStore.instance.config?.is_airgapped) return false; + return true; + } + + /** + * Get the seats management enabled for the current workspace + * @returns boolean + */ + get isSeatManagementEnabled() { + const subscriptionDetail = this.currentWorkspaceSubscribedPlanDetail; + if (!subscriptionDetail) return false; + if (subscriptionDetail.is_offline_payment || this.rootStore.instance.config?.is_airgapped) return false; + return SUBSCRIPTION_WITH_SEATS_MANAGEMENT.includes(subscriptionDetail?.product); + } + + // --------------- Computed function --------------- + /** + * Get the in trial period for the current workspace + * @returns boolean + */ + getIsInTrialPeriod = (checkForUpgrade: boolean) => { + const subscriptionDetail = this.currentWorkspaceSubscribedPlanDetail; + if (!subscriptionDetail) return false; + if (subscriptionDetail.is_self_managed) return false; + if (checkForUpgrade) return subscriptionDetail.is_on_trial && !subscriptionDetail.has_upgraded; + return subscriptionDetail.is_on_trial; + }; + // --------------- Helper Actions --------------- /** * Toggles the paid plan modal