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 <nikhilschacko@gmail.com>
Co-authored-by: Prateek Shourya <prateekshourya29@gmail.com>
This commit is contained in:
sriram veeraghanta 2023-11-18 16:17:01 +05:30 committed by GitHub
parent 9369ee5008
commit 878707f444
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
78 changed files with 1950 additions and 290 deletions

View File

@ -21,20 +21,15 @@ AWS_S3_BUCKET_NAME="uploads"
FILE_SIZE_LIMIT=5242880 FILE_SIZE_LIMIT=5242880
# GPT settings # GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
OPENAI_API_KEY="sk-" # add your openai key here OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access GPT_ENGINE="gpt-3.5-turbo" # deprecated
# Settings related to Docker # Settings related to Docker
DOCKERIZED=1 DOCKERIZED=1 # deprecated
# set to 1 If using the pre-configured minio setup # set to 1 If using the pre-configured minio setup
USE_MINIO=1 USE_MINIO=1
# Nginx Configuration # Nginx Configuration
NGINX_PORT=80 NGINX_PORT=80
# Set it to 0, to disable it
ENABLE_WEBHOOK=1
# Set it to 0, to disable it
ENABLE_API=1

View File

@ -43,8 +43,6 @@ FROM python:3.11.1-alpine3.17 AS backend
ENV PYTHONDONTWRITEBYTECODE 1 ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONUNBUFFERED 1 ENV PYTHONUNBUFFERED 1
ENV PIP_DISABLE_PIP_VERSION_CHECK=1 ENV PIP_DISABLE_PIP_VERSION_CHECK=1
ENV DJANGO_SETTINGS_MODULE plane.settings.production
ENV DOCKERIZED 1
WORKDIR /code WORKDIR /code

View File

@ -31,12 +31,10 @@ AWS_S3_BUCKET_NAME="uploads"
FILE_SIZE_LIMIT=5242880 FILE_SIZE_LIMIT=5242880
# GPT settings # GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
OPENAI_API_KEY="sk-" # add your openai key here OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access GPT_ENGINE="gpt-3.5-turbo" # deprecated
# Settings related to Docker
DOCKERIZED=1
# set to 1 If using the pre-configured minio setup # set to 1 If using the pre-configured minio setup
USE_MINIO=1 USE_MINIO=1
@ -78,7 +76,7 @@ NEXT_PUBLIC_ENABLE_OAUTH=0
# Backend # Backend
# Debug value for api server use it as 0 for production use # Debug value for api server use it as 0 for production use
DEBUG=0 DEBUG=0
DJANGO_SETTINGS_MODULE="plane.settings.selfhosted" DJANGO_SETTINGS_MODULE="plane.settings.selfhosted" # deprecated
# Error logs # Error logs
SENTRY_DSN="" SENTRY_DSN=""
@ -115,24 +113,22 @@ AWS_S3_BUCKET_NAME="uploads"
FILE_SIZE_LIMIT=5242880 FILE_SIZE_LIMIT=5242880
# GPT settings # GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
OPENAI_API_KEY="sk-" # add your openai key here OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access GPT_ENGINE="gpt-3.5-turbo" # deprecated
# Settings related to Docker
DOCKERIZED=1 # Deprecated
# Github # Github
GITHUB_CLIENT_SECRET="" # For fetching release notes GITHUB_CLIENT_SECRET="" # For fetching release notes
# Settings related to Docker
DOCKERIZED=1
# set to 1 If using the pre-configured minio setup # set to 1 If using the pre-configured minio setup
USE_MINIO=1 USE_MINIO=1
# Nginx Configuration # Nginx Configuration
NGINX_PORT=80 NGINX_PORT=80
# Default Creds
DEFAULT_EMAIL="captain@plane.so"
DEFAULT_PASSWORD="password123"
# SignUps # SignUps
ENABLE_SIGNUP="1" ENABLE_SIGNUP="1"

View File

@ -1,7 +1,8 @@
# Backend # Backend
# Debug value for api server use it as 0 for production use # Debug value for api server use it as 0 for production use
DEBUG=0 DEBUG=0
CORS_ALLOWED_ORIGINS="http://localhost" CORS_ALLOWED_ORIGINS=""
ENVIRONMENT="development"
# Error logs # Error logs
SENTRY_DSN="" SENTRY_DSN=""
@ -18,15 +19,6 @@ REDIS_HOST="plane-redis"
REDIS_PORT="6379" REDIS_PORT="6379"
REDIS_URL="redis://${REDIS_HOST}:6379/" REDIS_URL="redis://${REDIS_HOST}:6379/"
# Email Settings
EMAIL_HOST=""
EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587
EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS="1"
EMAIL_USE_SSL="0"
# AWS Settings # AWS Settings
AWS_REGION="" AWS_REGION=""
AWS_ACCESS_KEY_ID="access-key" AWS_ACCESS_KEY_ID="access-key"
@ -38,9 +30,9 @@ AWS_S3_BUCKET_NAME="uploads"
FILE_SIZE_LIMIT=5242880 FILE_SIZE_LIMIT=5242880
# GPT settings # GPT settings
OPENAI_API_BASE="https://api.openai.com/v1" # change if using a custom endpoint OPENAI_API_BASE="https://api.openai.com/v1" # deprecated
OPENAI_API_KEY="sk-" # add your openai key here OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" # use "gpt-4" if you have access GPT_ENGINE="gpt-3.5-turbo" # deprecated
# Github # Github
GITHUB_CLIENT_SECRET="" # For fetching release notes GITHUB_CLIENT_SECRET="" # For fetching release notes
@ -53,9 +45,6 @@ USE_MINIO=1
# Nginx Configuration # Nginx Configuration
NGINX_PORT=80 NGINX_PORT=80
# Default Creds
DEFAULT_EMAIL="captain@plane.so"
DEFAULT_PASSWORD="password123"
# SignUps # SignUps
ENABLE_SIGNUP="1" ENABLE_SIGNUP="1"
@ -70,12 +59,6 @@ ENABLE_MAGIC_LINK_LOGIN="0"
# Email redirections and minio domain settings # Email redirections and minio domain settings
WEB_URL="http://localhost" 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
GUNICORN_WORKERS=2 GUNICORN_WORKERS=2

View File

