From eb53876af3718d422dcbdc96c1379dc7dd1f8e22 Mon Sep 17 00:00:00 2001 From: sriram veeraghanta Date: Sat, 18 Nov 2023 16:17:01 +0530 Subject: [PATCH] feat: Instance Registration and Configuration (#2793) * dev: remove default user * dev: initiate licensing * dev: remove migration file 0046 * feat: self hosted licensing initialize * dev: instance licenses * dev: change license response structure * dev: add default properties and issue mention migration * dev: reset migrations * dev: instance configuration * dev: instance configuration migration * dev: update instance configuration model to take null and empty values * dev: instance configuration variables * dev: set default values * dev: update instance configuration load * dev: email configuration settings moved to database * dev: instance configuration on instance bootup * dev: auto instance registration script * dev: instance admin * dev: enable instance configuration and instance admin roles * dev: instance owner fix * dev: instance configuration values * dev: fix instance permissions and serializer * dev: fix email senders * dev: remove deprecated variables * dev: fix current site domain registration * dev: update cors setup and local settings * dev: migrate instance registration and configuration to manage commands * dev: check email validity * dev: update script to use manage command * dev: default bucket creation script * dev: instance admin routes and initial set of screens * dev: admin api to check if the current user is admin * dev: instance admin unique constraints * dev: check magic link login * dev: fix email sending for ssl * dev: create instance activation route if the instance is not activated during startup * dev: removed DJANGO_SETTINGS_MODULE from environment files and deleted auto bucket create script * dev: environment configuration for backend * dev: fix access token variable error * feat: Instance Admin Panel: General Settings (#2792) --------- Co-authored-by: pablohashescobar Co-authored-by: Prateek Shourya --- .env.example | 15 +- Dockerfile | 2 - ENV_SETUP.md | 24 +- apiserver/.env.example | 27 +- apiserver/Dockerfile.api | 2 +- apiserver/bin/bucket_script.py | 83 ------ apiserver/bin/takeoff | 6 +- apiserver/bin/user_script.py | 28 -- apiserver/package.json | 4 + apiserver/plane/api/serializers/user.py | 5 +- apiserver/plane/api/urls/__init__.py | 8 +- apiserver/plane/api/urls/user.py | 9 + apiserver/plane/api/views/authentication.py | 2 +- apiserver/plane/api/views/base.py | 1 - apiserver/plane/api/views/config.py | 83 +++++- apiserver/plane/api/views/external.py | 29 ++- apiserver/plane/api/views/project.py | 2 +- apiserver/plane/api/views/user.py | 12 +- apiserver/plane/api/views/workspace.py | 2 +- .../plane/bgtasks/analytic_plot_export.py | 18 +- .../plane/bgtasks/email_verification_task.py | 19 +- .../plane/bgtasks/forgot_password_task.py | 17 +- .../plane/bgtasks/magic_link_code_task.py | 19 +- .../plane/bgtasks/project_invitation_task.py | 17 +- .../bgtasks/workspace_invitation_task.py | 36 ++- .../db/management/commands/create_bucket.py | 71 +++++ apiserver/plane/license/__init__.py | 0 apiserver/plane/license/api/__init__.py | 0 .../plane/license/api/permissions/__init__.py | 1 + .../plane/license/api/permissions/instance.py | 33 +++ .../plane/license/api/serializers/__init__.py | 1 + .../plane/license/api/serializers/instance.py | 42 +++ apiserver/plane/license/api/views/__init__.py | 6 + apiserver/plane/license/api/views/instance.py | 242 ++++++++++++++++++ apiserver/plane/license/apps.py | 5 + .../plane/license/management/__init__.py | 0 .../license/management/commands/__init__.py | 0 .../management/commands/configure_instance.py | 46 ++++ .../management/commands/register_instance.py | 104 ++++++++ .../plane/license/migrations/0001_initial.py | 83 ++++++ ...002_alter_instanceadmin_unique_together.py | 19 ++ .../plane/license/migrations/__init__.py | 0 apiserver/plane/license/models/__init__.py | 1 + apiserver/plane/license/models/instance.py | 73 ++++++ apiserver/plane/license/urls.py | 36 +++ apiserver/plane/license/utils/__init__.py | 0 .../plane/license/utils/instance_value.py | 6 + apiserver/plane/settings/common.py | 45 +--- apiserver/plane/settings/local.py | 8 - apiserver/plane/settings/production.py | 5 + apiserver/plane/urls.py | 4 +- apiserver/requirements/base.txt | 2 +- deploy/selfhost/docker-compose.yml | 15 +- deploy/selfhost/variables.env | 20 +- web/components/instance/general-form.tsx | 126 +++++++++ web/components/instance/help-section.tsx | 134 ++++++++++ web/components/instance/index.ts | 4 + web/components/instance/sidebar-dropdown.tsx | 148 +++++++++++ web/components/instance/sidebar-menu.tsx | 65 +++++ web/components/workspace/sidebar-dropdown.tsx | 15 +- web/layouts/admin-layout/header.tsx | 47 ++++ web/layouts/admin-layout/index.ts | 3 + web/layouts/admin-layout/layout.tsx | 32 +++ web/layouts/admin-layout/sidebar.tsx | 28 ++ web/layouts/auth-layout/user-wrapper.tsx | 6 +- web/pages/admin/ai.tsx | 16 ++ web/pages/admin/email.tsx | 16 ++ web/pages/admin/index.tsx | 28 ++ web/pages/admin/oauth.tsx | 16 ++ web/services/instance.service.ts | 37 +++ web/services/user.service.ts | 9 + web/services/workspace.service.ts | 6 +- web/store/instance/index.ts | 1 + web/store/instance/instance.store.ts | 111 ++++++++ web/store/root.ts | 5 + web/store/user.store.ts | 23 ++ web/types/instance.d.ts | 22 ++ web/types/users.d.ts | 4 + 78 files changed, 1950 insertions(+), 290 deletions(-) delete mode 100644 apiserver/bin/bucket_script.py delete mode 100644 apiserver/bin/user_script.py create mode 100644 apiserver/package.json create mode 100644 apiserver/plane/db/management/commands/create_bucket.py create mode 100644 apiserver/plane/license/__init__.py create mode 100644 apiserver/plane/license/api/__init__.py create mode 100644 apiserver/plane/license/api/permissions/__init__.py create mode 100644 apiserver/plane/license/api/permissions/instance.py create mode 100644 apiserver/plane/license/api/serializers/__init__.py create mode 100644 apiserver/plane/license/api/serializers/instance.py create mode 100644 apiserver/plane/license/api/views/__init__.py create mode 100644 apiserver/plane/license/api/views/instance.py create mode 100644 apiserver/plane/license/apps.py create mode 100644 apiserver/plane/license/management/__init__.py create mode 100644 apiserver/plane/license/management/commands/__init__.py create mode 100644 apiserver/plane/license/management/commands/configure_instance.py create mode 100644 apiserver/plane/license/management/commands/register_instance.py create mode 100644 apiserver/plane/license/migrations/0001_initial.py create mode 100644 apiserver/plane/license/migrations/0002_alter_instanceadmin_unique_together.py create mode 100644 apiserver/plane/license/migrations/__init__.py create mode 100644 apiserver/plane/license/models/__init__.py create mode 100644 apiserver/plane/license/models/instance.py create mode 100644 apiserver/plane/license/urls.py create mode 100644 apiserver/plane/license/utils/__init__.py create mode 100644 apiserver/plane/license/utils/instance_value.py create mode 100644 web/components/instance/general-form.tsx create mode 100644 web/components/instance/help-section.tsx create mode 100644 web/components/instance/index.ts create mode 100644 web/components/instance/sidebar-dropdown.tsx create mode 100644 web/components/instance/sidebar-menu.tsx create mode 100644 web/layouts/admin-layout/header.tsx create mode 100644 web/layouts/admin-layout/index.ts create mode 100644 web/layouts/admin-layout/layout.tsx create mode 100644 web/layouts/admin-layout/sidebar.tsx create mode 100644 web/pages/admin/ai.tsx create mode 100644 web/pages/admin/email.tsx create mode 100644 web/pages/admin/index.tsx create mode 100644 web/pages/admin/oauth.tsx create mode 100644 web/services/instance.service.ts create mode 100644 web/store/instance/index.ts create mode 100644 web/store/instance/instance.store.ts create mode 100644 web/types/instance.d.ts diff --git a/.env.example b/.env.example index b98adf171..90070de19 100644 --- a/.env.example +++ b/.env.example @@ -21,20 +21,15 @@ AWS_S3_BUCKET_NAME="uploads" FILE_SIZE_LIMIT=5242880 # GPT settings -OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint -OPENAI_API_KEY="sk-" # add your openai key here -GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access +OPENAI_API_BASE="https://api.openai.com/v1" # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated # Settings related to Docker -DOCKERIZED=1 +DOCKERIZED=1 # deprecated + # set to 1 If using the pre-configured minio setup USE_MINIO=1 # Nginx Configuration NGINX_PORT=80 - -# Set it to 0, to disable it -ENABLE_WEBHOOK=1 - -# Set it to 0, to disable it -ENABLE_API=1 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 388c5a4ef..0e5d2f118 100644 --- a/Dockerfile +++ b/Dockerfile @@ -43,8 +43,6 @@ FROM python:3.11.1-alpine3.17 AS backend ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONUNBUFFERED 1 ENV PIP_DISABLE_PIP_VERSION_CHECK=1 -ENV DJANGO_SETTINGS_MODULE plane.settings.production -ENV DOCKERIZED 1 WORKDIR /code diff --git a/ENV_SETUP.md b/ENV_SETUP.md index 23faf83f7..f1cc7cb1e 100644 --- a/ENV_SETUP.md +++ b/ENV_SETUP.md @@ -31,12 +31,10 @@ AWS_S3_BUCKET_NAME="uploads" FILE_SIZE_LIMIT=5242880 ​ # GPT settings -OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint -OPENAI_API_KEY="sk-" # add your openai key here -GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access +OPENAI_API_BASE="https://api.openai.com/v1" # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated ​ -# Settings related to Docker -DOCKERIZED=1 # set to 1 If using the pre-configured minio setup USE_MINIO=1 ​ @@ -78,7 +76,7 @@ NEXT_PUBLIC_ENABLE_OAUTH=0 # Backend # Debug value for api server use it as 0 for production use DEBUG=0 -DJANGO_SETTINGS_MODULE="plane.settings.selfhosted" +DJANGO_SETTINGS_MODULE="plane.settings.selfhosted" # deprecated ​ # Error logs SENTRY_DSN="" @@ -115,24 +113,22 @@ AWS_S3_BUCKET_NAME="uploads" FILE_SIZE_LIMIT=5242880 ​ # GPT settings -OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint -OPENAI_API_KEY="sk-" # add your openai key here -GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access +OPENAI_API_BASE="https://api.openai.com/v1" # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated ​ +# Settings related to Docker +DOCKERIZED=1 # Deprecated + # Github GITHUB_CLIENT_SECRET="" # For fetching release notes ​ -# Settings related to Docker -DOCKERIZED=1 # set to 1 If using the pre-configured minio setup USE_MINIO=1 ​ # Nginx Configuration NGINX_PORT=80 ​ -# Default Creds -DEFAULT_EMAIL="captain@plane.so" -DEFAULT_PASSWORD="password123" ​ # SignUps ENABLE_SIGNUP="1" diff --git a/apiserver/.env.example b/apiserver/.env.example index d0b4013a8..2078fc94a 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -1,7 +1,8 @@ # Backend # Debug value for api server use it as 0 for production use DEBUG=0 -CORS_ALLOWED_ORIGINS="http://localhost" +CORS_ALLOWED_ORIGINS="" +ENVIRONMENT="development" # Error logs SENTRY_DSN="" @@ -18,15 +19,6 @@ REDIS_HOST="plane-redis" REDIS_PORT="6379" REDIS_URL="redis://${REDIS_HOST}:6379/" -# Email Settings -EMAIL_HOST="" -EMAIL_HOST_USER="" -EMAIL_HOST_PASSWORD="" -EMAIL_PORT=587 -EMAIL_FROM="Team Plane " -EMAIL_USE_TLS="1" -EMAIL_USE_SSL="0" - # AWS Settings AWS_REGION="" AWS_ACCESS_KEY_ID="access-key" @@ -38,9 +30,9 @@ AWS_S3_BUCKET_NAME="uploads" FILE_SIZE_LIMIT=5242880 # GPT settings -OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint -OPENAI_API_KEY="sk-" # add your openai key here -GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access +OPENAI_API_BASE="https://api.openai.com/v1" # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated # Github GITHUB_CLIENT_SECRET="" # For fetching release notes @@ -53,9 +45,6 @@ USE_MINIO=1 # Nginx Configuration NGINX_PORT=80 -# Default Creds -DEFAULT_EMAIL="captain@plane.so" -DEFAULT_PASSWORD="password123" # SignUps ENABLE_SIGNUP="1" @@ -70,12 +59,6 @@ ENABLE_MAGIC_LINK_LOGIN="0" # Email redirections and minio domain settings WEB_URL="http://localhost" -# Set it to 0, to disable it -ENABLE_WEBHOOK=1 - -# Set it to 0, to disable it -ENABLE_API=1 - # Gunicorn Workers GUNICORN_WORKERS=2 diff --git a/apiserver/Dockerfile.api b/apiserver/Dockerfile.api index 15c3f53a9..a2ce4a7b2 100644 --- a/apiserver/Dockerfile.api +++ b/apiserver/Dockerfile.api @@ -43,7 +43,7 @@ USER captain COPY manage.py manage.py COPY plane plane/ COPY templates templates/ - +COPY package.json package.json COPY gunicorn.config.py ./ USER root RUN apk --no-cache add "bash~=5.2" diff --git a/apiserver/bin/bucket_script.py b/apiserver/bin/bucket_script.py deleted file mode 100644 index 89717d527..000000000 --- a/apiserver/bin/bucket_script.py +++ /dev/null @@ -1,83 +0,0 @@ -import os, sys -import boto3 -import json -from botocore.exceptions import ClientError - - -sys.path.append("/code") - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") -import django - -django.setup() - -def set_bucket_public_policy(s3_client, bucket_name): - public_policy = { - "Version": "2012-10-17", - "Statement": [{ - "Effect": "Allow", - "Principal": "*", - "Action": ["s3:GetObject"], - "Resource": [f"arn:aws:s3:::{bucket_name}/*"] - }] - } - - try: - s3_client.put_bucket_policy( - Bucket=bucket_name, - Policy=json.dumps(public_policy) - ) - print(f"Public read access policy set for bucket '{bucket_name}'.") - except ClientError as e: - print(f"Error setting public read access policy: {e}") - - - -def create_bucket(): - try: - from django.conf import settings - - # Create a session using the credentials from Django settings - session = boto3.session.Session( - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - ) - - # Create an S3 client using the session - s3_client = session.client('s3', endpoint_url=settings.AWS_S3_ENDPOINT_URL) - bucket_name = settings.AWS_STORAGE_BUCKET_NAME - - print("Checking bucket...") - - # Check if the bucket exists - s3_client.head_bucket(Bucket=bucket_name) - - # If head_bucket does not raise an exception, the bucket exists - print(f"Bucket '{bucket_name}' already exists.") - - set_bucket_public_policy(s3_client, bucket_name) - - except ClientError as e: - error_code = int(e.response['Error']['Code']) - bucket_name = settings.AWS_STORAGE_BUCKET_NAME - if error_code == 404: - # Bucket does not exist, create it - print(f"Bucket '{bucket_name}' does not exist. Creating bucket...") - try: - s3_client.create_bucket(Bucket=bucket_name) - print(f"Bucket '{bucket_name}' created successfully.") - set_bucket_public_policy(s3_client, bucket_name) - except ClientError as create_error: - print(f"Failed to create bucket: {create_error}") - elif error_code == 403: - # Access to the bucket is forbidden - print(f"Access to the bucket '{bucket_name}' is forbidden. Check permissions.") - else: - # Another ClientError occurred - print(f"Failed to check bucket: {e}") - except Exception as ex: - # Handle any other exception - print(f"An error occurred: {ex}") - -if __name__ == "__main__": - create_bucket() diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index 74980dd62..44f251155 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -3,8 +3,10 @@ set -e python manage.py wait_for_db python manage.py migrate -# Create a Default User -python bin/user_script.py +# Register instance +python manage.py register_instance +# Load the configuration variable +python manage.py configure_instance # Create the default bucket python bin/bucket_script.py diff --git a/apiserver/bin/user_script.py b/apiserver/bin/user_script.py deleted file mode 100644 index a356f2ec9..000000000 --- a/apiserver/bin/user_script.py +++ /dev/null @@ -1,28 +0,0 @@ -import os, sys -import uuid - -sys.path.append("/code") - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "plane.settings.production") -import django - -django.setup() - -from plane.db.models import User - - -def populate(): - default_email = os.environ.get("DEFAULT_EMAIL", "captain@plane.so") - default_password = os.environ.get("DEFAULT_PASSWORD", "password123") - - if not User.objects.filter(email=default_email).exists(): - user = User.objects.create(email=default_email, username=uuid.uuid4().hex) - user.set_password(default_password) - user.save() - print(f"User created with an email: {default_email}") - else: - print(f"User already exists with the default email: {default_email}") - - -if __name__ == "__main__": - populate() diff --git a/apiserver/package.json b/apiserver/package.json new file mode 100644 index 000000000..c622ae496 --- /dev/null +++ b/apiserver/package.json @@ -0,0 +1,4 @@ +{ + "name": "plane-api", + "version": "0.13.2" +} \ No newline at end of file diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py index b8f9dedd4..687993fcc 100644 --- a/apiserver/plane/api/serializers/user.py +++ b/apiserver/plane/api/serializers/user.py @@ -4,6 +4,7 @@ from rest_framework import serializers # Module import from .base import BaseSerializer from plane.db.models import User, Workspace, WorkspaceMemberInvite +from plane.license.models import InstanceAdmin, Instance class UserSerializer(BaseSerializer): @@ -86,7 +87,9 @@ class UserMeSettingsSerializer(BaseSerializer): "last_workspace_id": obj.last_workspace_id, "last_workspace_slug": workspace.slug if workspace is not None else "", "fallback_workspace_id": obj.last_workspace_id, - "fallback_workspace_slug": workspace.slug if workspace is not None else "", + "fallback_workspace_slug": workspace.slug + if workspace is not None + else "", "invites": workspace_invites, } else: diff --git a/apiserver/plane/api/urls/__init__.py b/apiserver/plane/api/urls/__init__.py index 1e3c1cbca..e6088cb14 100644 --- a/apiserver/plane/api/urls/__init__.py +++ b/apiserver/plane/api/urls/__init__.py @@ -49,10 +49,6 @@ urlpatterns = [ *user_urls, *view_urls, *workspace_urls, + *api_urls, + *webhook_urls, ] - -if settings.ENABLE_WEBHOOK: - urlpatterns += webhook_urls - -if settings.ENABLE_API: - urlpatterns += api_urls diff --git a/apiserver/plane/api/urls/user.py b/apiserver/plane/api/urls/user.py index 00f95cd42..da794d59a 100644 --- a/apiserver/plane/api/urls/user.py +++ b/apiserver/plane/api/urls/user.py @@ -38,6 +38,15 @@ urlpatterns = [ ), name="users", ), + path( + "users/me/instance-admin/", + UserEndpoint.as_view( + { + "get": "retrieve_instance_admin", + } + ), + name="users", + ), path( "users/me/change-password/", ChangePasswordEndpoint.as_view(), diff --git a/apiserver/plane/api/views/authentication.py b/apiserver/plane/api/views/authentication.py index fe7b4c473..2ec241303 100644 --- a/apiserver/plane/api/views/authentication.py +++ b/apiserver/plane/api/views/authentication.py @@ -320,11 +320,11 @@ class SignInEndpoint(BaseAPIView): except RequestException as e: capture_exception(e) + access_token, refresh_token = get_tokens_for_user(user) data = { "access_token": access_token, "refresh_token": refresh_token, } - access_token, refresh_token = get_tokens_for_user(user) return Response(data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/base.py b/apiserver/plane/api/views/base.py index 71f9c1842..de7bafd57 100644 --- a/apiserver/plane/api/views/base.py +++ b/apiserver/plane/api/views/base.py @@ -51,7 +51,6 @@ class WebhookMixin: self.webhook_event and self.request.method in ["POST", "PATCH", "DELETE"] and response.status_code in [200, 201, 204] - and settings.ENABLE_WEBHOOK ): send_webhook.delay( event=self.webhook_event, diff --git a/apiserver/plane/api/views/config.py b/apiserver/plane/api/views/config.py index d035c4740..237d8d6bf 100644 --- a/apiserver/plane/api/views/config.py +++ b/apiserver/plane/api/views/config.py @@ -12,6 +12,8 @@ from sentry_sdk import capture_exception # Module imports from .base import BaseAPIView +from plane.license.models import Instance, InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value class ConfigurationEndpoint(BaseAPIView): @@ -20,18 +22,75 @@ class ConfigurationEndpoint(BaseAPIView): ] def get(self, request): + instance_configuration = InstanceConfiguration.objects.values("key", "value") + data = {} - data["google_client_id"] = os.environ.get("GOOGLE_CLIENT_ID", None) - data["github_client_id"] = os.environ.get("GITHUB_CLIENT_ID", None) - data["github_app_name"] = os.environ.get("GITHUB_APP_NAME", None) - data["magic_login"] = ( - bool(settings.EMAIL_HOST_USER) and bool(settings.EMAIL_HOST_PASSWORD) - ) and os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0") == "1" - data["email_password_login"] = ( - os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1" + # Authentication + data["google_client_id"] = get_configuration_value( + instance_configuration, + "GOOGLE_CLIENT_ID", + os.environ.get("GOOGLE_CLIENT_ID", None), ) - data["slack_client_id"] = os.environ.get("SLACK_CLIENT_ID", None) - data["posthog_api_key"] = os.environ.get("POSTHOG_API_KEY", None) - data["posthog_host"] = os.environ.get("POSTHOG_HOST", None) - data["has_unsplash_configured"] = bool(settings.UNSPLASH_ACCESS_KEY) + data["github_client_id"] = get_configuration_value( + instance_configuration, + "GITHUB_CLIENT_ID", + os.environ.get("GITHUB_CLIENT_ID", None), + ) + data["github_app_name"] = get_configuration_value( + instance_configuration, + "GITHUB_APP_NAME", + os.environ.get("GITHUB_APP_NAME", None), + ) + data["magic_login"] = ( + bool( + get_configuration_value( + instance_configuration, + "EMAIL_HOST_USER", + os.environ.get("GITHUB_APP_NAME", None), + ), + ) + and bool( + get_configuration_value( + instance_configuration, + "EMAIL_HOST_PASSWORD", + os.environ.get("GITHUB_APP_NAME", None), + ) + ) + ) and get_configuration_value( + instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", "0" + ) == "1" + data["email_password_login"] = ( + get_configuration_value( + instance_configuration, "ENABLE_EMAIL_PASSWORD", "0" + ) + == "1" + ) + # Slack client + data["slack_client_id"] = get_configuration_value( + instance_configuration, + "SLACK_CLIENT_ID", + os.environ.get("SLACK_CLIENT_ID", None), + ) + + # Posthog + data["posthog_api_key"] = get_configuration_value( + instance_configuration, + "POSTHOG_API_KEY", + os.environ.get("POSTHOG_API_KEY", None), + ) + data["posthog_host"] = get_configuration_value( + instance_configuration, + "POSTHOG_HOST", + os.environ.get("POSTHOG_HOST", None), + ) + + # Unsplash + data["has_unsplash_configured"] = bool( + get_configuration_value( + instance_configuration, + "UNSPLASH_ACCESS_KEY", + os.environ.get("UNSPLASH_ACCESS_KEY", None), + ) + ) + return Response(data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/external.py b/apiserver/plane/api/views/external.py index a04495569..1953743a2 100644 --- a/apiserver/plane/api/views/external.py +++ b/apiserver/plane/api/views/external.py @@ -2,7 +2,7 @@ import requests # Third party imports -import openai +from openai import OpenAI from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import AllowAny @@ -17,7 +17,8 @@ from plane.api.permissions import ProjectEntityPermission from plane.db.models import Workspace, Project from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer from plane.utils.integrations.github import get_release_notes - +from plane.license.models import InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value class GPTIntegrationEndpoint(BaseAPIView): permission_classes = [ @@ -25,7 +26,14 @@ class GPTIntegrationEndpoint(BaseAPIView): ] def post(self, request, slug, project_id): - if not settings.OPENAI_API_KEY or not settings.GPT_ENGINE: + + # Get the configuration value + instance_configuration = InstanceConfiguration.objects.values("key", "value") + api_key = get_configuration_value(instance_configuration, "OPENAI_API_KEY") + gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE") + + # Check the keys + if not api_key or not gpt_engine: return Response( {"error": "OpenAI API key and engine is required"}, status=status.HTTP_400_BAD_REQUEST, @@ -41,12 +49,17 @@ class GPTIntegrationEndpoint(BaseAPIView): final_text = task + "\n" + prompt - openai.api_key = settings.OPENAI_API_KEY - response = openai.ChatCompletion.create( - model=settings.GPT_ENGINE, + instance_configuration = InstanceConfiguration.objects.values("key", "value") + + gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE") + + client = OpenAI( + api_key=api_key, + ) + + response = client.chat.completions.create( + model=gpt_engine, messages=[{"role": "user", "content": final_text}], - temperature=0.7, - max_tokens=1024, ) workspace = Workspace.objects.get(slug=slug) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 08c7fee4d..ce7750105 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -427,7 +427,7 @@ class ProjectInvitationsViewset(BaseViewSet): project_invitations = ProjectMemberInvite.objects.bulk_create( project_invitations, batch_size=10, ignore_conflicts=True ) - current_site = f"{request.scheme}://{request.get_host()}", + current_site = request.META.get('HTTP_ORIGIN') # Send invitations for invitation in project_invitations: diff --git a/apiserver/plane/api/views/user.py b/apiserver/plane/api/views/user.py index 9b488489a..e6e742a63 100644 --- a/apiserver/plane/api/views/user.py +++ b/apiserver/plane/api/views/user.py @@ -14,6 +14,7 @@ from plane.api.serializers import ( from plane.api.views.base import BaseViewSet, BaseAPIView from plane.db.models import User, IssueActivity, WorkspaceMember +from plane.license.models import Instance, InstanceAdmin from plane.utils.paginator import BasePaginator @@ -35,12 +36,17 @@ class UserEndpoint(BaseViewSet): serialized_data = UserMeSettingsSerializer(request.user).data return Response(serialized_data, status=status.HTTP_200_OK) + def retrieve_instance_admin(self, request): + instance = Instance.objects.first() + is_admin = InstanceAdmin.objects.filter( + instance=instance, user=request.user + ).exists() + return Response({"is_instance_admin": is_admin}, status=status.HTTP_200_OK) + def deactivate(self, request): # Check all workspace user is active user = self.get_object() - if WorkspaceMember.objects.filter( - member=request.user, is_active=True - ).exists(): + if WorkspaceMember.objects.filter(member=request.user, is_active=True).exists(): return Response( { "error": "User cannot deactivate account as user is active in some workspaces" diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 3fc9b7bde..8804d48ef 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -319,7 +319,7 @@ class WorkspaceInvitationsViewset(BaseViewSet): workspace_invitations, batch_size=10, ignore_conflicts=True ) - current_site = f"{request.scheme}://{request.get_host()}", + current_site = request.META.get('HTTP_ORIGIN') # Send invitations for invitation in workspace_invitations: diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index a80770c37..8cccc2299 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -3,7 +3,7 @@ import csv import io # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -16,6 +16,8 @@ from sentry_sdk import capture_exception from plane.db.models import Issue from plane.utils.analytics_plot import build_graph_plot from plane.utils.issue_filters import issue_filters +from plane.license.models import InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value row_mapping = { "state__name": "State", @@ -47,7 +49,19 @@ def send_export_email(email, slug, csv_buffer): text_content = strip_tags(html_content) csv_buffer.seek(0) - msg = EmailMultiAlternatives(subject, text_content, settings.EMAIL_FROM, [email]) + + # Configure email connection from the database + instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") + connection = get_connection( + host=get_configuration_value(instance_configuration, "EMAIL_HOST"), + port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), + username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), + password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), + use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), + use_ssl=bool(get_configuration_value(instance_configuration, "EMAIL_USE_SSL", "0")), + ) + + msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue()) msg.send(fail_silently=False) diff --git a/apiserver/plane/bgtasks/email_verification_task.py b/apiserver/plane/bgtasks/email_verification_task.py index 9f9d06437..ba4ce6490 100644 --- a/apiserver/plane/bgtasks/email_verification_task.py +++ b/apiserver/plane/bgtasks/email_verification_task.py @@ -1,5 +1,5 @@ # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -11,8 +11,8 @@ from celery import shared_task from sentry_sdk import capture_exception # Module imports -from plane.db.models import User - +from plane.license.models import InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value @shared_task def email_verification(first_name, email, token, current_site): @@ -34,7 +34,18 @@ def email_verification(first_name, email, token, current_site): text_content = strip_tags(html_content) - msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + # Configure email connection from the database + instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") + connection = get_connection( + host=get_configuration_value(instance_configuration, "EMAIL_HOST"), + port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), + username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), + password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), + use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), + ) + + # Initiate email alternatives + msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) msg.attach_alternative(html_content, "text/html") msg.send() return diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index de1390f01..b924ad3a2 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -1,5 +1,5 @@ # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -8,7 +8,9 @@ from django.conf import settings from celery import shared_task from sentry_sdk import capture_exception - +# Module imports +from plane.license.models import InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value @shared_task def forgot_password(first_name, email, uidb64, token, current_site): @@ -30,7 +32,16 @@ def forgot_password(first_name, email, uidb64, token, current_site): text_content = strip_tags(html_content) - msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") + connection = get_connection( + host=get_configuration_value(instance_configuration, "EMAIL_HOST"), + port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), + username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), + password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), + use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), + ) + # Initiate email alternatives + msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) msg.attach_alternative(html_content, "text/html") msg.send() return diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 71f6db8da..372cafa6e 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -1,5 +1,5 @@ # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -8,6 +8,9 @@ from django.conf import settings from celery import shared_task from sentry_sdk import capture_exception +# Module imports +from plane.license.models import InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value @shared_task def magic_link(email, key, token, current_site): @@ -15,8 +18,6 @@ def magic_link(email, key, token, current_site): realtivelink = f"/magic-sign-in/?password={token}&key={key}" abs_url = current_site + realtivelink - from_email_string = settings.EMAIL_FROM - subject = "Login for Plane" context = {"magic_url": abs_url, "code": token} @@ -25,7 +26,17 @@ def magic_link(email, key, token, current_site): text_content = strip_tags(html_content) - msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") + connection = get_connection( + host=get_configuration_value(instance_configuration, "EMAIL_HOST"), + port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), + username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), + password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), + use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), + ) + + # Initiate email alternatives + msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) msg.attach_alternative(html_content, "text/html") msg.send() return diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index 41f6da3ca..311ccec0a 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -1,5 +1,5 @@ # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -10,7 +10,8 @@ from sentry_sdk import capture_exception # Module imports from plane.db.models import Project, User, ProjectMemberInvite - +from plane.license.models import InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value @shared_task def project_invitation(email, project_id, token, current_site, invitor): @@ -44,7 +45,17 @@ def project_invitation(email, project_id, token, current_site, invitor): project_member_invite.message = text_content project_member_invite.save() - msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + # Configure email connection from the database + instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") + connection = get_connection( + host=get_configuration_value(instance_configuration, "EMAIL_HOST"), + port=int(get_configuration_value(instance_configuration, "EMAIL_PORT", "587")), + username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), + password=get_configuration_value(instance_configuration, "EMAIL_HOST_PASSWORD"), + use_tls=bool(get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1")), + ) + # Initiate email alternatives + msg = EmailMultiAlternatives(subject=subject, body=text_content, from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), to=[email], connection=connection) msg.attach_alternative(html_content, "text/html") msg.send() return diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index fca34a84d..7be1dbf60 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -1,5 +1,5 @@ # Django imports -from django.core.mail import EmailMultiAlternatives +from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags from django.conf import settings @@ -11,13 +11,14 @@ from slack_sdk import WebClient from slack_sdk.errors import SlackApiError # Module imports -from plane.db.models import User, Workspace, WorkspaceMemberInvite +from plane.db.models import Workspace, WorkspaceMemberInvite, User +from plane.license.models import InstanceConfiguration +from plane.license.utils.instance_value import get_configuration_value @shared_task def workspace_invitation(email, workspace_id, token, current_site, invitor): try: - user = User.objects.get(email=invitor) workspace = Workspace.objects.get(pk=workspace_id) @@ -26,9 +27,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): ) # Relative link - relative_link = ( - f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" - ) + relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" # The complete url including the domain abs_url = current_site + relative_link @@ -55,7 +54,30 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): workspace_member_invite.message = text_content workspace_member_invite.save() - msg = EmailMultiAlternatives(subject, text_content, from_email_string, [email]) + instance_configuration = InstanceConfiguration.objects.filter( + key__startswith="EMAIL_" + ).values("key", "value") + connection = get_connection( + host=get_configuration_value(instance_configuration, "EMAIL_HOST"), + port=int( + get_configuration_value(instance_configuration, "EMAIL_PORT", "587") + ), + username=get_configuration_value(instance_configuration, "EMAIL_HOST_USER"), + password=get_configuration_value( + instance_configuration, "EMAIL_HOST_PASSWORD" + ), + use_tls=bool( + get_configuration_value(instance_configuration, "EMAIL_USE_TLS", "1") + ), + ) + # Initiate email alternatives + msg = EmailMultiAlternatives( + subject=subject, + body=text_content, + from_email=get_configuration_value(instance_configuration, "EMAIL_FROM"), + to=[email], + connection=connection, + ) msg.attach_alternative(html_content, "text/html") msg.send() diff --git a/apiserver/plane/db/management/commands/create_bucket.py b/apiserver/plane/db/management/commands/create_bucket.py new file mode 100644 index 000000000..054523bf9 --- /dev/null +++ b/apiserver/plane/db/management/commands/create_bucket.py @@ -0,0 +1,71 @@ +# Python imports +import boto3 +import json +from botocore.exceptions import ClientError + +# Django imports +from django.core.management import BaseCommand +from django.conf import settings + +class Command(BaseCommand): + help = "Create the default bucket for the instance" + + def set_bucket_public_policy(self, s3_client, bucket_name): + public_policy = { + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": "*", + "Action": ["s3:GetObject"], + "Resource": [f"arn:aws:s3:::{bucket_name}/*"] + }] + } + + try: + s3_client.put_bucket_policy( + Bucket=bucket_name, + Policy=json.dumps(public_policy) + ) + self.stdout.write(self.style.SUCCESS(f"Public read access policy set for bucket '{bucket_name}'.")) + except ClientError as e: + self.stdout.write(self.style.ERROR(f"Error setting public read access policy: {e}")) + + + def handle(self, *args, **options): + # Create a session using the credentials from Django settings + try: + session = boto3.session.Session( + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + ) + # Create an S3 client using the session + s3_client = session.client('s3', endpoint_url=settings.AWS_S3_ENDPOINT_URL) + bucket_name = settings.AWS_STORAGE_BUCKET_NAME + + self.stdout.write(self.style.NOTICE("Checking bucket...")) + + # Check if the bucket exists + s3_client.head_bucket(Bucket=bucket_name) + + self.set_bucket_public_policy(s3_client, bucket_name) + except ClientError as e: + error_code = int(e.response['Error']['Code']) + bucket_name = settings.AWS_STORAGE_BUCKET_NAME + if error_code == 404: + # Bucket does not exist, create it + self.stdout.write(self.style.WARNING(f"Bucket '{bucket_name}' does not exist. Creating bucket...")) + try: + s3_client.create_bucket(Bucket=bucket_name) + self.stdout.write(self.style.SUCCESS(f"Bucket '{bucket_name}' created successfully.")) + self.set_bucket_public_policy(s3_client, bucket_name) + except ClientError as create_error: + self.stdout.write(self.style.ERROR(f"Failed to create bucket: {create_error}")) + elif error_code == 403: + # Access to the bucket is forbidden + self.stdout.write(self.style.ERROR(f"Access to the bucket '{bucket_name}' is forbidden. Check permissions.")) + else: + # Another ClientError occurred + self.stdout.write(self.style.ERROR(f"Failed to check bucket: {e}")) + except Exception as ex: + # Handle any other exception + self.stdout.write(self.style.ERROR(f"An error occurred: {ex}")) \ No newline at end of file diff --git a/apiserver/plane/license/__init__.py b/apiserver/plane/license/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/api/__init__.py b/apiserver/plane/license/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/api/permissions/__init__.py b/apiserver/plane/license/api/permissions/__init__.py new file mode 100644 index 000000000..392b228c0 --- /dev/null +++ b/apiserver/plane/license/api/permissions/__init__.py @@ -0,0 +1 @@ +from .instance import InstanceOwnerPermission, InstanceAdminPermission diff --git a/apiserver/plane/license/api/permissions/instance.py b/apiserver/plane/license/api/permissions/instance.py new file mode 100644 index 000000000..1d1845f12 --- /dev/null +++ b/apiserver/plane/license/api/permissions/instance.py @@ -0,0 +1,33 @@ +# Third party imports +from rest_framework.permissions import BasePermission + +# Module imports +from plane.license.models import Instance, InstanceAdmin + + +class InstanceOwnerPermission(BasePermission): + def has_permission(self, request, view): + + if request.user.is_anonymous: + return False + + instance = Instance.objects.first() + return InstanceAdmin.objects.filter( + role=20, + instance=instance, + user=request.user, + ).exists() + + +class InstanceAdminPermission(BasePermission): + def has_permission(self, request, view): + + if request.user.is_anonymous: + return False + + instance = Instance.objects.first() + return InstanceAdmin.objects.filter( + role__gte=15, + instance=instance, + user=request.user, + ).exists() diff --git a/apiserver/plane/license/api/serializers/__init__.py b/apiserver/plane/license/api/serializers/__init__.py new file mode 100644 index 000000000..b658ff148 --- /dev/null +++ b/apiserver/plane/license/api/serializers/__init__.py @@ -0,0 +1 @@ +from .instance import InstanceSerializer, InstanceAdminSerializer, InstanceConfigurationSerializer \ No newline at end of file diff --git a/apiserver/plane/license/api/serializers/instance.py b/apiserver/plane/license/api/serializers/instance.py new file mode 100644 index 000000000..b8c990522 --- /dev/null +++ b/apiserver/plane/license/api/serializers/instance.py @@ -0,0 +1,42 @@ +# Module imports +from plane.license.models import Instance, InstanceAdmin, InstanceConfiguration +from plane.api.serializers import BaseSerializer +from plane.api.serializers import UserAdminLiteSerializer + + +class InstanceSerializer(BaseSerializer): + primary_owner_details = UserAdminLiteSerializer(source="primary_owner", read_only=True) + + class Meta: + model = Instance + fields = "__all__" + read_only_fields = [ + "id", + "primary_owner", + "primary_email", + "instance_id", + "license_key", + "api_key", + "version", + "email", + "last_checked_at", + ] + + +class InstanceAdminSerializer(BaseSerializer): + user_detail = UserAdminLiteSerializer(source="user", read_only=True) + + class Meta: + model = InstanceAdmin + fields = "__all__" + read_only_fields = [ + "id", + "instance", + "user", + ] + +class InstanceConfigurationSerializer(BaseSerializer): + + class Meta: + model = InstanceConfiguration + fields = "__all__" diff --git a/apiserver/plane/license/api/views/__init__.py b/apiserver/plane/license/api/views/__init__.py new file mode 100644 index 000000000..4b925759a --- /dev/null +++ b/apiserver/plane/license/api/views/__init__.py @@ -0,0 +1,6 @@ +from .instance import ( + InstanceEndpoint, + TransferPrimaryOwnerEndpoint, + InstanceAdminEndpoint, + InstanceConfigurationEndpoint, +) diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py new file mode 100644 index 000000000..309b2b9da --- /dev/null +++ b/apiserver/plane/license/api/views/instance.py @@ -0,0 +1,242 @@ +# Python imports +import json +import os +import requests + +# Django imports +from django.utils import timezone + +# Third party imports +from rest_framework import status +from rest_framework.response import Response + +# Module imports +from plane.api.views import BaseAPIView +from plane.license.models import Instance, InstanceAdmin, InstanceConfiguration +from plane.license.api.serializers import ( + InstanceSerializer, + InstanceAdminSerializer, + InstanceConfigurationSerializer, +) +from plane.license.api.permissions import ( + InstanceOwnerPermission, + InstanceAdminPermission, +) +from plane.db.models import User + + +class InstanceEndpoint(BaseAPIView): + def get_permissions(self): + if self.request.method in ["POST", "PATCH"]: + self.permission_classes = [ + InstanceOwnerPermission, + ] + else: + self.permission_classes = [ + InstanceAdminPermission, + ] + return super(InstanceEndpoint, self).get_permissions() + + def post(self, request): + # Check if the instance is registered + instance = Instance.objects.first() + + # If instance is None then register this instance + if instance is None: + with open("package.json", "r") as file: + # Load JSON content from the file + data = json.load(file) + + license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL") + + if not license_engine_base_url: + raise Response( + {"error": "LICENSE_ENGINE_BASE_URL is required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + headers = {"Content-Type": "application/json"} + + payload = { + "email": request.user.email, + "version": data.get("version", 0.1), + } + + response = requests.post( + f"{license_engine_base_url}/api/instances", + headers=headers, + data=json.dumps(payload), + ) + + if response.status_code == 201: + data = response.json() + # Create instance + instance = Instance.objects.create( + instance_name="Plane Free", + instance_id=data.get("id"), + license_key=data.get("license_key"), + api_key=data.get("api_key"), + version=data.get("version"), + primary_email=data.get("email"), + primary_owner=request.user, + last_checked_at=timezone.now(), + ) + # Create instance admin + _ = InstanceAdmin.objects.create( + user=request.user, + instance=instance, + role=20, + ) + + return Response( + { + "message": f"Instance succesfully registered with owner: {instance.primary_owner.email}" + }, + status=status.HTTP_201_CREATED, + ) + return Response( + {"error": "Instance could not be registered"}, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return Response( + { + "message": f"Instance already registered with instance owner: {instance.primary_owner.email}" + }, + status=status.HTTP_200_OK, + ) + + def get(self, request): + instance = Instance.objects.first() + # get the instance + if instance is None: + return Response({"activated": False}, status=status.HTTP_400_BAD_REQUEST) + # Return instance + serializer = InstanceSerializer(instance) + serializer.data["activated"] = True + return Response(serializer.data, status=status.HTTP_200_OK) + + def patch(self, request): + # Get the instance + instance = Instance.objects.first() + serializer = InstanceSerializer(instance, data=request.data, partial=True) + if serializer.is_valid(): + serializer.save() + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class TransferPrimaryOwnerEndpoint(BaseAPIView): + permission_classes = [ + InstanceOwnerPermission, + ] + + # Transfer the owner of the instance + def post(self, request): + instance = Instance.objects.first() + + # Get the email of the new user + email = request.data.get("email", False) + if not email: + return Response( + {"error": "User is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + # Get users + user = User.objects.get(email=email) + + # Save the instance user + instance.primary_owner = user + instance.primary_email = user.email + instance.save(update_fields=["owner", "email"]) + + # Add the user to admin + _ = InstanceAdmin.objects.get_or_create( + instance=instance, + user=user, + role=20, + ) + + return Response( + {"message": "Owner successfully updated"}, status=status.HTTP_200_OK + ) + + +class InstanceAdminEndpoint(BaseAPIView): + def get_permissions(self): + if self.request.method in ["POST", "DELETE"]: + self.permission_classes = [ + InstanceOwnerPermission, + ] + else: + self.permission_classes = [ + InstanceAdminPermission, + ] + return super(InstanceAdminEndpoint, self).get_permissions() + + # Create an instance admin + def post(self, request): + email = request.data.get("email", False) + role = request.data.get("role", 15) + + if not email: + return Response( + {"error": "Email is required"}, status=status.HTTP_400_BAD_REQUEST + ) + + instance = Instance.objects.first() + if instance is None: + return Response( + {"error": "Instance is not registered yet"}, + status=status.HTTP_403_FORBIDDEN, + ) + + # Fetch the user + user = User.objects.get(email=email) + + instance_admin = InstanceAdmin.objects.create( + instance=instance, + user=user, + role=role, + ) + serializer = InstanceAdminSerializer(instance_admin) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def get(self, request): + instance = Instance.objects.first() + if instance is None: + return Response( + {"error": "Instance is not registered yet"}, + status=status.HTTP_403_FORBIDDEN, + ) + instance_admins = InstanceAdmin.objects.filter(instance=instance) + serializer = InstanceAdminSerializer(instance_admins, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def delete(self, request, pk): + instance = Instance.objects.first() + instance_admin = InstanceAdmin.objects.filter(instance=instance, pk=pk).delete() + return Response(status=status.HTTP_204_NO_CONTENT) + + +class InstanceConfigurationEndpoint(BaseAPIView): + permission_classes = [ + InstanceAdminPermission, + ] + + def get(self, request): + instance_configurations = InstanceConfiguration.objects.all() + serializer = InstanceConfigurationSerializer(instance_configurations, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + def patch(self, request): + key = request.data.get("key", False) + if not key: + return Response( + {"error": "Key is required"}, status=status.HTTP_400_BAD_REQUEST + ) + configuration = InstanceConfiguration.objects.get(key=key) + configuration.value = request.data.get("value") + configuration.save() + serializer = InstanceConfigurationSerializer(configuration) + return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/license/apps.py b/apiserver/plane/license/apps.py new file mode 100644 index 000000000..400e98155 --- /dev/null +++ b/apiserver/plane/license/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class LicenseConfig(AppConfig): + name = "plane.license" diff --git a/apiserver/plane/license/management/__init__.py b/apiserver/plane/license/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/management/commands/__init__.py b/apiserver/plane/license/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/management/commands/configure_instance.py b/apiserver/plane/license/management/commands/configure_instance.py new file mode 100644 index 000000000..d71d9f590 --- /dev/null +++ b/apiserver/plane/license/management/commands/configure_instance.py @@ -0,0 +1,46 @@ +# Python imports +import os + +# Django imports +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +# Module imports +from plane.license.models import InstanceConfiguration + +class Command(BaseCommand): + help = "Configure instance variables" + + def handle(self, *args, **options): + config_keys = { + # Authentication Settings + "GOOGLE_CLIENT_ID": os.environ.get("GOOGLE_CLIENT_ID"), + "GITHUB_CLIENT_ID": os.environ.get("GITHUB_CLIENT_ID"), + "GITHUB_CLIENT_SECRET": os.environ.get("GITHUB_CLIENT_SECRET"), + "ENABLE_SIGNUP": os.environ.get("ENABLE_SIGNUP", "1"), + "ENABLE_EMAIL_PASSWORD": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), + "ENABLE_MAGIC_LINK_LOGIN": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0"), + # Email Settings + "EMAIL_HOST": os.environ.get("EMAIL_HOST", ""), + "EMAIL_HOST_USER": os.environ.get("EMAIL_HOST_USER", ""), + "EMAIL_HOST_PASSWORD": os.environ.get("EMAIL_HOST_PASSWORD"), + "EMAIL_PORT": os.environ.get("EMAIL_PORT", "587"), + "EMAIL_FROM": os.environ.get("EMAIL_FROM", ""), + "EMAIL_USE_TLS": os.environ.get("EMAIL_USE_TLS", "1"), + "EMAIL_USE_SSL": os.environ.get("EMAIL_USE_SSL", "0"), + # Open AI Settings + "OPENAI_API_BASE": os.environ.get("", "https://api.openai.com/v1"), + "OPENAI_API_KEY": os.environ.get("OPENAI_API_KEY", "sk-"), + "GPT_ENGINE": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), + } + + for key, value in config_keys.items(): + obj, created = InstanceConfiguration.objects.get_or_create( + key=key + ) + if created: + obj.value = value + obj.save() + self.stdout.write(self.style.SUCCESS(f"{key} loaded with value from environment variable.")) + else: + self.stdout.write(self.style.WARNING(f"{key} configuration already exists")) \ No newline at end of file diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py new file mode 100644 index 000000000..855a3a035 --- /dev/null +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -0,0 +1,104 @@ +# Python imports +import json +import os +import requests +import uuid + +# Django imports +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone +from django.core.exceptions import ValidationError +from django.core.validators import validate_email + +# Module imports +from plane.db.models import User +from plane.license.models import Instance, InstanceAdmin + + +class Command(BaseCommand): + help = "Check if instance in registered else register" + + def handle(self, *args, **options): + # Check if the instance is registered + instance = Instance.objects.first() + + # If instance is None then register this instance + if instance is None: + with open("package.json", "r") as file: + # Load JSON content from the file + data = json.load(file) + + admin_email = os.environ.get("ADMIN_EMAIL") + + try: + validate_email(admin_email) + except ValidationError: + CommandError(f"{admin_email} is not a valid ADMIN_EMAIL") + + # Raise an exception if the admin email is not provided + if not admin_email: + raise CommandError("ADMIN_EMAIL is required") + + # Check if the admin email user exists + user = User.objects.filter(email=admin_email).first() + + # If the user does not exist create the user and add him to the database + if user is None: + user = User.objects.create(email=admin_email, username=uuid.uuid4().hex) + user.set_password(uuid.uuid4().hex) + user.save() + + license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL") + + if not license_engine_base_url: + raise CommandError("LICENSE_ENGINE_BASE_URL is required") + + headers = {"Content-Type": "application/json"} + + payload = { + "email": user.email, + "version": data.get("version", 0.1), + } + + response = requests.post( + f"{license_engine_base_url}/api/instances", + headers=headers, + data=json.dumps(payload), + ) + + if response.status_code == 201: + data = response.json() + # Create instance + instance = Instance.objects.create( + instance_name="Plane Free", + instance_id=data.get("id"), + license_key=data.get("license_key"), + api_key=data.get("api_key"), + version=data.get("version"), + primary_email=data.get("email"), + primary_owner=user, + last_checked_at=timezone.now(), + ) + # Create instance admin + _ = InstanceAdmin.objects.create( + user=user, + instance=instance, + role=20, + ) + + self.stdout.write( + self.style.SUCCESS( + f"Instance succesfully registered with owner: {instance.primary_owner.email}" + ) + ) + return + + self.stdout.write(self.style.WARNING("Instance could not be registered")) + return + else: + self.stdout.write( + self.style.SUCCESS( + f"Instance already registered with instance owner: {instance.primary_owner.email}" + ) + ) + return diff --git a/apiserver/plane/license/migrations/0001_initial.py b/apiserver/plane/license/migrations/0001_initial.py new file mode 100644 index 000000000..db620a18e --- /dev/null +++ b/apiserver/plane/license/migrations/0001_initial.py @@ -0,0 +1,83 @@ +# Generated by Django 4.2.5 on 2023-11-15 14:22 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Instance', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('instance_name', models.CharField(max_length=255)), + ('whitelist_emails', models.TextField(blank=True, null=True)), + ('instance_id', models.CharField(max_length=25, unique=True)), + ('license_key', models.CharField(blank=True, max_length=256, null=True)), + ('api_key', models.CharField(max_length=16)), + ('version', models.CharField(max_length=10)), + ('primary_email', models.CharField(max_length=256)), + ('last_checked_at', models.DateTimeField()), + ('namespace', models.CharField(blank=True, max_length=50, null=True)), + ('is_telemetry_enabled', models.BooleanField(default=True)), + ('is_support_required', models.BooleanField(default=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('primary_owner', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='instance_primary_owner', to=settings.AUTH_USER_MODEL)), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'Instance', + 'verbose_name_plural': 'Instances', + 'db_table': 'instances', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='InstanceConfiguration', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('key', models.CharField(max_length=100, unique=True)), + ('value', models.TextField(blank=True, default=None, null=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ], + options={ + 'verbose_name': 'Instance Configuration', + 'verbose_name_plural': 'Instance Configurations', + 'db_table': 'instance_configurations', + 'ordering': ('-created_at',), + }, + ), + migrations.CreateModel( + name='InstanceAdmin', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('role', models.PositiveIntegerField(choices=[(20, 'Owner'), (15, 'Admin')], default=15)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='admins', to='license.instance')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='instance_owner', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Instance Admin', + 'verbose_name_plural': 'Instance Admins', + 'db_table': 'instance_admins', + 'ordering': ('-created_at',), + }, + ), + ] diff --git a/apiserver/plane/license/migrations/0002_alter_instanceadmin_unique_together.py b/apiserver/plane/license/migrations/0002_alter_instanceadmin_unique_together.py new file mode 100644 index 000000000..21d4baaf1 --- /dev/null +++ b/apiserver/plane/license/migrations/0002_alter_instanceadmin_unique_together.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.5 on 2023-11-16 09:45 + +from django.conf import settings +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('license', '0001_initial'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='instanceadmin', + unique_together={('instance', 'user')}, + ), + ] diff --git a/apiserver/plane/license/migrations/__init__.py b/apiserver/plane/license/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/models/__init__.py b/apiserver/plane/license/models/__init__.py new file mode 100644 index 000000000..28f2c4352 --- /dev/null +++ b/apiserver/plane/license/models/__init__.py @@ -0,0 +1 @@ +from .instance import Instance, InstanceAdmin, InstanceConfiguration \ No newline at end of file diff --git a/apiserver/plane/license/models/instance.py b/apiserver/plane/license/models/instance.py new file mode 100644 index 000000000..5f96b6b90 --- /dev/null +++ b/apiserver/plane/license/models/instance.py @@ -0,0 +1,73 @@ +# Django imports +from django.db import models +from django.conf import settings + +# Module imports +from plane.db.models import BaseModel +from plane.db.mixins import AuditModel + +ROLE_CHOICES = ( + (20, "Owner"), + (15, "Admin"), +) + + +class Instance(BaseModel): + # General informations + instance_name = models.CharField(max_length=255) + whitelist_emails = models.TextField(blank=True, null=True) + instance_id = models.CharField(max_length=25, unique=True) + license_key = models.CharField(max_length=256, null=True, blank=True) + api_key = models.CharField(max_length=16) + version = models.CharField(max_length=10) + # User information + primary_email = models.CharField(max_length=256) + primary_owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="instance_primary_owner", + ) + # Instnace specifics + last_checked_at = models.DateTimeField() + namespace = models.CharField(max_length=50, blank=True, null=True) + # telemetry and support + is_telemetry_enabled = models.BooleanField(default=True) + is_support_required = models.BooleanField(default=True) + + class Meta: + verbose_name = "Instance" + verbose_name_plural = "Instances" + db_table = "instances" + ordering = ("-created_at",) + + +class InstanceAdmin(BaseModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="instance_owner", + ) + instance = models.ForeignKey(Instance, on_delete=models.CASCADE, related_name="admins") + role = models.PositiveIntegerField(choices=ROLE_CHOICES, default=15) + + class Meta: + unique_together = ["instance", "user"] + verbose_name = "Instance Admin" + verbose_name_plural = "Instance Admins" + db_table = "instance_admins" + ordering = ("-created_at",) + + +class InstanceConfiguration(BaseModel): + # The instance configuration variables + key = models.CharField(max_length=100, unique=True) + value = models.TextField(null=True, blank=True, default=None) + + class Meta: + verbose_name = "Instance Configuration" + verbose_name_plural = "Instance Configurations" + db_table = "instance_configurations" + ordering = ("-created_at",) + diff --git a/apiserver/plane/license/urls.py b/apiserver/plane/license/urls.py new file mode 100644 index 000000000..6e95329bd --- /dev/null +++ b/apiserver/plane/license/urls.py @@ -0,0 +1,36 @@ +from django.urls import path + +from plane.license.api.views import ( + InstanceEndpoint, + TransferPrimaryOwnerEndpoint, + InstanceAdminEndpoint, + InstanceConfigurationEndpoint, +) + +urlpatterns = [ + path( + "instances/", + InstanceEndpoint.as_view(), + name="instance", + ), + path( + "instances/transfer-primary-owner/", + TransferPrimaryOwnerEndpoint.as_view(), + name="instance", + ), + path( + "instances/admins/", + InstanceAdminEndpoint.as_view(), + name="instance-admins", + ), + path( + "instances/admins//", + InstanceAdminEndpoint.as_view(), + name="instance-admins", + ), + path( + "instances/configurations/", + InstanceConfigurationEndpoint.as_view(), + name="instance-configuration", + ), +] diff --git a/apiserver/plane/license/utils/__init__.py b/apiserver/plane/license/utils/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/apiserver/plane/license/utils/instance_value.py b/apiserver/plane/license/utils/instance_value.py new file mode 100644 index 000000000..efca2799c --- /dev/null +++ b/apiserver/plane/license/utils/instance_value.py @@ -0,0 +1,6 @@ +# Helper function to return value from the passed key +def get_configuration_value(query, key, default=None): + for item in query: + if item['key'] == key: + return item.get("value", default) + return default diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 74bf59be3..0ef96717f 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -5,6 +5,7 @@ import ssl import certifi from datetime import timedelta from urllib.parse import urlparse + # Django imports from django.core.management.utils import get_random_secret_key @@ -26,12 +27,6 @@ DEBUG = False # Allowed Hosts ALLOWED_HOSTS = ["*"] -# To access webhook -ENABLE_WEBHOOK = os.environ.get("ENABLE_WEBHOOK", "1") == "1" - -# To access plane api through api tokens -ENABLE_API = os.environ.get("ENABLE_API", "1") == "1" - # Redirect if / is not present APPEND_SLASH = True @@ -48,6 +43,7 @@ INSTALLED_APPS = [ "plane.utils", "plane.web", "plane.middleware", + "plane.license", "plane.proxy", # Third-party things "rest_framework", @@ -118,7 +114,13 @@ CSRF_COOKIE_SECURE = True # CORS Settings CORS_ALLOW_CREDENTIALS = True -CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "").split(",") +cors_origins_raw = os.environ.get("CORS_ALLOWED_ORIGINS", "") +# filter out empty strings +cors_allowed_origins = [origin.strip() for origin in cors_origins_raw.split(",") if origin.strip()] +if cors_allowed_origins: + CORS_ALLOWED_ORIGINS = cors_allowed_origins +else: + CORS_ALLOW_ALL_ORIGINS = True # Application Settings WSGI_APPLICATION = "plane.wsgi.application" @@ -212,16 +214,6 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # Email settings EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -# Host for sending e-mail. -EMAIL_HOST = os.environ.get("EMAIL_HOST") -# Port for sending e-mail. -EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 587)) -# Optional SMTP authentication information for EMAIL_HOST. -EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER") -EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD") -EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "1") == "1" -EMAIL_USE_SSL = os.environ.get("EMAIL_USE_SSL", "0") == "1" -EMAIL_FROM = os.environ.get("EMAIL_FROM", "Team Plane ") # Storage Settings STORAGES = { @@ -229,7 +221,9 @@ STORAGES = { "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", }, } -STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"} +STORAGES["default"] = { + "BACKEND": "storages.backends.s3boto3.S3Boto3Storage", +} AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key") AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads") @@ -245,7 +239,6 @@ if AWS_S3_ENDPOINT_URL: AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:" - # JWT Auth Configuration SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(minutes=10080), @@ -328,17 +321,5 @@ GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) ANALYTICS_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False) ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) -# Open AI Settings -OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") -OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) -GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo") - -# Scout Settings -SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False) -SCOUT_KEY = os.environ.get("SCOUT_KEY", "") -SCOUT_NAME = "Plane" - -# Set the variable true if running in docker environment -DOCKERIZED = int(os.environ.get("DOCKERIZED", 1)) == 1 +# Use Minio settings USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 - diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index 9fa5ed0aa..8f27d4234 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -3,10 +3,6 @@ from .common import * # noqa DEBUG = True -ALLOWED_HOSTS = [ - "*", -] - # Debug Toolbar settings INSTALLED_APPS += ("debug_toolbar",) MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) @@ -24,13 +20,9 @@ CACHES = { INTERNAL_IPS = ("127.0.0.1",) -CORS_ORIGIN_ALLOW_ALL = True - MEDIA_URL = "/uploads/" MEDIA_ROOT = os.path.join(BASE_DIR, "uploads") -# For local settings -CORS_ALLOW_ALL_ORIGINS = True CORS_ALLOWED_ORIGINS = [ "http://localhost:3000", "http://127.0.0.1:3000", diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index b230bbc32..90eb04dd5 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -11,3 +11,8 @@ INSTALLED_APPS += ("scout_apm.django",) # Honor the 'X-Forwarded-Proto' header for request.is_secure() SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") + +# Scout Settings +SCOUT_MONITOR = os.environ.get("SCOUT_MONITOR", False) +SCOUT_KEY = os.environ.get("SCOUT_KEY", "") +SCOUT_NAME = "Plane" diff --git a/apiserver/plane/urls.py b/apiserver/plane/urls.py index aabc6a75a..66f6714fb 100644 --- a/apiserver/plane/urls.py +++ b/apiserver/plane/urls.py @@ -11,11 +11,11 @@ from django.conf import settings urlpatterns = [ path("", TemplateView.as_view(template_name="index.html")), path("api/", include("plane.api.urls")), + path("api/licenses/", include("plane.license.urls")), + path("api/v1/", include("plane.proxy.urls")), path("", include("plane.web.urls")), ] -if settings.ENABLE_API: - urlpatterns += path("api/v1/", include("plane.proxy.urls")), if settings.DEBUG: import debug_toolbar diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index f870b998b..7c5f1cf28 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -26,7 +26,7 @@ google-api-python-client==2.97.0 django-redis==5.3.0 uvicorn==0.23.2 channels==4.0.0 -openai==0.28.0 +openai==1.2.4 slack-sdk==3.21.3 celery==5.3.4 django_celery_beat==2.5.0 diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 8ac4a7277..74377aef0 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -5,18 +5,15 @@ x-app-env : &app-env - NGINX_PORT=${NGINX_PORT:-80} - WEB_URL=${WEB_URL:-http://localhost} - DEBUG=${DEBUG:-0} - - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted} + - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.selfhosted} # deprecated - NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0} - NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} - SENTRY_DSN=${SENTRY_DSN:-""} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""} - - DOCKERIZED=${DOCKERIZED:-1} - # BASE WEBHOOK - - ENABLE_WEBHOOK=${ENABLE_WEBHOOK:-1} - # BASE API - - ENABLE_API=${ENABLE_API:-1} - - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-http://localhost} - # Gunicorn Workers + - DOCKERIZED=${DOCKERIZED:-1} # deprecated + - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""} + - ENVIRONMENT=${ENVIRONMENT:-"production"} + # Gunicorn Workers - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} #DB SETTINGS - PGHOST=${PGHOST:-plane-db} @@ -40,7 +37,7 @@ x-app-env : &app-env - EMAIL_USE_SSL=${EMAIL_USE_SSL:-0} - DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so} - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} - # OPENAI SETTINGS + # OPENAI SETTINGS - Deprecated can be configured through admin panel - OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1} - OPENAI_API_KEY=${OPENAI_API_KEY:-"sk-"} - GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"} diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index b2547cbbe..f74c838e7 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -7,18 +7,14 @@ API_REPLICAS=1 NGINX_PORT=80 WEB_URL=http://localhost DEBUG=0 -DJANGO_SETTINGS_MODULE=plane.settings.selfhosted +DJANGO_SETTINGS_MODULE=plane.settings.selfhosted # deprecated NEXT_PUBLIC_ENABLE_OAUTH=0 NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces SENTRY_DSN="" GITHUB_CLIENT_SECRET="" -DOCKERIZED=1 -CORS_ALLOWED_ORIGINS="http://localhost" - -# Webhook -ENABLE_WEBHOOK=1 -# API -ENABLE_API=1 +DOCKERIZED=1 # deprecated +CORS_ALLOWED_ORIGINS="" +ENVIRONMENT="production" #DB SETTINGS PGHOST=plane-db @@ -42,13 +38,11 @@ EMAIL_PORT=587 EMAIL_FROM="Team Plane <team@mailer.plane.so>" EMAIL_USE_TLS=1 EMAIL_USE_SSL=0 -DEFAULT_EMAIL=captain@plane.so -DEFAULT_PASSWORD=password123 # OPENAI SETTINGS -OPENAI_API_BASE=https://api.openai.com/v1 -OPENAI_API_KEY="sk-" -GPT_ENGINE="gpt-3.5-turbo" +OPENAI_API_BASE=https://api.openai.com/v1 # deprecated +OPENAI_API_KEY="sk-" # deprecated +GPT_ENGINE="gpt-3.5-turbo" # deprecated # LOGIN/SIGNUP SETTINGS ENABLE_SIGNUP=1 diff --git a/web/components/instance/general-form.tsx b/web/components/instance/general-form.tsx new file mode 100644 index 000000000..87a268fd2 --- /dev/null +++ b/web/components/instance/general-form.tsx @@ -0,0 +1,126 @@ +import { FC } from "react"; +import { Controller, useForm } from "react-hook-form"; +// ui +import { Button, Input, ToggleSwitch } from "@plane/ui"; +// types +import { IInstance } from "types/instance"; +// hooks +import useToast from "hooks/use-toast"; +import { useMobxStore } from "lib/mobx/store-provider"; + +export interface IInstanceGeneralForm { + instance: IInstance; +} + +export interface GeneralFormValues { + instance_name: string; + is_telemetry_enabled: boolean; +} + +export const InstanceGeneralForm: FC = (props) => { + const { instance } = props; + // store + const { instance: instanceStore } = useMobxStore(); + // toast + const { setToastAlert } = useToast(); + // form data + const { + handleSubmit, + control, + formState: { errors, isSubmitting }, + } = useForm({ + defaultValues: { + instance_name: instance.instance_name, + is_telemetry_enabled: instance.is_telemetry_enabled, + }, + }); + + const onSubmit = async (formData: GeneralFormValues) => { + const payload: Partial = { ...formData }; + + await instanceStore + .updateInstanceInfo(payload) + .then(() => + setToastAlert({ + title: "Success", + type: "success", + message: "Settings updated successfully", + }) + ) + .catch((err) => console.error(err)); + }; + + return ( +
+
+
+

Name of instance

+ ( + + )} + /> +
+ +
+