@ -43,7 +43,7 @@ USER captain
COPY manage.py manage.py COPY manage.py manage.py
COPY plane plane/ COPY plane plane/
COPY templates templates/ COPY templates templates/
COPY package.json package.json
COPY gunicorn.config.py ./ COPY gunicorn.config.py ./
USER root USER root
RUN apk --no-cache add "bash~=5.2" RUN apk --no-cache add "bash~=5.2"

View File

@ -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()

View File

@ -3,8 +3,10 @@ set -e
python manage.py wait_for_db python manage.py wait_for_db
python manage.py migrate python manage.py migrate
# Create a Default User # Register instance
python bin/user_script.py python manage.py register_instance
# Load the configuration variable
python manage.py configure_instance
# Create the default bucket # Create the default bucket
python bin/bucket_script.py python bin/bucket_script.py

View File

@ -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()

4
apiserver/package.json Normal file
View File

@ -0,0 +1,4 @@
{
"name": "plane-api",
"version": "0.13.2"
}

View File

@ -4,6 +4,7 @@ from rest_framework import serializers
# Module import # Module import
from .base import BaseSerializer from .base import BaseSerializer
from plane.db.models import User, Workspace, WorkspaceMemberInvite from plane.db.models import User, Workspace, WorkspaceMemberInvite
from plane.license.models import InstanceAdmin, Instance
class UserSerializer(BaseSerializer): class UserSerializer(BaseSerializer):
@ -86,7 +87,9 @@ class UserMeSettingsSerializer(BaseSerializer):
"last_workspace_id": obj.last_workspace_id, "last_workspace_id": obj.last_workspace_id,
"last_workspace_slug": workspace.slug if workspace is not None else "", "last_workspace_slug": workspace.slug if workspace is not None else "",
"fallback_workspace_id": obj.last_workspace_id, "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, "invites": workspace_invites,
} }
else: else:

View File

@ -49,10 +49,6 @@ urlpatterns = [
*user_urls, *user_urls,
*view_urls, *view_urls,
*workspace_urls, *workspace_urls,
*api_urls,
*webhook_urls,
] ]
if settings.ENABLE_WEBHOOK:
urlpatterns += webhook_urls
if settings.ENABLE_API:
urlpatterns += api_urls

View File

@ -38,6 +38,15 @@ urlpatterns = [
), ),
name="users", name="users",
), ),
path(
"users/me/instance-admin/",
UserEndpoint.as_view(
{
"get": "retrieve_instance_admin",
}
),
name="users",
),
path( path(
"users/me/change-password/", "users/me/change-password/",
ChangePasswordEndpoint.as_view(), ChangePasswordEndpoint.as_view(),

View File

@ -320,11 +320,11 @@ class SignInEndpoint(BaseAPIView):
except RequestException as e: except RequestException as e:
capture_exception(e) capture_exception(e)
access_token, refresh_token = get_tokens_for_user(user)
data = { data = {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_token, "refresh_token": refresh_token,
} }
access_token, refresh_token = get_tokens_for_user(user)
return Response(data, status=status.HTTP_200_OK) return Response(data, status=status.HTTP_200_OK)

View File

@ -51,7 +51,6 @@ class WebhookMixin:
self.webhook_event self.webhook_event
and self.request.method in ["POST", "PATCH", "DELETE"] and self.request.method in ["POST", "PATCH", "DELETE"]
and response.status_code in [200, 201, 204] and response.status_code in [200, 201, 204]
and settings.ENABLE_WEBHOOK
): ):
send_webhook.delay( send_webhook.delay(
event=self.webhook_event, event=self.webhook_event,

View File

@ -12,6 +12,8 @@ from sentry_sdk import capture_exception
# Module imports # Module imports
from .base import BaseAPIView from .base import BaseAPIView
from plane.license.models import Instance, InstanceConfiguration
from plane.license.utils.instance_value import get_configuration_value
class ConfigurationEndpoint(BaseAPIView): class ConfigurationEndpoint(BaseAPIView):
@ -20,18 +22,75 @@ class ConfigurationEndpoint(BaseAPIView):
] ]
def get(self, request): def get(self, request):
instance_configuration = InstanceConfiguration.objects.values("key", "value")
data = {} data = {}
data["google_client_id"] = os.environ.get("GOOGLE_CLIENT_ID", None) # Authentication
data["github_client_id"] = os.environ.get("GITHUB_CLIENT_ID", None) data["google_client_id"] = get_configuration_value(
data["github_app_name"] = os.environ.get("GITHUB_APP_NAME", None) instance_configuration,
data["magic_login"] = ( "GOOGLE_CLIENT_ID",
bool(settings.EMAIL_HOST_USER) and bool(settings.EMAIL_HOST_PASSWORD) os.environ.get("GOOGLE_CLIENT_ID", None),
) and os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0") == "1"
data["email_password_login"] = (
os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1"
) )
data["slack_client_id"] = os.environ.get("SLACK_CLIENT_ID", None) data["github_client_id"] = get_configuration_value(
data["posthog_api_key"] = os.environ.get("POSTHOG_API_KEY", None) instance_configuration,
data["posthog_host"] = os.environ.get("POSTHOG_HOST", None) "GITHUB_CLIENT_ID",
data["has_unsplash_configured"] = bool(settings.UNSPLASH_ACCESS_KEY) 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) return Response(data, status=status.HTTP_200_OK)

View File

@ -2,7 +2,7 @@
import requests import requests
# Third party imports # Third party imports
import openai from openai import OpenAI
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework import status from rest_framework import status
from rest_framework.permissions import AllowAny 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.db.models import Workspace, Project
from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer
from plane.utils.integrations.github import get_release_notes 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): class GPTIntegrationEndpoint(BaseAPIView):
permission_classes = [ permission_classes = [
@ -25,7 +26,14 @@ class GPTIntegrationEndpoint(BaseAPIView):
] ]
def post(self, request, slug, project_id): 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( return Response(
{"error": "OpenAI API key and engine is required"}, {"error": "OpenAI API key and engine is required"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
@ -41,12 +49,17 @@ class GPTIntegrationEndpoint(BaseAPIView):
final_text = task + "\n" + prompt final_text = task + "\n" + prompt
openai.api_key = settings.OPENAI_API_KEY instance_configuration = InstanceConfiguration.objects.values("key", "value")
response = openai.ChatCompletion.create(
model=settings.GPT_ENGINE, 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}], messages=[{"role": "user", "content": final_text}],
temperature=0.7,
max_tokens=1024,
) )
workspace = Workspace.objects.get(slug=slug) workspace = Workspace.objects.get(slug=slug)

View File

@ -427,7 +427,7 @@ class ProjectInvitationsViewset(BaseViewSet):
project_invitations = ProjectMemberInvite.objects.bulk_create( project_invitations = ProjectMemberInvite.objects.bulk_create(
project_invitations, batch_size=10, ignore_conflicts=True 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 # Send invitations
for invitation in project_invitations: for invitation in project_invitations:

View File

@ -14,6 +14,7 @@ from plane.api.serializers import (
from plane.api.views.base import BaseViewSet, BaseAPIView from plane.api.views.base import BaseViewSet, BaseAPIView
from plane.db.models import User, IssueActivity, WorkspaceMember from plane.db.models import User, IssueActivity, WorkspaceMember
from plane.license.models import Instance, InstanceAdmin
from plane.utils.paginator import BasePaginator from plane.utils.paginator import BasePaginator
@ -35,12 +36,17 @@ class UserEndpoint(BaseViewSet):
serialized_data = UserMeSettingsSerializer(request.user).data serialized_data = UserMeSettingsSerializer(request.user).data
return Response(serialized_data, status=status.HTTP_200_OK) 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): def deactivate(self, request):
# Check all workspace user is active # Check all workspace user is active
user = self.get_object() user = self.get_object()
if WorkspaceMember.objects.filter( if WorkspaceMember.objects.filter(member=request.user, is_active=True).exists():
member=request.user, is_active=True
).exists():
return Response( return Response(
{ {
"error": "User cannot deactivate account as user is active in some workspaces" "error": "User cannot deactivate account as user is active in some workspaces"

View File

@ -319,7 +319,7 @@ class WorkspaceInvitationsViewset(BaseViewSet):
workspace_invitations, batch_size=10, ignore_conflicts=True 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 # Send invitations
for invitation in workspace_invitations: for invitation in workspace_invitations:

View File

@ -3,7 +3,7 @@ import csv
import io import io
# Django imports # 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.template.loader import render_to_string
from django.utils.html import strip_tags from django.utils.html import strip_tags
from django.conf import settings from django.conf import settings
@ -16,6 +16,8 @@ from sentry_sdk import capture_exception
from plane.db.models import Issue from plane.db.models import Issue
from plane.utils.analytics_plot import build_graph_plot from plane.utils.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters 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 = { row_mapping = {
"state__name": "State", "state__name": "State",
@ -47,7 +49,19 @@ def send_export_email(email, slug, csv_buffer):
text_content = strip_tags(html_content) text_content = strip_tags(html_content)
csv_buffer.seek(0) 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.attach(f"{slug}-analytics.csv", csv_buffer.getvalue())
msg.send(fail_silently=False) msg.send(fail_silently=False)

View File

@ -1,5 +1,5 @@
# Django imports # 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.template.loader import render_to_string
from django.utils.html import strip_tags from django.utils.html import strip_tags
from django.conf import settings from django.conf import settings
@ -11,8 +11,8 @@ from celery import shared_task
from sentry_sdk import capture_exception from sentry_sdk import capture_exception
# Module imports # 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 @shared_task
def email_verification(first_name, email, token, current_site): 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) 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.attach_alternative(html_content, "text/html")
msg.send() msg.send()
return return

View File

@ -1,5 +1,5 @@
# Django imports # 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.template.loader import render_to_string
from django.utils.html import strip_tags from django.utils.html import strip_tags
from django.conf import settings from django.conf import settings
@ -8,7 +8,9 @@ from django.conf import settings
from celery import shared_task from celery import shared_task
from sentry_sdk import capture_exception 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 @shared_task
def forgot_password(first_name, email, uidb64, token, current_site): 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) 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.attach_alternative(html_content, "text/html")
msg.send() msg.send()
return return

View File

@ -1,5 +1,5 @@
# Django imports # 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.template.loader import render_to_string
from django.utils.html import strip_tags from django.utils.html import strip_tags
from django.conf import settings from django.conf import settings
@ -8,6 +8,9 @@ from django.conf import settings
from celery import shared_task from celery import shared_task
from sentry_sdk import capture_exception 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 @shared_task
def magic_link(email, key, token, current_site): 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}" realtivelink = f"/magic-sign-in/?password={token}&key={key}"
abs_url = current_site + realtivelink abs_url = current_site + realtivelink
from_email_string = settings.EMAIL_FROM
subject = "Login for Plane" subject = "Login for Plane"
context = {"magic_url": abs_url, "code": token} 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) 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.attach_alternative(html_content, "text/html")
msg.send() msg.send()
return return

View File

@ -1,5 +1,5 @@
# Django imports # 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.template.loader import render_to_string
from django.utils.html import strip_tags from django.utils.html import strip_tags
from django.conf import settings from django.conf import settings
@ -10,7 +10,8 @@ from sentry_sdk import capture_exception
# Module imports # Module imports
from plane.db.models import Project, User, ProjectMemberInvite 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 @shared_task
def project_invitation(email, project_id, token, current_site, invitor): 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.message = text_content
project_member_invite.save() 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.attach_alternative(html_content, "text/html")
msg.send() msg.send()
return return

View File

@ -1,5 +1,5 @@
# Django imports # 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.template.loader import render_to_string
from django.utils.html import strip_tags from django.utils.html import strip_tags
from django.conf import settings from django.conf import settings
@ -11,13 +11,14 @@ from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError from slack_sdk.errors import SlackApiError
# Module imports # 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 @shared_task
def workspace_invitation(email, workspace_id, token, current_site, invitor): def workspace_invitation(email, workspace_id, token, current_site, invitor):
try: try:
user = User.objects.get(email=invitor) user = User.objects.get(email=invitor)
workspace = Workspace.objects.get(pk=workspace_id) 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
relative_link = ( relative_link = f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}"
f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}"
)
# The complete url including the domain # The complete url including the domain
abs_url = current_site + relative_link 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.message = text_content
workspace_member_invite.save() 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.attach_alternative(html_content, "text/html")
msg.send() msg.send()

View File

@ -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}"))

View File

View File

View File

@ -0,0 +1 @@
from .instance import InstanceOwnerPermission, InstanceAdminPermission

View File

@ -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()

View File

@ -0,0 +1 @@
from .instance import InstanceSerializer, InstanceAdminSerializer, InstanceConfigurationSerializer

View File

@ -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__"

View File

@ -0,0 +1,6 @@
from .instance import (
InstanceEndpoint,
TransferPrimaryOwnerEndpoint,
InstanceAdminEndpoint,
InstanceConfigurationEndpoint,
)

View File

@ -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)

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class LicenseConfig(AppConfig):
name = "plane.license"