Admin Email

+ +
+ +
+

Instance Id

+ +
+
+ +
+
+
Share anonymous usage instance
+
+ Help us understand how you use Plane so we can build better for you. +
+
+
+ } + /> +
+
+ +
+ +
+
+ ); +}; diff --git a/web/components/instance/help-section.tsx b/web/components/instance/help-section.tsx new file mode 100644 index 000000000..4093f9ffd --- /dev/null +++ b/web/components/instance/help-section.tsx @@ -0,0 +1,134 @@ +import { FC, useState, useRef } from "react"; +import { Transition } from "@headlessui/react"; +import Link from "next/link"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// icons +import { FileText, HelpCircle, MessagesSquare, MoveLeft } from "lucide-react"; +import { DiscordIcon, GithubIcon } from "@plane/ui"; +// assets +import packageJson from "package.json"; + +const helpOptions = [ + { + name: "Documentation", + href: "https://docs.plane.so/", + Icon: FileText, + }, + { + name: "Join our Discord", + href: "https://discord.com/invite/A92xrEGCge", + Icon: DiscordIcon, + }, + { + name: "Report a bug", + href: "https://github.com/makeplane/plane/issues/new/choose", + Icon: GithubIcon, + }, + { + name: "Chat with us", + href: null, + onClick: () => (window as any).$crisp.push(["do", "chat:show"]), + Icon: MessagesSquare, + }, +]; + +export const InstanceHelpSection: FC = () => { + // states + const [isNeedHelpOpen, setIsNeedHelpOpen] = useState(false); + // store + const { + theme: { sidebarCollapsed, toggleSidebar }, + } = useMobxStore(); + // refs + const helpOptionsRef = useRef(null); + + return ( +
+
+ + + +
+ +
+ +
+
+ {helpOptions.map(({ name, Icon, href, onClick }) => { + if (href) + return ( + + +
+ +
+ {name} +
+ + ); + else + return ( + + ); + })} +
+
Version: v{packageJson.version}
+
+
+
+
+ ); +}; diff --git a/web/components/instance/index.ts b/web/components/instance/index.ts new file mode 100644 index 000000000..c4840736a --- /dev/null +++ b/web/components/instance/index.ts @@ -0,0 +1,4 @@ +export * from "./help-section"; +export * from "./sidebar-menu"; +export * from "./sidebar-dropdown"; +export * from "./general-form"; diff --git a/web/components/instance/sidebar-dropdown.tsx b/web/components/instance/sidebar-dropdown.tsx new file mode 100644 index 000000000..923dd8d21 --- /dev/null +++ b/web/components/instance/sidebar-dropdown.tsx @@ -0,0 +1,148 @@ +import { Fragment } from "react"; +import { useRouter } from "next/router"; +import { observer } from "mobx-react-lite"; +import Link from "next/link"; +import { Menu, Transition } from "@headlessui/react"; +import { LogOut, Settings, Shield, UserCircle2 } from "lucide-react"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// hooks +import useToast from "hooks/use-toast"; +// services +import { AuthService } from "services/auth.service"; +// ui +import { Avatar } from "@plane/ui"; + +// Static Data +const profileLinks = (workspaceSlug: string, userId: string) => [ + { + name: "View profile", + icon: UserCircle2, + link: `/${workspaceSlug}/profile/${userId}`, + }, + { + name: "Settings", + icon: Settings, + link: `/${workspaceSlug}/me/profile`, + }, +]; + +const authService = new AuthService(); + +export const InstanceSidebarDropdown = observer(() => { + const router = useRouter(); + // store + const { + theme: { sidebarCollapsed }, + workspace: { workspaceSlug }, + user: { currentUser, currentUserSettings }, + } = useMobxStore(); + // hooks + const { setToastAlert } = useToast(); + + // redirect url for normal mode + const redirectWorkspaceSlug = + workspaceSlug || + currentUserSettings?.workspace?.last_workspace_slug || + currentUserSettings?.workspace?.fallback_workspace_slug || + ""; + + const handleSignOut = async () => { + await authService + .signOut() + .then(() => { + router.push("/"); + }) + .catch(() => + setToastAlert({ + type: "error", + title: "Error!", + message: "Failed to sign out. Please try again.", + }) + ); + }; + + return ( +
+
+
+
+ +
+ + {!sidebarCollapsed && ( +

Instance Admin Settings

+ )} +
+
+ + {!sidebarCollapsed && ( + + + + + + + +
+ {currentUser?.email} + {profileLinks(workspaceSlug?.toString() ?? "", currentUser?.id ?? "").map((link, index) => ( + + + + + {link.name} + + + + ))} +
+
+ + + Sign out + +
+ +
+ + + + Normal Mode + + + +
+
+
+
+ )} +
+ ); +}); diff --git a/web/components/instance/sidebar-menu.tsx b/web/components/instance/sidebar-menu.tsx new file mode 100644 index 000000000..dbb697efb --- /dev/null +++ b/web/components/instance/sidebar-menu.tsx @@ -0,0 +1,65 @@ +import Link from "next/link"; +import { useRouter } from "next/router"; +import { BarChart2, Briefcase, CheckCircle, LayoutGrid } from "lucide-react"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// ui +import { Tooltip } from "@plane/ui"; + +const INSTANCE_ADMIN_LINKS = [ + { + Icon: LayoutGrid, + name: "General", + href: `/admin`, + }, + { + Icon: BarChart2, + name: "OAuth", + href: `/admin/oauth`, + }, + { + Icon: Briefcase, + name: "Email", + href: `/admin/email`, + }, + { + Icon: CheckCircle, + name: "AI", + href: `/admin/ai`, + }, +]; + +export const InstanceAdminSidebarMenu = () => { + const { + theme: { sidebarCollapsed }, + } = useMobxStore(); + // router + const router = useRouter(); + + return ( +
+ {INSTANCE_ADMIN_LINKS.map((item, index) => { + const isActive = item.name === "Settings" ? router.asPath.includes(item.href) : router.asPath === item.href; + + return ( + + + +
+ {} + {!sidebarCollapsed && item.name} +
+
+
+ + ); + })} +
+ ); +}; diff --git a/web/components/workspace/sidebar-dropdown.tsx b/web/components/workspace/sidebar-dropdown.tsx index 58879e968..6fa950a84 100644 --- a/web/components/workspace/sidebar-dropdown.tsx +++ b/web/components/workspace/sidebar-dropdown.tsx @@ -53,7 +53,7 @@ export const WorkspaceSidebarDropdown = observer(() => { const { theme: { sidebarCollapsed }, workspace: { workspaces, currentWorkspace: activeWorkspace }, - user: { currentUser, updateCurrentUser }, + user: { currentUser, updateCurrentUser, isUserInstanceAdmin }, } = useMobxStore(); // hooks const { setToastAlert } = useToast(); @@ -286,7 +286,7 @@ export const WorkspaceSidebarDropdown = observer(() => { ))} -
+
{ Sign out
+ {isUserInstanceAdmin && ( +
+ + + + God Mode + + + +
+ )} diff --git a/web/layouts/admin-layout/header.tsx b/web/layouts/admin-layout/header.tsx new file mode 100644 index 000000000..a111222f3 --- /dev/null +++ b/web/layouts/admin-layout/header.tsx @@ -0,0 +1,47 @@ +import { FC } from "react"; +// next +import Link from "next/link"; +// mobx +import { observer } from "mobx-react-lite"; +// ui +import { Breadcrumbs } from "@plane/ui"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// icons +import { ArrowLeftToLine, Settings } from "lucide-react"; + +export const InstanceAdminHeader: FC = observer(() => { + const { + workspace: { workspaceSlug }, + user: { currentUserSettings }, + } = useMobxStore(); + + const redirectWorkspaceSlug = + workspaceSlug || + currentUserSettings?.workspace?.last_workspace_slug || + currentUserSettings?.workspace?.fallback_workspace_slug || + ""; + + return ( +
+
+
+ + } + label="General" + /> + +
+
+
+ + + + + +
+
+ ); +}); diff --git a/web/layouts/admin-layout/index.ts b/web/layouts/admin-layout/index.ts new file mode 100644 index 000000000..8a235ad00 --- /dev/null +++ b/web/layouts/admin-layout/index.ts @@ -0,0 +1,3 @@ +export * from "./layout"; +export * from "./sidebar"; +export * from "./header"; diff --git a/web/layouts/admin-layout/layout.tsx b/web/layouts/admin-layout/layout.tsx new file mode 100644 index 000000000..1a1dbfa63 --- /dev/null +++ b/web/layouts/admin-layout/layout.tsx @@ -0,0 +1,32 @@ +import { FC, ReactNode } from "react"; +// layouts +import { UserAuthWrapper } from "layouts/auth-layout"; +// components +import { InstanceAdminSidebar } from "./sidebar"; +import { InstanceAdminHeader } from "./header"; + +export interface IInstanceAdminLayout { + children: ReactNode; +} + +export const InstanceAdminLayout: FC = (props) => { + const { children } = props; + + return ( + <> + +
+ +
+ +
+
+ <>{children} +
+
+
+
+
+ + ); +}; diff --git a/web/layouts/admin-layout/sidebar.tsx b/web/layouts/admin-layout/sidebar.tsx new file mode 100644 index 000000000..d3a9ecfa1 --- /dev/null +++ b/web/layouts/admin-layout/sidebar.tsx @@ -0,0 +1,28 @@ +import { FC } from "react"; +import { observer } from "mobx-react-lite"; +// components +import { InstanceAdminSidebarMenu, InstanceHelpSection, InstanceSidebarDropdown } from "components/instance"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; + +export interface IInstanceAdminSidebar {} + +export const InstanceAdminSidebar: FC = observer(() => { + // store + const { theme: themStore } = useMobxStore(); + + return ( +
+
+ + + +
+
+ ); +}); diff --git a/web/layouts/auth-layout/user-wrapper.tsx b/web/layouts/auth-layout/user-wrapper.tsx index 6072f1673..6b64099fa 100644 --- a/web/layouts/auth-layout/user-wrapper.tsx +++ b/web/layouts/auth-layout/user-wrapper.tsx @@ -14,7 +14,7 @@ export const UserAuthWrapper: FC = (props) => { const { children } = props; // store const { - user: { fetchCurrentUser, fetchCurrentUserSettings }, + user: { fetchCurrentUser, fetchCurrentUserInstanceAdminStatus, fetchCurrentUserSettings }, workspace: { fetchWorkspaces }, } = useMobxStore(); // router @@ -23,6 +23,10 @@ export const UserAuthWrapper: FC = (props) => { const { data: currentUser, error } = useSWR("CURRENT_USER_DETAILS", () => fetchCurrentUser(), { shouldRetryOnError: false, }); + // fetching current user instance admin status + useSWR("CURRENT_USER_INSTANCE_ADMIN_STATUS", () => fetchCurrentUserInstanceAdminStatus(), { + shouldRetryOnError: false, + }); // fetching user settings useSWR("CURRENT_USER_SETTINGS", () => fetchCurrentUserSettings(), { shouldRetryOnError: false, diff --git a/web/pages/admin/ai.tsx b/web/pages/admin/ai.tsx new file mode 100644 index 000000000..49557c8ce --- /dev/null +++ b/web/pages/admin/ai.tsx @@ -0,0 +1,16 @@ +import { ReactElement } from "react"; +// layouts +import { InstanceAdminLayout } from "layouts/admin-layout"; +// types +import { NextPageWithLayout } from "types/app"; + +const InstanceAdminAIPage: NextPageWithLayout = () => { + console.log("admin page"); + return
Admin AI Page
; +}; + +InstanceAdminAIPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default InstanceAdminAIPage; diff --git a/web/pages/admin/email.tsx b/web/pages/admin/email.tsx new file mode 100644 index 000000000..9fc572b44 --- /dev/null +++ b/web/pages/admin/email.tsx @@ -0,0 +1,16 @@ +import { ReactElement } from "react"; +// layouts +import { InstanceAdminLayout } from "layouts/admin-layout"; +// types +import { NextPageWithLayout } from "types/app"; + +const InstanceAdminEmailPage: NextPageWithLayout = () => { + console.log("admin page"); + return
Admin Email Page
; +}; + +InstanceAdminEmailPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default InstanceAdminEmailPage; diff --git a/web/pages/admin/index.tsx b/web/pages/admin/index.tsx new file mode 100644 index 000000000..70ffd0cc1 --- /dev/null +++ b/web/pages/admin/index.tsx @@ -0,0 +1,28 @@ +import { ReactElement } from "react"; +import useSWR from "swr"; +import { observer } from "mobx-react-lite"; +// layouts +import { InstanceAdminLayout } from "layouts/admin-layout"; +// types +import { NextPageWithLayout } from "types/app"; +// store +import { useMobxStore } from "lib/mobx/store-provider"; +// components +import { InstanceGeneralForm } from "components/instance"; + +const InstanceAdminPage: NextPageWithLayout = observer(() => { + // store + const { + instance: { fetchInstanceInfo, instance }, + } = useMobxStore(); + + useSWR("INSTANCE_INFO", () => fetchInstanceInfo()); + + return
{instance && }
; +}); + +InstanceAdminPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default InstanceAdminPage; diff --git a/web/pages/admin/oauth.tsx b/web/pages/admin/oauth.tsx new file mode 100644 index 000000000..56bb8fc17 --- /dev/null +++ b/web/pages/admin/oauth.tsx @@ -0,0 +1,16 @@ +import { ReactElement } from "react"; +// layouts +import { InstanceAdminLayout } from "layouts/admin-layout"; +// types +import { NextPageWithLayout } from "types/app"; + +const InstanceAdminOAuthPage: NextPageWithLayout = () => { + console.log("admin page"); + return
Admin oauth Page
; +}; + +InstanceAdminOAuthPage.getLayout = function getLayout(page: ReactElement) { + return {page}; +}; + +export default InstanceAdminOAuthPage; diff --git a/web/services/instance.service.ts b/web/services/instance.service.ts new file mode 100644 index 000000000..74c32aa5f --- /dev/null +++ b/web/services/instance.service.ts @@ -0,0 +1,37 @@ +import { APIService } from "services/api.service"; +// helpers +import { API_BASE_URL } from "helpers/common.helper"; +// types +import type { IInstance } from "types/instance"; + +export class InstanceService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async getInstanceInfo(): Promise { + return this.get("/api/licenses/instances/") + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } + + async updateInstanceInfo( + data: Partial + ): Promise { + return this.patch("/api/licenses/instances/", data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }) + } + + async getInstanceConfigurations() { + return this.get("/api/licenses/instances/configurations/") + .then((response) => response.data) + .catch((error) => { + throw error; + }); + } +} diff --git a/web/services/user.service.ts b/web/services/user.service.ts index f5c4ac17e..a2cd74697 100644 --- a/web/services/user.service.ts +++ b/web/services/user.service.ts @@ -6,6 +6,7 @@ import type { IIssue, IUser, IUserActivityResponse, + IInstanceAdminStatus, IUserProfileData, IUserProfileProjectSegregation, IUserSettings, @@ -54,6 +55,14 @@ export class UserService extends APIService { }); } + async currentUserInstanceAdminStatus(): Promise { + return this.get("/api/users/me/instance-admin/") + .then((respone) => respone?.data) + .catch((error) => { + throw error?.response; + }); + } + async currentUserSettings(): Promise { return this.get("/api/users/me/settings/") .then((response) => response?.data) diff --git a/web/services/workspace.service.ts b/web/services/workspace.service.ts index 30126c2ee..98d85ec8a 100644 --- a/web/services/workspace.service.ts +++ b/web/services/workspace.service.ts @@ -96,7 +96,7 @@ export class WorkspaceService extends APIService { } async joinWorkspace(workspaceSlug: string, invitationId: string, data: any, user: IUser | undefined): Promise { - return this.post(`/api/users/me/invitations/workspaces/${workspaceSlug}/${invitationId}/join/`, data, { + return this.post(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/join/`, data, { headers: {}, }) .then((response) => { @@ -109,7 +109,7 @@ export class WorkspaceService extends APIService { } async joinWorkspaces(data: any): Promise { - return this.post("/api/users/me/invitations/workspaces/", data) + return this.post("/api/users/me/workspaces/invitations/", data) .then((response) => response?.data) .catch((error) => { throw error?.response?.data; @@ -125,7 +125,7 @@ export class WorkspaceService extends APIService { } async userWorkspaceInvitations(): Promise { - return this.get("/api/users/me/invitations/workspaces/") + return this.get("/api/users/me/workspaces/invitations/") .then((response) => response?.data) .catch((error) => { throw error?.response?.data; diff --git a/web/store/instance/index.ts b/web/store/instance/index.ts new file mode 100644 index 000000000..96a0e600f --- /dev/null +++ b/web/store/instance/index.ts @@ -0,0 +1 @@ +export * from "./instance.store"; diff --git a/web/store/instance/instance.store.ts b/web/store/instance/instance.store.ts new file mode 100644 index 000000000..bd37110a1 --- /dev/null +++ b/web/store/instance/instance.store.ts @@ -0,0 +1,111 @@ +import { observable, action, computed, makeObservable, runInAction } from "mobx"; +// store +import { RootStore } from "../root"; +// types +import { IInstance } from "types/instance"; +// services +import { InstanceService } from "services/instance.service"; + +export interface IInstanceStore { + loader: boolean; + error: any | null; + // issues + instance: IInstance | null; + configurations: any | null; + // computed + // action + fetchInstanceInfo: () => Promise; + updateInstanceInfo: (data: Partial) => Promise; + fetchInstanceConfigurations: () => Promise; +} + +export class InstanceStore implements IInstanceStore { + loader: boolean = false; + error: any | null = null; + instance: IInstance | null = null; + configurations: any | null = null; + // service + instanceService; + rootStore; + + constructor(_rootStore: RootStore) { + makeObservable(this, { + // observable + loader: observable.ref, + error: observable.ref, + instance: observable.ref, + configurations: observable.ref, + // computed + // getIssueType: computed, + // actions + fetchInstanceInfo: action, + updateInstanceInfo: action, + fetchInstanceConfigurations: action, + }); + + this.rootStore = _rootStore; + this.instanceService = new InstanceService(); + } + + /** + * fetch instace info from API + */ + fetchInstanceInfo = async () => { + try { + const instance = await this.instanceService.getInstanceInfo(); + runInAction(() => { + this.instance = instance; + }); + return instance; + } catch (error) { + console.log("Error while fetching the instance"); + throw error; + } + }; + + /** + * update instance info + * @param data + */ + updateInstanceInfo = async (data: Partial) => { + try { + runInAction(() => { + this.loader = true; + this.error = null; + }); + + const response = await this.instanceService.updateInstanceInfo(data); + + runInAction(() => { + this.loader = false; + this.error = null; + this.instance = response; + }); + + return response; + } catch (error) { + runInAction(() => { + this.loader = false; + this.error = error; + }); + + throw error; + } + }; + + /** + * fetch instace configurations from API + */ + fetchInstanceConfigurations = async () => { + try { + const configurations = await this.instanceService.getInstanceConfigurations(); + runInAction(() => { + this.configurations = configurations; + }); + return configurations; + } catch (error) { + console.log("Error while fetching the instance"); + throw error; + } + }; +} diff --git a/web/store/root.ts b/web/store/root.ts index f7c3f49c4..3bebdcd70 100644 --- a/web/store/root.ts +++ b/web/store/root.ts @@ -1,5 +1,6 @@ import { enableStaticRendering } from "mobx-react-lite"; // store imports +import { InstanceStore, IInstanceStore } from "./instance"; import AppConfigStore, { IAppConfigStore } from "./app-config.store"; import CommandPaletteStore, { ICommandPaletteStore } from "./command-palette.store"; import UserStore, { IUserStore } from "store/user.store"; @@ -116,6 +117,8 @@ import { IMentionsStore, MentionsStore } from "store/editor"; enableStaticRendering(typeof window === "undefined"); export class RootStore { + instance: IInstanceStore; + user: IUserStore; theme: IThemeStore; appConfig: IAppConfigStore; @@ -184,6 +187,8 @@ export class RootStore { mentionsStore: IMentionsStore; constructor() { + this.instance = new InstanceStore(this); + this.appConfig = new AppConfigStore(this); this.commandPalette = new CommandPaletteStore(this); this.user = new UserStore(this); diff --git a/web/store/user.store.ts b/web/store/user.store.ts index c1d91904f..6b7e41548 100644 --- a/web/store/user.store.ts +++ b/web/store/user.store.ts @@ -14,6 +14,7 @@ export interface IUserStore { isUserLoggedIn: boolean | null; currentUser: IUser | null; + isUserInstanceAdmin: boolean | null; currentUserSettings: IUserSettings | null; dashboardInfo: any; @@ -41,6 +42,7 @@ export interface IUserStore { hasPermissionToCurrentProject: boolean | undefined; fetchCurrentUser: () => Promise; + fetchCurrentUserInstanceAdminStatus: () => Promise; fetchCurrentUserSettings: () => Promise; fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise; @@ -58,6 +60,7 @@ class UserStore implements IUserStore { isUserLoggedIn: boolean | null = null; currentUser: IUser | null = null; + isUserInstanceAdmin: boolean | null = null; currentUserSettings: IUserSettings | null = null; dashboardInfo: any = null; @@ -87,7 +90,9 @@ class UserStore implements IUserStore { makeObservable(this, { // observable loader: observable.ref, + isUserLoggedIn: observable.ref, currentUser: observable.ref, + isUserInstanceAdmin: observable.ref, currentUserSettings: observable.ref, dashboardInfo: observable.ref, workspaceMemberInfo: observable.ref, @@ -96,6 +101,7 @@ class UserStore implements IUserStore { hasPermissionToProject: observable.ref, // action fetchCurrentUser: action, + fetchCurrentUserInstanceAdminStatus: action, fetchCurrentUserSettings: action, fetchUserDashboardInfo: action, fetchUserWorkspaceInfo: action, @@ -167,6 +173,23 @@ class UserStore implements IUserStore { } }; + fetchCurrentUserInstanceAdminStatus = async () => { + try { + const response = await this.userService.currentUserInstanceAdminStatus(); + if (response) { + runInAction(() => { + this.isUserInstanceAdmin = response.is_instance_admin; + }) + } + return response.is_instance_admin; + } catch (error) { + runInAction(() => { + this.isUserInstanceAdmin = false; + }); + throw error; + } + }; + fetchCurrentUserSettings = async () => { try { const response = await this.userService.currentUserSettings(); diff --git a/web/types/instance.d.ts b/web/types/instance.d.ts new file mode 100644 index 000000000..6ba32b138 --- /dev/null +++ b/web/types/instance.d.ts @@ -0,0 +1,22 @@ +import { IUserLite } from "./users"; + +export interface IInstance { + id: string; + primary_owner_details: IUserLite; + created_at: string; + updated_at: string; + instance_name: string; + whitelist_emails: string | null; + instance_id: string; + license_key: string | null; + api_key: string; + version: string; + primary_email: string; + last_checked_at: string; + namespace: string | null; + is_telemetry_enabled: boolean; + is_support_required: boolean; + created_by: string | null; + updated_by: string | null; + primary_owner: string; +} diff --git a/web/types/users.d.ts b/web/types/users.d.ts index 2c93ff764..c9dbd6cbd 100644 --- a/web/types/users.d.ts +++ b/web/types/users.d.ts @@ -29,6 +29,10 @@ export interface IUser { theme: IUserTheme; } +export interface IInstanceAdminStatus { + is_instance_admin: boolean; +} + export interface IUserSettings { id: string; email: string;