View File

@ -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"))

View File

@ -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

View File

@ -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',),
},
),
]

View File

@ -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')},
),
]

View File

@ -0,0 +1 @@
from .instance import Instance, InstanceAdmin, InstanceConfiguration

View File

@ -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",)

View File

@ -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/<uuid:pk>/",
InstanceAdminEndpoint.as_view(),
name="instance-admins",
),
path(
"instances/configurations/",
InstanceConfigurationEndpoint.as_view(),
name="instance-configuration",
),
]

View File

@ -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

View File

@ -5,6 +5,7 @@ import ssl
import certifi import certifi
from datetime import timedelta from datetime import timedelta
from urllib.parse import urlparse from urllib.parse import urlparse
# Django imports # Django imports
from django.core.management.utils import get_random_secret_key from django.core.management.utils import get_random_secret_key
@ -26,12 +27,6 @@ DEBUG = False
# Allowed Hosts # Allowed Hosts
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 # Redirect if / is not present
APPEND_SLASH = True APPEND_SLASH = True
@ -48,6 +43,7 @@ INSTALLED_APPS = [
"plane.utils", "plane.utils",
"plane.web", "plane.web",
"plane.middleware", "plane.middleware",
"plane.license",
"plane.proxy", "plane.proxy",
# Third-party things # Third-party things
"rest_framework", "rest_framework",
@ -118,7 +114,13 @@ CSRF_COOKIE_SECURE = True
# CORS Settings # CORS Settings
CORS_ALLOW_CREDENTIALS = True 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 # Application Settings
WSGI_APPLICATION = "plane.wsgi.application" WSGI_APPLICATION = "plane.wsgi.application"
@ -212,16 +214,6 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Email settings # Email settings
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" 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 <team@mailer.plane.so>")
# Storage Settings # Storage Settings
STORAGES = { STORAGES = {
@ -229,7 +221,9 @@ STORAGES = {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", "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_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_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key")
AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads") 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}:" AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:"
# JWT Auth Configuration # JWT Auth Configuration
SIMPLE_JWT = { SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=10080), "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_SECRET_KEY = os.environ.get("ANALYTICS_SECRET_KEY", False)
ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False) ANALYTICS_BASE_API = os.environ.get("ANALYTICS_BASE_API", False)
# Open AI Settings # Use Minio 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 = int(os.environ.get("USE_MINIO", 0)) == 1 USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1

View File

@ -3,10 +3,6 @@ from .common import * # noqa
DEBUG = True DEBUG = True
ALLOWED_HOSTS = [
"*",
]
# Debug Toolbar settings # Debug Toolbar settings
INSTALLED_APPS += ("debug_toolbar",) INSTALLED_APPS += ("debug_toolbar",)
MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",) MIDDLEWARE += ("debug_toolbar.middleware.DebugToolbarMiddleware",)
@ -24,13 +20,9 @@ CACHES = {
INTERNAL_IPS = ("127.0.0.1",) INTERNAL_IPS = ("127.0.0.1",)
CORS_ORIGIN_ALLOW_ALL = True
MEDIA_URL = "/uploads/" MEDIA_URL = "/uploads/"
MEDIA_ROOT = os.path.join(BASE_DIR, "uploads") MEDIA_ROOT = os.path.join(BASE_DIR, "uploads")
# For local settings
CORS_ALLOW_ALL_ORIGINS = True
CORS_ALLOWED_ORIGINS = [ CORS_ALLOWED_ORIGINS = [
"http://localhost:3000", "http://localhost:3000",
"http://127.0.0.1:3000", "http://127.0.0.1:3000",

View File

@ -11,3 +11,8 @@ INSTALLED_APPS += ("scout_apm.django",)
# Honor the 'X-Forwarded-Proto' header for request.is_secure() # Honor the 'X-Forwarded-Proto' header for request.is_secure()
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") 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"

View File

@ -11,11 +11,11 @@ from django.conf import settings
urlpatterns = [ urlpatterns = [
path("", TemplateView.as_view(template_name="index.html")), path("", TemplateView.as_view(template_name="index.html")),
path("api/", include("plane.api.urls")), 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")), path("", include("plane.web.urls")),
] ]
if settings.ENABLE_API:
urlpatterns += path("api/v1/", include("plane.proxy.urls")),
if settings.DEBUG: if settings.DEBUG:
import debug_toolbar import debug_toolbar

View File

@ -26,7 +26,7 @@ google-api-python-client==2.97.0
django-redis==5.3.0 django-redis==5.3.0
uvicorn==0.23.2 uvicorn==0.23.2
channels==4.0.0 channels==4.0.0
openai==0.28.0 openai==1.2.4
slack-sdk==3.21.3 slack-sdk==3.21.3
celery==5.3.4 celery==5.3.4
django_celery_beat==2.5.0 django_celery_beat==2.5.0

View File

@ -5,18 +5,15 @@ x-app-env : &app-env
- NGINX_PORT=${NGINX_PORT:-80} - NGINX_PORT=${NGINX_PORT:-80}
- WEB_URL=${WEB_URL:-http://localhost} - WEB_URL=${WEB_URL:-http://localhost}
- DEBUG=${DEBUG:-0} - 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_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0}
- NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} - NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces}
- SENTRY_DSN=${SENTRY_DSN:-""} - SENTRY_DSN=${SENTRY_DSN:-""}
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1} - DOCKERIZED=${DOCKERIZED:-1} # deprecated
# BASE WEBHOOK - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""}
- ENABLE_WEBHOOK=${ENABLE_WEBHOOK:-1} - ENVIRONMENT=${ENVIRONMENT:-"production"}
# BASE API # Gunicorn Workers
- ENABLE_API=${ENABLE_API:-1}
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-http://localhost}
# Gunicorn Workers
- GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
#DB SETTINGS #DB SETTINGS
- PGHOST=${PGHOST:-plane-db} - PGHOST=${PGHOST:-plane-db}
@ -40,7 +37,7 @@ x-app-env : &app-env
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0} - EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so} - DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} - 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_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-"sk-"} - OPENAI_API_KEY=${OPENAI_API_KEY:-"sk-"}
- GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"} - GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"}

View File

@ -7,18 +7,14 @@ API_REPLICAS=1
NGINX_PORT=80 NGINX_PORT=80
WEB_URL=http://localhost WEB_URL=http://localhost
DEBUG=0 DEBUG=0
DJANGO_SETTINGS_MODULE=plane.settings.selfhosted DJANGO_SETTINGS_MODULE=plane.settings.selfhosted # deprecated
NEXT_PUBLIC_ENABLE_OAUTH=0 NEXT_PUBLIC_ENABLE_OAUTH=0
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
SENTRY_DSN="" SENTRY_DSN=""
GITHUB_CLIENT_SECRET="" GITHUB_CLIENT_SECRET=""
DOCKERIZED=1 DOCKERIZED=1 # deprecated
CORS_ALLOWED_ORIGINS="http://localhost" CORS_ALLOWED_ORIGINS=""
ENVIRONMENT="production"
# Webhook
ENABLE_WEBHOOK=1
# API
ENABLE_API=1
#DB SETTINGS #DB SETTINGS
PGHOST=plane-db PGHOST=plane-db
@ -42,13 +38,11 @@ EMAIL_PORT=587
EMAIL_FROM="Team Plane &lt;team@mailer.plane.so&gt;" EMAIL_FROM="Team Plane &lt;team@mailer.plane.so&gt;"
EMAIL_USE_TLS=1 EMAIL_USE_TLS=1
EMAIL_USE_SSL=0 EMAIL_USE_SSL=0
DEFAULT_EMAIL=captain@plane.so
DEFAULT_PASSWORD=password123
# OPENAI SETTINGS # OPENAI SETTINGS
OPENAI_API_BASE=https://api.openai.com/v1 OPENAI_API_BASE=https://api.openai.com/v1 # deprecated
OPENAI_API_KEY="sk-" OPENAI_API_KEY="sk-" # deprecated
GPT_ENGINE="gpt-3.5-turbo" GPT_ENGINE="gpt-3.5-turbo" # deprecated
# LOGIN/SIGNUP SETTINGS # LOGIN/SIGNUP SETTINGS
ENABLE_SIGNUP=1 ENABLE_SIGNUP=1

View File

@ -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<IInstanceGeneralForm> = (props) => {
const { instance } = props;
// store
const { instance: instanceStore } = useMobxStore();
// toast
const { setToastAlert } = useToast();
// form data
const {
handleSubmit,
control,
formState: { errors, isSubmitting },
} = useForm<GeneralFormValues>({
defaultValues: {
instance_name: instance.instance_name,
is_telemetry_enabled: instance.is_telemetry_enabled,
},
});
const onSubmit = async (formData: GeneralFormValues) => {
const payload: Partial<GeneralFormValues> = { ...formData };
await instanceStore
.updateInstanceInfo(payload)
.then(() =>
setToastAlert({
title: "Success",
type: "success",
message: "Settings updated successfully",
})
)
.catch((err) => console.error(err));
};
return (
<div className="flex flex-col gap-8 m-8">
<div className="grid grid-col grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 items-center justify-between gap-8 w-full">
<div className="flex flex-col gap-1">
<h4 className="text-sm">Name of instance</h4>
<Controller
control={control}
name="instance_name"
render={({ field: { value, onChange, ref } }) => (
<Input
id="instance_name"
name="instance_name"
type="text"
value={value}
onChange={onChange}
ref={ref}
hasError={Boolean(errors.instance_name)}
placeholder="Instance Name"
className="rounded-md font-medium w-full"
/>
)}
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Admin Email</h4>
<Input
id="primary_email"
name="primary_email"
type="email"
value={instance.primary_email}
placeholder="Admin Email"
className="w-full cursor-not-allowed !text-custom-text-400"
disabled
/>
</div>
<div className="flex flex-col gap-1">
<h4 className="text-sm">Instance Id</h4>
<Input
id="instance_id"
name="instance_id"
type="text"
value={instance.instance_id}
className="rounded-md font-medium w-full cursor-not-allowed !text-custom-text-400"
disabled
/>
</div>
</div>
<div className="flex items-center gap-8 pt-4">
<div>
<div className="text-custom-text-100 font-medium text-sm">Share anonymous usage instance</div>
<div className="text-custom-text-300 font-normal text-xs">
Help us understand how you use Plane so we can build better for you.
</div>
</div>
<div>
<Controller
control={control}
name="is_telemetry_enabled"
render={({ field: { value, onChange } }) => <ToggleSwitch value={value} onChange={onChange} size="sm" />}
/>
</div>
</div>
<div className="flex items-center py-1">
<Button variant="primary" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
);
};

View File

@ -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<HTMLDivElement | null>(null);
return (
<div
className={`flex w-full items-center justify-between gap-1 self-baseline border-t border-custom-border-200 bg-custom-sidebar-background-100 py-2 px-4 ${
sidebarCollapsed ? "flex-col" : ""
}`}
>
<div className={`flex items-center gap-1 ${sidebarCollapsed ? "flex-col justify-center" : "justify-end w-full"}`}>
<button
type="button"
className={`grid place-items-center rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none ${
sidebarCollapsed ? "w-full" : ""
}`}
onClick={() => setIsNeedHelpOpen((prev) => !prev)}
>
<HelpCircle className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="grid place-items-center rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none md:hidden"
onClick={() => toggleSidebar()}
>
<MoveLeft className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={`hidden md:grid place-items-center rounded-md p-1.5 text-custom-text-200 hover:text-custom-text-100 hover:bg-custom-background-90 outline-none ${
sidebarCollapsed ? "w-full" : ""
}`}
onClick={() => toggleSidebar()}
>
<MoveLeft className={`h-3.5 w-3.5 duration-300 ${sidebarCollapsed ? "rotate-180" : ""}`} />
</button>
</div>
<div className="relative">
<Transition
show={isNeedHelpOpen}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<div
className={`absolute bottom-2 min-w-[10rem] ${
sidebarCollapsed ? "left-full" : "-left-[75px]"
} rounded bg-custom-background-100 p-1 shadow-custom-shadow-xs whitespace-nowrap divide-y divide-custom-border-200`}
ref={helpOptionsRef}
>
<div className="space-y-1 pb-2">
{helpOptions.map(({ name, Icon, href, onClick }) => {
if (href)
return (
<Link href={href} key={name}>
<a
target="_blank"
className="flex items-center gap-x-2 rounded px-2 py-1 text-xs hover:bg-custom-background-80"
>
<div className="grid place-items-center flex-shrink-0">
<Icon className="text-custom-text-200 h-3.5 w-3.5" size={14} />
</div>
<span className="text-xs">{name}</span>
</a>
</Link>
);
else
return (
<button
key={name}
type="button"
onClick={onClick ?? undefined}
className="flex w-full items-center gap-x-2 rounded px-2 py-1 text-xs hover:bg-custom-background-80"
>
<div className="grid place-items-center flex-shrink-0">
<Icon className="text-custom-text-200 h-3.5 w-3.5" />
</div>
<span className="text-xs">{name}</span>
</button>
);
})}
</div>
<div className="px-2 pt-2 pb-1 text-[10px]">Version: v{packageJson.version}</div>
</div>
</Transition>
</div>
</div>
);
};

View File

@ -0,0 +1,4 @@
export * from "./help-section";
export * from "./sidebar-menu";
export * from "./sidebar-dropdown";
export * from "./general-form";

View File

@ -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 (
<div className="flex items-center gap-x-3 gap-y-2 px-4 py-4">
<div className="w-full h-full truncate">
<div
className={`flex flex-grow items-center gap-x-2 rounded p-1 truncate ${
sidebarCollapsed ? "justify-center" : ""
}`}
>
<div className={`flex-shrink-0 `}>
<Shield className="h-6 w-6 text-custom-text-100" />
</div>
{!sidebarCollapsed && (
<h4 className="text-custom-text-100 font-medium text-base truncate">Instance Admin Settings</h4>
)}
</div>
</div>
{!sidebarCollapsed && (
<Menu as="div" className="relative flex-shrink-0">
<Menu.Button className="grid place-items-center outline-none">
<Avatar
name={currentUser?.display_name}
src={currentUser?.avatar}
size={24}
shape="square"
className="!text-base"
/>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items
className="absolute left-0 z-20 mt-1.5 flex flex-col w-52 origin-top-left rounded-md
border border-custom-sidebar-border-200 bg-custom-sidebar-background-100 px-1 py-2 divide-y divide-custom-sidebar-border-200 shadow-lg text-xs outline-none"
>
<div className="flex flex-col gap-2.5 pb-2">
<span className="px-2 text-custom-sidebar-text-200">{currentUser?.email}</span>
{profileLinks(workspaceSlug?.toString() ?? "", currentUser?.id ?? "").map((link, index) => (
<Menu.Item key={index} as="button" type="button">
<Link href={link.link}>
<a className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80">
<link.icon className="h-4 w-4 stroke-[1.5]" />
{link.name}
</a>
</Link>
</Menu.Item>
))}
</div>
<div className="py-2">
<Menu.Item
as="button"
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1 hover:bg-custom-sidebar-background-80"
onClick={handleSignOut}
>
<LogOut className="h-4 w-4 stroke-[1.5]" />
Sign out
</Menu.Item>
</div>
<div className="p-2 pb-0">
<Menu.Item as="button" type="button" className="w-full">
<Link href={redirectWorkspaceSlug}>
<a className="flex w-full items-center justify-center rounded px-2 py-1 text-sm font-medium text-custom-primary-100 hover:text-custom-primary-200 bg-custom-primary-10 hover:bg-custom-primary-20">
Normal Mode
</a>
</Link>
</Menu.Item>
</div>
</Menu.Items>
</Transition>
</Menu>
)}
</div>
);
});

View File

@ -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 (
<div className="h-full overflow-y-auto w-full cursor-pointer space-y-2 p-4">
{INSTANCE_ADMIN_LINKS.map((item, index) => {
const isActive = item.name === "Settings" ? router.asPath.includes(item.href) : router.asPath === item.href;
return (
<Link key={index} href={item.href}>
<a className="block w-full">
<Tooltip tooltipContent={item.name} position="right" className="ml-2" disabled={!sidebarCollapsed}>
<div
className={`group flex w-full items-center gap-2.5 rounded-md px-3 py-2 text-sm font-medium outline-none ${
isActive
? "bg-custom-primary-100/10 text-custom-primary-100"
: "text-custom-sidebar-text-200 hover:bg-custom-sidebar-background-80 focus:bg-custom-sidebar-background-80"
} ${sidebarCollapsed ? "justify-center" : ""}`}
>
{<item.Icon className="h-4 w-4" />}
{!sidebarCollapsed && item.name}
</div>
</Tooltip>
</a>
</Link>
);
})}
</div>
);
};

View File

@ -53,7 +53,7 @@ export const WorkspaceSidebarDropdown = observer(() => {
const { const {
theme: { sidebarCollapsed }, theme: { sidebarCollapsed },
workspace: { workspaces, currentWorkspace: activeWorkspace }, workspace: { workspaces, currentWorkspace: activeWorkspace },
user: { currentUser, updateCurrentUser }, user: { currentUser, updateCurrentUser, isUserInstanceAdmin },
} = useMobxStore(); } = useMobxStore();
// hooks // hooks
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -286,7 +286,7 @@ export const WorkspaceSidebarDropdown = observer(() => {
</Menu.Item> </Menu.Item>
))} ))}
</div> </div>
<div className="pt-2"> <div className="py-2">
<Menu.Item <Menu.Item
as="button" as="button"
type="button" type="button"
@ -297,6 +297,17 @@ export const WorkspaceSidebarDropdown = observer(() => {
Sign out Sign out
</Menu.Item> </Menu.Item>
</div> </div>
{isUserInstanceAdmin && (
<div className="p-2 pb-0">
<Menu.Item as="button" type="button" className="w-full">
<Link href="/admin">
<a className="flex w-full items-center justify-center rounded px-2 py-1 text-sm font-medium text-custom-primary-100 hover:text-custom-primary-200 bg-custom-primary-10 hover:bg-custom-primary-20">
God Mode
</a>
</Link>
</Menu.Item>
</div>
)}
</Menu.Items> </Menu.Items>
</Transition> </Transition>
</Menu> </Menu>

View File

@ -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 (
<div className="relative flex w-full flex-shrink-0 flex-row z-10 h-[3.75rem] items-center justify-between gap-x-2 gap-y-4 border-b border-custom-border-200 bg-custom-sidebar-background-100 p-4">
<div className="flex items-center gap-2 flex-grow w-full whitespace-nowrap overflow-ellipsis">
<div>
<Breadcrumbs>
<Breadcrumbs.BreadcrumbItem
type="text"
icon={<Settings className="h-4 w-4 text-custom-text-300" />}
label="General"
/>
</Breadcrumbs>
</div>
</div>
<div className="flex-shrink-0">
<Link href={redirectWorkspaceSlug}>
<a>
<ArrowLeftToLine className="h-4 w-4 text-custom-text-300" />
</a>
</Link>
</div>
</div>
);
});

View File

@ -0,0 +1,3 @@
export * from "./layout";
export * from "./sidebar";
export * from "./header";

View File

@ -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<IInstanceAdminLayout> = (props) => {
const { children } = props;
return (
<>
<UserAuthWrapper>
<div className="relative flex h-screen w-full overflow-hidden">
<InstanceAdminSidebar />
<main className="relative flex flex-col h-full w-full overflow-hidden bg-custom-background-100">
<InstanceAdminHeader />
<div className="h-full w-full overflow-hidden">
<div className="relative h-full w-full overflow-x-hidden overflow-y-scroll">
<>{children}</>
</div>
</div>
</main>
</div>
</UserAuthWrapper>
</>
);
};

View File

@ -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<IInstanceAdminSidebar> = observer(() => {
// store
const { theme: themStore } = useMobxStore();
return (
<div
id="app-sidebar"
className={`fixed md:relative inset-y-0 flex flex-col bg-custom-sidebar-background-100 h-full flex-shrink-0 flex-grow-0 border-r border-custom-sidebar-border-200 z-20 duration-300 ${
themStore?.sidebarCollapsed ? "" : "md:w-[280px]"
} ${themStore?.sidebarCollapsed ? "left-0" : "-left-full md:left-0"}`}
>
<div className="flex h-full w-full flex-1 flex-col">
<InstanceSidebarDropdown />
<InstanceAdminSidebarMenu />
<InstanceHelpSection />
</div>
</div>
);
});

View File

@ -14,7 +14,7 @@ export const UserAuthWrapper: FC<IUserAuthWrapper> = (props) => {
const { children } = props; const { children } = props;
// store // store
const { const {
user: { fetchCurrentUser, fetchCurrentUserSettings }, user: { fetchCurrentUser, fetchCurrentUserInstanceAdminStatus, fetchCurrentUserSettings },
workspace: { fetchWorkspaces }, workspace: { fetchWorkspaces },
} = useMobxStore(); } = useMobxStore();
// router // router
@ -23,6 +23,10 @@ export const UserAuthWrapper: FC<IUserAuthWrapper> = (props) => {
const { data: currentUser, error } = useSWR("CURRENT_USER_DETAILS", () => fetchCurrentUser(), { const { data: currentUser, error } = useSWR("CURRENT_USER_DETAILS", () => fetchCurrentUser(), {
shouldRetryOnError: false, shouldRetryOnError: false,
}); });
// fetching current user instance admin status
useSWR("CURRENT_USER_INSTANCE_ADMIN_STATUS", () => fetchCurrentUserInstanceAdminStatus(), {
shouldRetryOnError: false,
});
// fetching user settings // fetching user settings
useSWR("CURRENT_USER_SETTINGS", () => fetchCurrentUserSettings(), { useSWR("CURRENT_USER_SETTINGS", () => fetchCurrentUserSettings(), {
shouldRetryOnError: false, shouldRetryOnError: false,

16
web/pages/admin/ai.tsx Normal file
View File

@ -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 <div>Admin AI Page</div>;
};
InstanceAdminAIPage.getLayout = function getLayout(page: ReactElement) {
return <InstanceAdminLayout>{page}</InstanceAdminLayout>;
};
export default InstanceAdminAIPage;

16
web/pages/admin/email.tsx Normal file
View File

@ -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 <div>Admin Email Page</div>;
};
InstanceAdminEmailPage.getLayout = function getLayout(page: ReactElement) {
return <InstanceAdminLayout>{page}</InstanceAdminLayout>;
};
export default InstanceAdminEmailPage;

28
web/pages/admin/index.tsx Normal file
View File

@ -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 <div>{instance && <InstanceGeneralForm instance={instance} />}</div>;
});
InstanceAdminPage.getLayout = function getLayout(page: ReactElement) {
return <InstanceAdminLayout>{page}</InstanceAdminLayout>;
};
export default InstanceAdminPage;

16
web/pages/admin/oauth.tsx Normal file
View File

@ -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 <div>Admin oauth Page</div>;
};
InstanceAdminOAuthPage.getLayout = function getLayout(page: ReactElement) {
return <InstanceAdminLayout>{page}</InstanceAdminLayout>;
};
export default InstanceAdminOAuthPage;

View File

@ -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<IInstance> {
return this.get("/api/licenses/instances/")
.then((response) => response.data)
.catch((error) => {
throw error;
});
}
async updateInstanceInfo(
data: Partial<IInstance>
): Promise<IInstance> {
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;
});
}
}

View File

@ -6,6 +6,7 @@ import type {
IIssue, IIssue,
IUser, IUser,
IUserActivityResponse, IUserActivityResponse,
IInstanceAdminStatus,
IUserProfileData, IUserProfileData,
IUserProfileProjectSegregation, IUserProfileProjectSegregation,
IUserSettings, IUserSettings,
@ -54,6 +55,14 @@ export class UserService extends APIService {
}); });
} }
async currentUserInstanceAdminStatus(): Promise<IInstanceAdminStatus> {
return this.get("/api/users/me/instance-admin/")
.then((respone) => respone?.data)
.catch((error) => {
throw error?.response;
});
}
async currentUserSettings(): Promise<IUserSettings> { async currentUserSettings(): Promise<IUserSettings> {
return this.get("/api/users/me/settings/") return this.get("/api/users/me/settings/")
.then((response) => response?.data) .then((response) => response?.data)

View File

@ -96,7 +96,7 @@ export class WorkspaceService extends APIService {
} }
async joinWorkspace(workspaceSlug: string, invitationId: string, data: any, user: IUser | undefined): Promise<any> { async joinWorkspace(workspaceSlug: string, invitationId: string, data: any, user: IUser | undefined): Promise<any> {
return this.post(`/api/users/me/invitations/workspaces/${workspaceSlug}/${invitationId}/join/`, data, { return this.post(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/join/`, data, {
headers: {}, headers: {},
}) })
.then((response) => { .then((response) => {
@ -109,7 +109,7 @@ export class WorkspaceService extends APIService {
} }
async joinWorkspaces(data: any): Promise<any> { async joinWorkspaces(data: any): Promise<any> {
return this.post("/api/users/me/invitations/workspaces/", data) return this.post("/api/users/me/workspaces/invitations/", data)
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
throw error?.response?.data; throw error?.response?.data;
@ -125,7 +125,7 @@ export class WorkspaceService extends APIService {
} }
async userWorkspaceInvitations(): Promise<IWorkspaceMemberInvitation[]> { async userWorkspaceInvitations(): Promise<IWorkspaceMemberInvitation[]> {
return this.get("/api/users/me/invitations/workspaces/") return this.get("/api/users/me/workspaces/invitations/")
.then((response) => response?.data) .then((response) => response?.data)
.catch((error) => { .catch((error) => {
throw error?.response?.data; throw error?.response?.data;

View File

@ -0,0 +1 @@
export * from "./instance.store";

View File

@ -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<IInstance>;
updateInstanceInfo: (data: Partial<IInstance>) => Promise<IInstance>;
fetchInstanceConfigurations: () => Promise<any>;
}
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<IInstance>) => {
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;
}
};
}

View File

@ -1,5 +1,6 @@
import { enableStaticRendering } from "mobx-react-lite"; import { enableStaticRendering } from "mobx-react-lite";
// store imports // store imports
import { InstanceStore, IInstanceStore } from "./instance";
import AppConfigStore, { IAppConfigStore } from "./app-config.store"; import AppConfigStore, { IAppConfigStore } from "./app-config.store";
import CommandPaletteStore, { ICommandPaletteStore } from "./command-palette.store"; import CommandPaletteStore, { ICommandPaletteStore } from "./command-palette.store";
import UserStore, { IUserStore } from "store/user.store"; import UserStore, { IUserStore } from "store/user.store";
@ -116,6 +117,8 @@ import { IMentionsStore, MentionsStore } from "store/editor";
enableStaticRendering(typeof window === "undefined"); enableStaticRendering(typeof window === "undefined");
export class RootStore { export class RootStore {
instance: IInstanceStore;
user: IUserStore; user: IUserStore;
theme: IThemeStore; theme: IThemeStore;
appConfig: IAppConfigStore; appConfig: IAppConfigStore;
@ -184,6 +187,8 @@ export class RootStore {
mentionsStore: IMentionsStore; mentionsStore: IMentionsStore;
constructor() { constructor() {
this.instance = new InstanceStore(this);
this.appConfig = new AppConfigStore(this); this.appConfig = new AppConfigStore(this);
this.commandPalette = new CommandPaletteStore(this); this.commandPalette = new CommandPaletteStore(this);
this.user = new UserStore(this); this.user = new UserStore(this);

View File

@ -14,6 +14,7 @@ export interface IUserStore {
isUserLoggedIn: boolean | null; isUserLoggedIn: boolean | null;
currentUser: IUser | null; currentUser: IUser | null;
isUserInstanceAdmin: boolean | null;
currentUserSettings: IUserSettings | null; currentUserSettings: IUserSettings | null;
dashboardInfo: any; dashboardInfo: any;
@ -41,6 +42,7 @@ export interface IUserStore {
hasPermissionToCurrentProject: boolean | undefined; hasPermissionToCurrentProject: boolean | undefined;
fetchCurrentUser: () => Promise<IUser>; fetchCurrentUser: () => Promise<IUser>;
fetchCurrentUserInstanceAdminStatus: () => Promise<boolean>;
fetchCurrentUserSettings: () => Promise<IUserSettings>; fetchCurrentUserSettings: () => Promise<IUserSettings>;
fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise<IWorkspaceMemberMe>; fetchUserWorkspaceInfo: (workspaceSlug: string) => Promise<IWorkspaceMemberMe>;
@ -58,6 +60,7 @@ class UserStore implements IUserStore {
isUserLoggedIn: boolean | null = null; isUserLoggedIn: boolean | null = null;
currentUser: IUser | null = null; currentUser: IUser | null = null;
isUserInstanceAdmin: boolean | null = null;
currentUserSettings: IUserSettings | null = null; currentUserSettings: IUserSettings | null = null;
dashboardInfo: any = null; dashboardInfo: any = null;
@ -87,7 +90,9 @@ class UserStore implements IUserStore {
makeObservable(this, { makeObservable(this, {
// observable // observable
loader: observable.ref, loader: observable.ref,
isUserLoggedIn: observable.ref,
currentUser: observable.ref, currentUser: observable.ref,
isUserInstanceAdmin: observable.ref,
currentUserSettings: observable.ref, currentUserSettings: observable.ref,
dashboardInfo: observable.ref, dashboardInfo: observable.ref,
workspaceMemberInfo: observable.ref, workspaceMemberInfo: observable.ref,
@ -96,6 +101,7 @@ class UserStore implements IUserStore {
hasPermissionToProject: observable.ref, hasPermissionToProject: observable.ref,
// action // action
fetchCurrentUser: action, fetchCurrentUser: action,
fetchCurrentUserInstanceAdminStatus: action,
fetchCurrentUserSettings: action, fetchCurrentUserSettings: action,
fetchUserDashboardInfo: action, fetchUserDashboardInfo: action,
fetchUserWorkspaceInfo: 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 () => { fetchCurrentUserSettings = async () => {
try { try {
const response = await this.userService.currentUserSettings(); const response = await this.userService.currentUserSettings();

22
web/types/instance.d.ts vendored Normal file
View File

@ -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;
}

View File

@ -29,6 +29,10 @@ export interface IUser {
theme: IUserTheme; theme: IUserTheme;
} }
export interface IInstanceAdminStatus {
is_instance_admin: boolean;
}
export interface IUserSettings { export interface IUserSettings {
id: string; id: string;
email: string; email: string;