diff --git a/apiserver/bin/takeoff b/apiserver/bin/takeoff index 891ec1472..0ec2e495c 100755 --- a/apiserver/bin/takeoff +++ b/apiserver/bin/takeoff @@ -27,4 +27,4 @@ python manage.py configure_instance # Create the default bucket python manage.py create_bucket -exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:8000 --max-requests 1200 --max-requests-jitter 1000 --access-logfile - +exec gunicorn -w $GUNICORN_WORKERS -k uvicorn.workers.UvicornWorker plane.asgi:application --bind 0.0.0.0:${PORT:-8000} --max-requests 1200 --max-requests-jitter 1000 --access-logfile - diff --git a/apiserver/plane/app/serializers/workspace.py b/apiserver/plane/app/serializers/workspace.py index 48a4bc44e..f0ad4b4ab 100644 --- a/apiserver/plane/app/serializers/workspace.py +++ b/apiserver/plane/app/serializers/workspace.py @@ -95,6 +95,16 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer): class Meta: model = WorkspaceMemberInvite fields = "__all__" + read_only_fields = [ + "id", + "email", + "token", + "workspace", + "message", + "responded_at", + "created_at", + "updated_at", + ] class TeamSerializer(BaseSerializer): diff --git a/apiserver/plane/app/urls/workspace.py b/apiserver/plane/app/urls/workspace.py index 739d17c55..2c3638842 100644 --- a/apiserver/plane/app/urls/workspace.py +++ b/apiserver/plane/app/urls/workspace.py @@ -65,6 +65,7 @@ urlpatterns = [ { "delete": "destroy", "get": "retrieve", + "patch": "partial_update", } ), name="workspace-invitations", diff --git a/apiserver/plane/app/views/auth_extended.py b/apiserver/plane/app/views/auth_extended.py index b169fde65..049e5aab9 100644 --- a/apiserver/plane/app/views/auth_extended.py +++ b/apiserver/plane/app/views/auth_extended.py @@ -34,7 +34,7 @@ from plane.app.serializers import ( from plane.db.models import User, WorkspaceMemberInvite from plane.license.utils.instance_value import get_configuration_value from plane.bgtasks.forgot_password_task import forgot_password -from plane.license.models import Instance, InstanceConfiguration +from plane.license.models import Instance from plane.settings.redis import redis_instance from plane.bgtasks.magic_link_code_task import magic_link from plane.bgtasks.event_tracking_task import auth_events @@ -321,8 +321,19 @@ class EmailCheckEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - # Get the configurations - instance_configuration = InstanceConfiguration.objects.values("key", "value") + # Get configuration values + ENABLE_SIGNUP, ENABLE_MAGIC_LINK_LOGIN = get_configuration_value( + [ + { + "key": "ENABLE_SIGNUP", + "default": os.environ.get("ENABLE_SIGNUP"), + }, + { + "key": "ENABLE_MAGIC_LINK_LOGIN", + "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN"), + }, + ] + ) email = request.data.get("email", False) @@ -347,12 +358,7 @@ class EmailCheckEndpoint(BaseAPIView): if user is None: # Create the user if ( - get_configuration_value( - instance_configuration, - "ENABLE_SIGNUP", - os.environ.get("ENABLE_SIGNUP", "0"), - ) - == "0" + ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter( email=email, ).exists() @@ -372,13 +378,8 @@ class EmailCheckEndpoint(BaseAPIView): is_password_autoset=True, ) - if not bool( - get_configuration_value( - instance_configuration, - "ENABLE_MAGIC_LINK_LOGIN", - os.environ.get("ENABLE_MAGIC_LINK_LOGIN"), - ), + ENABLE_MAGIC_LINK_LOGIN, ): return Response( {"error": "Magic link sign in is disabled."}, @@ -386,16 +387,15 @@ class EmailCheckEndpoint(BaseAPIView): ) # Send event - if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", - first_time=True, - ) + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium="MAGIC_LINK", + first_time=True, + ) key, token, current_attempt = generate_magic_token(email=email) if not current_attempt: return Response( @@ -413,28 +413,21 @@ class EmailCheckEndpoint(BaseAPIView): else: if user.is_password_autoset: ## Generate a random token - if not bool( - get_configuration_value( - instance_configuration, - "ENABLE_MAGIC_LINK_LOGIN", - os.environ.get("ENABLE_MAGIC_LINK_LOGIN"), - ), - ): + if not bool(ENABLE_MAGIC_LINK_LOGIN): return Response( {"error": "Magic link sign in is disabled."}, status=status.HTTP_400_BAD_REQUEST, ) - if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", - first_time=False, - ) + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium="MAGIC_LINK", + first_time=False, + ) # Generate magic token key, token, current_attempt = generate_magic_token(email=email) @@ -454,16 +447,15 @@ class EmailCheckEndpoint(BaseAPIView): status=status.HTTP_200_OK, ) else: - if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="EMAIL", - first_time=False, - ) + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium="EMAIL", + first_time=False, + ) # User should enter password to login return Response( diff --git a/apiserver/plane/app/views/authentication.py b/apiserver/plane/app/views/authentication.py index 32b8a34f4..811eeb959 100644 --- a/apiserver/plane/app/views/authentication.py +++ b/apiserver/plane/app/views/authentication.py @@ -27,7 +27,7 @@ from plane.db.models import ( ProjectMember, ) from plane.settings.redis import redis_instance -from plane.license.models import InstanceConfiguration, Instance +from plane.license.models import Instance from plane.license.utils.instance_value import get_configuration_value from plane.bgtasks.event_tracking_task import auth_events @@ -52,8 +52,6 @@ class SignUpEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - instance_configuration = InstanceConfiguration.objects.values("key", "value") - email = request.data.get("email", False) password = request.data.get("password", False) ## Raise exception if any of the above are missing @@ -73,14 +71,20 @@ class SignUpEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) + # get configuration values + # Get configuration values + ENABLE_SIGNUP, = get_configuration_value( + [ + { + "key": "ENABLE_SIGNUP", + "default": os.environ.get("ENABLE_SIGNUP"), + }, + ] + ) + # If the sign up is not enabled and the user does not have invite disallow him from creating the account if ( - get_configuration_value( - instance_configuration, - "ENABLE_SIGNUP", - os.environ.get("ENABLE_SIGNUP", "0"), - ) - == "0" + ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter( email=email, ).exists() @@ -169,16 +173,17 @@ class SignInEndpoint(BaseAPIView): # Create the user else: - # Get the configurations - instance_configuration = InstanceConfiguration.objects.values("key", "value") + ENABLE_SIGNUP, = get_configuration_value( + [ + { + "key": "ENABLE_SIGNUP", + "default": os.environ.get("ENABLE_SIGNUP"), + }, + ] + ) # Create the user if ( - get_configuration_value( - instance_configuration, - "ENABLE_SIGNUP", - os.environ.get("ENABLE_SIGNUP", "0"), - ) - == "0" + ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter( email=email, ).exists() @@ -264,16 +269,15 @@ class SignInEndpoint(BaseAPIView): workspace_member_invites.delete() project_member_invites.delete() # Send event - if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="EMAIL", - first_time=False, - ) + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium="EMAIL", + first_time=False, + ) access_token, refresh_token = get_tokens_for_user(user) data = { @@ -347,16 +351,15 @@ class MagicSignInEndpoint(BaseAPIView): status=status.HTTP_403_FORBIDDEN, ) # Send event - if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium="MAGIC_LINK", - first_time=False, - ) + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium="MAGIC_LINK", + first_time=False, + ) user.is_active = True user.is_email_verified = True diff --git a/apiserver/plane/app/views/config.py b/apiserver/plane/app/views/config.py index cef68d6d0..fb32e8570 100644 --- a/apiserver/plane/app/views/config.py +++ b/apiserver/plane/app/views/config.py @@ -11,7 +11,6 @@ from rest_framework.response import Response # Module imports from .base import BaseAPIView -from plane.license.models import InstanceConfiguration from plane.license.utils.instance_value import get_configuration_value @@ -21,89 +20,101 @@ class ConfigurationEndpoint(BaseAPIView): ] def get(self, request): - instance_configuration = InstanceConfiguration.objects.values("key", "value") + + # Get all the configuration + ( + GOOGLE_CLIENT_ID, + GITHUB_CLIENT_ID, + GITHUB_APP_NAME, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + ENABLE_MAGIC_LINK_LOGIN, + ENABLE_EMAIL_PASSWORD, + SLACK_CLIENT_ID, + POSTHOG_API_KEY, + POSTHOG_HOST, + UNSPLASH_ACCESS_KEY, + OPENAI_API_KEY, + ) = get_configuration_value( + [ + { + "key": "GOOGLE_CLIENT_ID", + "default": os.environ.get("GOOGLE_CLIENT_ID", None), + }, + { + "key": "GITHUB_CLIENT_ID", + "default": os.environ.get("GITHUB_CLIENT_ID", None), + }, + { + "key": "GITHUB_APP_NAME", + "default": os.environ.get("GITHUB_APP_NAME", None), + }, + { + "key": "EMAIL_HOST_USER", + "default": os.environ.get("EMAIL_HOST_USER", None), + }, + { + "key": "EMAIL_HOST_PASSWORD", + "default": os.environ.get("EMAIL_HOST_PASSWORD", None), + }, + { + "key": "ENABLE_MAGIC_LINK_LOGIN", + "default": os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "1"), + }, + { + "key": "ENABLE_EMAIL_PASSWORD", + "default": os.environ.get("ENABLE_EMAIL_PASSWORD", "1"), + }, + { + "key": "SLACK_CLIENT_ID", + "default": os.environ.get("SLACK_CLIENT_ID", "1"), + }, + { + "key": "POSTHOG_API_KEY", + "default": os.environ.get("POSTHOG_API_KEY", "1"), + }, + { + "key": "POSTHOG_HOST", + "default": os.environ.get("POSTHOG_HOST", "1"), + }, + { + "key": "UNSPLASH_ACCESS_KEY", + "default": os.environ.get("UNSPLASH_ACCESS_KEY", "1"), + }, + { + "key": "OPENAI_API_KEY", + "default": os.environ.get("OPENAI_API_KEY", "1"), + }, + ] + ) data = {} # Authentication - data["google_client_id"] = get_configuration_value( - instance_configuration, - "GOOGLE_CLIENT_ID", - os.environ.get("GOOGLE_CLIENT_ID", None), - ) - data["github_client_id"] = get_configuration_value( - instance_configuration, - "GITHUB_CLIENT_ID", - os.environ.get("GITHUB_CLIENT_ID", None), - ) - data["github_app_name"] = get_configuration_value( - instance_configuration, - "GITHUB_APP_NAME", - os.environ.get("GITHUB_APP_NAME", None), - ) + data["google_client_id"] = GOOGLE_CLIENT_ID + data["github_client_id"] = GITHUB_CLIENT_ID + data["github_app_name"] = GITHUB_APP_NAME data["magic_login"] = ( - bool( - get_configuration_value( - instance_configuration, - "EMAIL_HOST_USER", - os.environ.get("EMAIL_HOST_USER", None), - ), - ) - and bool( - get_configuration_value( - instance_configuration, - "EMAIL_HOST_PASSWORD", - os.environ.get("EMAIL_HOST_PASSWORD", None), - ) - ) - ) and get_configuration_value( - instance_configuration, "ENABLE_MAGIC_LINK_LOGIN", "1" - ) == "1" + bool(EMAIL_HOST_USER) and bool(EMAIL_HOST_PASSWORD) + ) and ENABLE_MAGIC_LINK_LOGIN == "1" - data["email_password_login"] = ( - get_configuration_value( - instance_configuration, "ENABLE_EMAIL_PASSWORD", "1" - ) - == "1" - ) + data["email_password_login"] = ENABLE_EMAIL_PASSWORD == "1" # Slack client - data["slack_client_id"] = get_configuration_value( - instance_configuration, - "SLACK_CLIENT_ID", - os.environ.get("SLACK_CLIENT_ID", None), - ) + data["slack_client_id"] = SLACK_CLIENT_ID # 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), - ) + data["posthog_api_key"] = POSTHOG_API_KEY + data["posthog_host"] = POSTHOG_HOST # Unsplash - data["has_unsplash_configured"] = bool( - get_configuration_value( - instance_configuration, - "UNSPLASH_ACCESS_KEY", - os.environ.get("UNSPLASH_ACCESS_KEY", None), - ) - ) + data["has_unsplash_configured"] = UNSPLASH_ACCESS_KEY # Open AI settings - data["has_openai_configured"] = bool( - get_configuration_value( - instance_configuration, - "OPENAI_API_KEY", - os.environ.get("OPENAI_API_KEY", None), - ) - ) + data["has_openai_configured"] = bool(OPENAI_API_KEY) + # File size settings data["file_size_limit"] = float(os.environ.get("FILE_SIZE_LIMIT", 5242880)) + # is self managed data["is_self_managed"] = bool(int(os.environ.get("IS_SELF_MANAGED", "1"))) return Response(data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/external.py b/apiserver/plane/app/views/external.py index b9f8a0cf0..97d509c1e 100644 --- a/apiserver/plane/app/views/external.py +++ b/apiserver/plane/app/views/external.py @@ -1,6 +1,7 @@ # Python imports import requests import os + # Third party imports from openai import OpenAI from rest_framework.response import Response @@ -15,23 +16,31 @@ from plane.app.permissions import ProjectEntityPermission from plane.db.models import Workspace, Project from plane.app.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer from plane.utils.integrations.github import get_release_notes -from plane.license.models import InstanceConfiguration from plane.license.utils.instance_value import get_configuration_value + class GPTIntegrationEndpoint(BaseAPIView): permission_classes = [ ProjectEntityPermission, ] def post(self, request, slug, project_id): + OPENAI_API_KEY, GPT_ENGINE = get_configuration_value( + [ + { + "key": "OPENAI_API_KEY", + "default": os.environ.get("OPENAI_API_KEY", None), + }, + { + "key": "GPT_ENGINE", + "default": os.environ.get("GPT_ENGINE", "gpt-3.5-turbo"), + }, + ] + ) # Get the configuration value - instance_configuration = InstanceConfiguration.objects.values("key", "value") - api_key = get_configuration_value(instance_configuration, "OPENAI_API_KEY", os.environ.get("OPENAI_API_KEY")) - gpt_engine = get_configuration_value(instance_configuration, "GPT_ENGINE", os.environ.get("GPT_ENGINE", "gpt-3.5-turbo")) - # Check the keys - if not api_key or not gpt_engine: + if not OPENAI_API_KEY or not GPT_ENGINE: return Response( {"error": "OpenAI API key and engine is required"}, status=status.HTTP_400_BAD_REQUEST, @@ -48,11 +57,11 @@ class GPTIntegrationEndpoint(BaseAPIView): final_text = task + "\n" + prompt client = OpenAI( - api_key=api_key, + api_key=OPENAI_API_KEY, ) response = client.chat.completions.create( - model=gpt_engine, + model=GPT_ENGINE, messages=[{"role": "user", "content": final_text}], ) @@ -79,13 +88,17 @@ class ReleaseNotesEndpoint(BaseAPIView): class UnsplashEndpoint(BaseAPIView): - def get(self, request): - instance_configuration = InstanceConfiguration.objects.values("key", "value") - unsplash_access_key = get_configuration_value(instance_configuration, "UNSPLASH_ACCESS_KEY", os.environ.get("UNSPLASH_ACCESS_KEY")) - + UNSPLASH_ACCESS_KEY, = get_configuration_value( + [ + { + "key": "UNSPLASH_ACCESS_KEY", + "default": os.environ.get("UNSPLASH_ACCESS_KEY"), + } + ] + ) # Check unsplash access key - if not unsplash_access_key: + if not UNSPLASH_ACCESS_KEY: return Response([], status=status.HTTP_200_OK) # Query parameters @@ -94,9 +107,9 @@ class UnsplashEndpoint(BaseAPIView): per_page = request.GET.get("per_page", 20) url = ( - f"https://api.unsplash.com/search/photos/?client_id={unsplash_access_key}&query={query}&page=${page}&per_page={per_page}" + f"https://api.unsplash.com/search/photos/?client_id={UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}" if query - else f"https://api.unsplash.com/photos/?client_id={unsplash_access_key}&page={page}&per_page={per_page}" + else f"https://api.unsplash.com/photos/?client_id={UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}" ) headers = { diff --git a/apiserver/plane/app/views/oauth.py b/apiserver/plane/app/views/oauth.py index 85e6ac957..e12cba2ae 100644 --- a/apiserver/plane/app/views/oauth.py +++ b/apiserver/plane/app/views/oauth.py @@ -30,7 +30,7 @@ from plane.db.models import ( ) from plane.bgtasks.event_tracking_task import auth_events from .base import BaseAPIView -from plane.license.models import InstanceConfiguration, Instance +from plane.license.models import Instance from plane.license.utils.instance_value import get_configuration_value @@ -147,18 +147,20 @@ class OauthEndpoint(BaseAPIView): id_token = request.data.get("credential", False) client_id = request.data.get("clientId", False) - instance_configuration = InstanceConfiguration.objects.values( - "key", "value" + GOOGLE_CLIENT_ID, GITHUB_CLIENT_ID = get_configuration_value( + [ + { + "key": "GOOGLE_CLIENT_ID", + "default": os.environ.get("GOOGLE_CLIENT_ID"), + }, + { + "key": "GITHUB_CLIENT_ID", + "default": os.environ.get("GITHUB_CLIENT_ID"), + }, + ] ) - if not get_configuration_value( - instance_configuration, - "GOOGLE_CLIENT_ID", - os.environ.get("GOOGLE_CLIENT_ID"), - ) or not get_configuration_value( - instance_configuration, - "GITHUB_CLIENT_ID", - os.environ.get("GITHUB_CLIENT_ID"), - ): + + if not GOOGLE_CLIENT_ID or not GITHUB_CLIENT_ID: return Response( {"error": "Github or Google login is not configured"}, status=status.HTTP_400_BAD_REQUEST, @@ -278,16 +280,15 @@ class OauthEndpoint(BaseAPIView): ) # Send event - if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium=medium.upper(), - first_time=False, - ) + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium=medium.upper(), + first_time=False, + ) access_token, refresh_token = get_tokens_for_user(user) @@ -298,17 +299,16 @@ class OauthEndpoint(BaseAPIView): return Response(data, status=status.HTTP_200_OK) except User.DoesNotExist: - ## Signup Case - instance_configuration = InstanceConfiguration.objects.values( - "key", "value" + ENABLE_SIGNUP, = get_configuration_value( + [ + { + "key": "ENABLE_SIGNUP", + "default": os.environ.get("ENABLE_SIGNUP", "0"), + } + ] ) if ( - get_configuration_value( - instance_configuration, - "ENABLE_SIGNUP", - os.environ.get("ENABLE_SIGNUP", "0"), - ) - == "0" + ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter( email=email, ).exists() @@ -411,16 +411,15 @@ class OauthEndpoint(BaseAPIView): project_member_invites.delete() # Send event - if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: - auth_events.delay( - user=user.id, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="SIGN_IN", - medium=medium.upper(), - first_time=True, - ) + auth_events.delay( + user=user.id, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="SIGN_IN", + medium=medium.upper(), + first_time=True, + ) SocialLoginConnection.objects.update_or_create( medium=medium, diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py index 5e50f28f0..ed72dbcf1 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -408,15 +408,14 @@ class WorkspaceJoinEndpoint(BaseAPIView): workspace_invite.delete() # Send event - if settings.POSTHOG_API_KEY and settings.POSTHOG_HOST: - workspace_invite_event.delay( - user=user.id if user is not None else None, - email=email, - user_agent=request.META.get("HTTP_USER_AGENT"), - ip=request.META.get("REMOTE_ADDR"), - event_name="MEMBER_ACCEPTED", - accepted_from="EMAIL", - ) + workspace_invite_event.delay( + user=user.id if user is not None else None, + email=email, + user_agent=request.META.get("HTTP_USER_AGENT"), + ip=request.META.get("REMOTE_ADDR"), + event_name="MEMBER_ACCEPTED", + accepted_from="EMAIL", + ) return Response( {"message": "Workspace Invitation Accepted"}, diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index 5d4f58eba..4aa86f6ca 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -18,7 +18,6 @@ from sentry_sdk import capture_exception from plane.db.models import Issue from plane.utils.analytics_plot import build_graph_plot from plane.utils.issue_filters import issue_filters -from plane.license.models import InstanceConfiguration, Instance from plane.license.utils.instance_value import get_email_configuration row_mapping = { @@ -52,11 +51,6 @@ def send_export_email(email, slug, csv_buffer, rows): csv_buffer.seek(0) - # Configure email connection from the database - instance_configuration = InstanceConfiguration.objects.filter( - key__startswith="EMAIL_" - ).values("key", "value") - ( EMAIL_HOST, EMAIL_HOST_USER, @@ -64,7 +58,7 @@ def send_export_email(email, slug, csv_buffer, rows): EMAIL_PORT, EMAIL_USE_TLS, EMAIL_FROM, - ) = get_email_configuration(instance_configuration=instance_configuration) + ) = get_email_configuration() connection = get_connection( host=EMAIL_HOST, diff --git a/apiserver/plane/bgtasks/event_tracking_task.py b/apiserver/plane/bgtasks/event_tracking_task.py index 25479d3ee..7d26dd4ab 100644 --- a/apiserver/plane/bgtasks/event_tracking_task.py +++ b/apiserver/plane/bgtasks/event_tracking_task.py @@ -1,50 +1,78 @@ import uuid +import os -from posthog import Posthog -from django.conf import settings - -#third party imports +# third party imports from celery import shared_task from sentry_sdk import capture_exception +from posthog import Posthog + +# module imports +from plane.license.utils.instance_value import get_configuration_value + + +def posthogConfiguration(): + POSTHOG_API_KEY, POSTHOG_HOST = get_configuration_value( + [ + { + "key": "POSTHOG_API_KEY", + "default": os.environ.get("POSTHOG_API_KEY", None), + }, + { + "key": "POSTHOG_HOST", + "default": os.environ.get("POSTHOG_HOST", None), + }, + ] + ) + if POSTHOG_API_KEY and POSTHOG_HOST: + return POSTHOG_API_KEY, POSTHOG_HOST + else: + return None, None @shared_task def auth_events(user, email, user_agent, ip, event_name, medium, first_time): try: - posthog = Posthog(settings.POSTHOG_API_KEY, host=settings.POSTHOG_HOST) - posthog.capture( - email, - event=event_name, - properties={ - "event_id": uuid.uuid4().hex, - "user": {"email": email, "id": str(user)}, - "device_ctx": { - "ip": ip, - "user_agent": user_agent, - }, - "medium": medium, - "first_time": first_time - } - ) + POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() + + if POSTHOG_API_KEY and POSTHOG_HOST: + posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) + posthog.capture( + email, + event=event_name, + properties={ + "event_id": uuid.uuid4().hex, + "user": {"email": email, "id": str(user)}, + "device_ctx": { + "ip": ip, + "user_agent": user_agent, + }, + "medium": medium, + "first_time": first_time + } + ) except Exception as e: capture_exception(e) + @shared_task def workspace_invite_event(user, email, user_agent, ip, event_name, accepted_from): try: - posthog = Posthog(settings.POSTHOG_API_KEY, host=settings.POSTHOG_HOST) - posthog.capture( - email, - event=event_name, - properties={ - "event_id": uuid.uuid4().hex, - "user": {"email": email, "id": str(user)}, - "device_ctx": { - "ip": ip, - "user_agent": user_agent, - }, - "accepted_from": accepted_from - } - ) + POSTHOG_API_KEY, POSTHOG_HOST = posthogConfiguration() + + if POSTHOG_API_KEY and POSTHOG_HOST: + posthog = Posthog(POSTHOG_API_KEY, host=POSTHOG_HOST) + posthog.capture( + email, + event=event_name, + properties={ + "event_id": uuid.uuid4().hex, + "user": {"email": email, "id": str(user)}, + "device_ctx": { + "ip": ip, + "user_agent": user_agent, + }, + "accepted_from": accepted_from + } + ) except Exception as e: capture_exception(e) \ No newline at end of file diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index b24d81d93..563cc8a40 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -14,7 +14,6 @@ from celery import shared_task from sentry_sdk import capture_exception # Module imports -from plane.license.models import InstanceConfiguration, Instance from plane.license.utils.instance_value import get_email_configuration @@ -26,10 +25,6 @@ def forgot_password(first_name, email, uidb64, token, current_site): ) abs_url = str(current_site) + relative_link - instance_configuration = InstanceConfiguration.objects.filter( - key__startswith="EMAIL_" - ).values("key", "value") - ( EMAIL_HOST, EMAIL_HOST_USER, @@ -37,7 +32,7 @@ def forgot_password(first_name, email, uidb64, token, current_site): EMAIL_PORT, EMAIL_USE_TLS, EMAIL_FROM, - ) = get_email_configuration(instance_configuration=instance_configuration) + ) = get_email_configuration() subject = "A new password to your Plane account has been requested" @@ -51,9 +46,6 @@ def forgot_password(first_name, email, uidb64, token, current_site): text_content = strip_tags(html_content) - instance_configuration = InstanceConfiguration.objects.filter( - key__startswith="EMAIL_" - ).values("key", "value") connection = get_connection( host=EMAIL_HOST, port=int(EMAIL_PORT), diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 2e8a7de16..55bbfa0d6 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -14,17 +14,12 @@ from celery import shared_task from sentry_sdk import capture_exception # Module imports -from plane.license.models import InstanceConfiguration, Instance from plane.license.utils.instance_value import get_email_configuration @shared_task def magic_link(email, key, token, current_site): try: - instance_configuration = InstanceConfiguration.objects.filter( - key__startswith="EMAIL_" - ).values("key", "value") - ( EMAIL_HOST, EMAIL_HOST_USER, @@ -32,7 +27,7 @@ def magic_link(email, key, token, current_site): EMAIL_PORT, EMAIL_USE_TLS, EMAIL_FROM, - ) = get_email_configuration(instance_configuration=instance_configuration) + ) = get_email_configuration() # Send the mail subject = f"Your unique Plane login code is {token}" diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index 1ff160afc..4ec06e623 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -13,7 +13,6 @@ from sentry_sdk import capture_exception # Module imports from plane.db.models import Project, User, ProjectMemberInvite -from plane.license.models import InstanceConfiguration from plane.license.utils.instance_value import get_email_configuration @shared_task @@ -47,7 +46,6 @@ def project_invitation(email, project_id, token, current_site, invitor): project_member_invite.save() # Configure email connection from the database - instance_configuration = InstanceConfiguration.objects.filter(key__startswith='EMAIL_').values("key", "value") ( EMAIL_HOST, EMAIL_HOST_USER, @@ -55,7 +53,7 @@ def project_invitation(email, project_id, token, current_site, invitor): EMAIL_PORT, EMAIL_USE_TLS, EMAIL_FROM, - ) = get_email_configuration(instance_configuration=instance_configuration) + ) = get_email_configuration() connection = get_connection( host=EMAIL_HOST, diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index 08fbaea62..1bdc48ca3 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -17,7 +17,6 @@ from slack_sdk.errors import SlackApiError # Module imports from plane.db.models import Workspace, WorkspaceMemberInvite, User -from plane.license.models import InstanceConfiguration, Instance from plane.license.utils.instance_value import get_email_configuration @@ -37,9 +36,6 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): # The complete url including the domain abs_url = str(current_site) + relative_link - instance_configuration = InstanceConfiguration.objects.filter( - key__startswith="EMAIL_" - ).values("key", "value") ( EMAIL_HOST, @@ -48,7 +44,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): EMAIL_PORT, EMAIL_USE_TLS, EMAIL_FROM, - ) = get_email_configuration(instance_configuration=instance_configuration) + ) = get_email_configuration() # Subject of the email subject = f"{user.first_name or user.display_name or user.email} has invited you to join them in {workspace.name} on Plane" @@ -69,9 +65,6 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): workspace_member_invite.message = text_content workspace_member_invite.save() - instance_configuration = InstanceConfiguration.objects.filter( - key__startswith="EMAIL_" - ).values("key", "value") connection = get_connection( host=EMAIL_HOST, port=int(EMAIL_PORT), diff --git a/apiserver/plane/celery.py b/apiserver/plane/celery.py index eb90c6205..442e72836 100644 --- a/apiserver/plane/celery.py +++ b/apiserver/plane/celery.py @@ -28,10 +28,6 @@ app.conf.beat_schedule = { "task": "plane.bgtasks.file_asset_task.delete_file_asset", "schedule": crontab(hour=0, minute=0), }, - "check-instance-verification": { - "task": "plane.license.bgtasks.instance_verification_task.instance_verification_task", - "schedule": crontab(minute=0, hour='*/4'), - }, } # Load task modules from all registered Django app configs. diff --git a/apiserver/plane/license/bgtasks/instance_verification_task.py b/apiserver/plane/license/bgtasks/instance_verification_task.py deleted file mode 100644 index 3ffaa6ba8..000000000 --- a/apiserver/plane/license/bgtasks/instance_verification_task.py +++ /dev/null @@ -1,136 +0,0 @@ -# Python imports -import os -import json -import requests - -# Django imports -from django.conf import settings - -# Third party imports -from celery import shared_task - -# Module imports -from plane.db.models import User -from plane.license.models import Instance, InstanceAdmin - - -def instance_verification(instance): - with open("package.json", "r") as file: - # Load JSON content from the file - data = json.load(file) - - headers = {"Content-Type": "application/json"} - payload = { - "instance_key": settings.INSTANCE_KEY, - "version": data.get("version", 0.1), - "machine_signature": os.environ.get("MACHINE_SIGNATURE", "machine-signature"), - "user_count": User.objects.filter(is_bot=False).count(), - } - # Register the instance - response = requests.post( - f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", - headers=headers, - data=json.dumps(payload), - timeout=30, - ) - - # check response status - if response.status_code == 201: - data = response.json() - # Update instance - instance.instance_id = data.get("id") - instance.license_key = data.get("license_key") - instance.api_key = data.get("api_key") - instance.version = data.get("version") - instance.user_count = data.get("user_count", 0) - instance.is_verified = True - instance.save() - else: - return - - -def admin_verification(instance): - # Save the user in control center - headers = { - "Content-Type": "application/json", - "x-instance-id": instance.instance_id, - "x-api-key": instance.api_key, - } - - # Get all the unverified instance admins - instance_admins = InstanceAdmin.objects.filter(is_verified=False).select_related( - "user" - ) - updated_instance_admin = [] - - # Verify the instance admin - for instance_admin in instance_admins: - instance_admin.is_verified = True - # Create the admin - response = requests.post( - f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/register/", - headers=headers, - data=json.dumps( - { - "email": str(instance_admin.user.email), - "signup_mode": "EMAIL", - "is_admin": True, - } - ), - timeout=30, - ) - updated_instance_admin.append(instance_admin) - - # update all the instance admins - InstanceAdmin.objects.bulk_update( - updated_instance_admin, ["is_verified"], batch_size=10 - ) - return - -def instance_user_count(instance): - try: - instance_users = User.objects.filter(is_bot=False).count() - - # Update the count in the license engine - payload = { - "user_count": instance_users, - } - - # Save the user in control center - headers = { - "Content-Type": "application/json", - "x-instance-id": instance.instance_id, - "x-api-key": instance.api_key, - } - - # Update the license engine - _ = requests.post( - f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", - headers=headers, - data=json.dumps(payload), - timeout=30, - ) - return - except requests.RequestException: - return - - -@shared_task -def instance_verification_task(): - try: - # Get the first instance - instance = Instance.objects.first() - - # Only register instance if it is not verified - if not instance.is_verified: - instance_verification(instance=instance) - - # Admin verifications - admin_verification(instance=instance) - - # Update user count - instance_user_count(instance=instance) - - return - except requests.RequestException: - return diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py index 9d467b9dd..e6cfa7167 100644 --- a/apiserver/plane/license/management/commands/register_instance.py +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -30,13 +30,11 @@ class Command(BaseCommand): # Load JSON content from the file data = json.load(file) - machine_signature = options.get("machine_signature", False) + machine_signature = options.get("machine_signature", "machine-signature") if not machine_signature: raise CommandError("Machine signature is required") - # Check if machine is online - headers = {"Content-Type": "application/json"} payload = { "instance_key": settings.INSTANCE_KEY, "version": data.get("version", 0.1), @@ -44,51 +42,21 @@ class Command(BaseCommand): "user_count": User.objects.filter(is_bot=False).count(), } - try: - response = requests.post( - f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", - headers=headers, - data=json.dumps(payload), - timeout=30 - ) + instance = Instance.objects.create( + instance_name="Plane Free", + instance_id=secrets.token_hex(12), + license_key=None, + api_key=secrets.token_hex(8), + version=payload.get("version"), + last_checked_at=timezone.now(), + user_count=payload.get("user_count", 0), + ) - 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"), - last_checked_at=timezone.now(), - user_count=data.get("user_count", 0), - is_verified=True, - ) - - self.stdout.write( - self.style.SUCCESS( - f"Instance successfully registered and verified" - ) - ) - return - except requests.RequestException as _e: - instance = Instance.objects.create( - instance_name="Plane Free", - instance_id=secrets.token_hex(12), - license_key=None, - api_key=secrets.token_hex(8), - version=payload.get("version"), - last_checked_at=timezone.now(), - user_count=payload.get("user_count", 0), + self.stdout.write( + self.style.SUCCESS( + f"Instance registered" ) - self.stdout.write( - self.style.SUCCESS( - f"Instance successfully registered" - ) - ) - return - raise CommandError("Instance could not be registered") + ) else: self.stdout.write( self.style.SUCCESS( diff --git a/apiserver/plane/license/utils/instance_value.py b/apiserver/plane/license/utils/instance_value.py index 547467f52..e56525893 100644 --- a/apiserver/plane/license/utils/instance_value.py +++ b/apiserver/plane/license/utils/instance_value.py @@ -1,63 +1,71 @@ +# Python imports import os +# Django imports +from django.conf import settings + +# Module imports +from plane.license.models import InstanceConfiguration +from plane.license.utils.encryption import decrypt_data + # 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 +def get_configuration_value(keys): + environment_list = [] + if settings.SKIP_ENV_VAR: + # Get the configurations + instance_configuration = InstanceConfiguration.objects.values( + "key", "value", "is_encrypted" + ) + + for key in keys: + for item in instance_configuration: + if key.get("key") == item.get("key"): + if item.get("is_encrypted", False): + environment_list.append(decrypt_data(item.get("value"))) + else: + environment_list.append(item.get("value")) + + break + else: + environment_list.append(key.get("default")) + else: + # Get the configuration from os + for key in keys: + environment_list.append(os.environ.get(key.get("key"), key.get("default"))) + + return tuple(environment_list) -def get_email_configuration(instance_configuration): - # Get the configuration variables - EMAIL_HOST_USER = get_configuration_value( - instance_configuration, - "EMAIL_HOST_USER", - os.environ.get("EMAIL_HOST_USER", None), - ) - - EMAIL_HOST_PASSWORD = get_configuration_value( - instance_configuration, - "EMAIL_HOST_PASSWORD", - os.environ.get("EMAIL_HOST_PASSWORD", None), - ) - - EMAIL_HOST = get_configuration_value( - instance_configuration, - "EMAIL_HOST", - os.environ.get("EMAIL_HOST", None), - ) - - EMAIL_FROM = get_configuration_value( - instance_configuration, - "EMAIL_FROM", - os.environ.get("EMAIL_FROM", None), - ) - - EMAIL_USE_TLS = get_configuration_value( - instance_configuration, - "EMAIL_USE_TLS", - os.environ.get("EMAIL_USE_TLS", "1"), - ) - - EMAIL_PORT = get_configuration_value( - instance_configuration, - "EMAIL_PORT", - 587, - ) - - EMAIL_FROM = get_configuration_value( - instance_configuration, - "EMAIL_FROM", - os.environ.get("EMAIL_FROM", "Team Plane "), - ) - +def get_email_configuration(): return ( - EMAIL_HOST, - EMAIL_HOST_USER, - EMAIL_HOST_PASSWORD, - EMAIL_PORT, - EMAIL_USE_TLS, - EMAIL_FROM, + get_configuration_value( + [ + { + "key": "EMAIL_HOST", + "default": os.environ.get("EMAIL_HOST"), + }, + { + "key": "EMAIL_HOST_USER", + "default": os.environ.get("EMAIL_HOST_USER"), + }, + { + "key": "EMAIL_HOST_PASSWORD", + "default": os.environ.get("EMAIL_HOST_PASSWORD"), + }, + { + "key": "EMAIL_PORT", + "default": os.environ.get("EMAIL_PORT", 587), + }, + { + "key": "EMAIL_USE_TLS", + "default": os.environ.get("EMAIL_USE_TLS", "1"), + }, + { + "key": "EMAIL_FROM", + "default": os.environ.get("EMAIL_FROM", "Team Plane "), + }, + ] + ) ) + diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index 8ac7090d7..76528176b 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -287,7 +287,6 @@ CELERY_IMPORTS = ( "plane.bgtasks.issue_automation_task", "plane.bgtasks.exporter_expired_task", "plane.bgtasks.file_asset_task", - "plane.license.bgtasks.instance_verification_task", ) # Sentry Settings @@ -328,12 +327,10 @@ USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False) POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False) -# License engine base url -LICENSE_ENGINE_BASE_URL = os.environ.get( - "LICENSE_ENGINE_BASE_URL", "https://control-center.plane.so" -) - # instance key INSTANCE_KEY = os.environ.get( "INSTANCE_KEY", "ae6517d563dfc13d8270bd45cf17b08f70b37d989128a9dab46ff687603333c3" ) + +# Skip environment variable configuration +SKIP_ENV_VAR = os.environ.get("SKIP_ENV_VAR", "1") == "1" diff --git a/web/components/workspace/settings/members-list-item.tsx b/web/components/workspace/settings/members-list-item.tsx index 751fc14e1..148958ad3 100644 --- a/web/components/workspace/settings/members-list-item.tsx +++ b/web/components/workspace/settings/members-list-item.tsx @@ -40,7 +40,7 @@ export const WorkspaceMembersListItem: FC = observer((props) => { const { workspaceSlug } = router.query; // store const { - workspaceMember: { removeMember, updateMember, deleteWorkspaceInvitation }, + workspaceMember: { removeMember, updateMember, updateMemberInvitation, deleteWorkspaceInvitation }, user: { currentWorkspaceMemberInfo, currentWorkspaceRole, currentUser, currentUserSettings, leaveWorkspace }, } = useMobxStore(); // states @@ -206,15 +206,26 @@ export const WorkspaceMembersListItem: FC = observer((props) => { onChange={(value: TUserWorkspaceRole | undefined) => { if (!workspaceSlug || !value) return; - updateMember(workspaceSlug.toString(), member.id, { - role: value, - }).catch(() => { - setToastAlert({ - type: "error", - title: "Error!", - message: "An error occurred while updating member role. Please try again.", + if (!member?.status) + updateMemberInvitation(workspaceSlug.toString(), member.id, { + role: value, + }).catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "An error occurred while updating member role. Please try again.", + }); + }); + else + updateMember(workspaceSlug.toString(), member.id, { + role: value, + }).catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "An error occurred while updating member role. Please try again.", + }); }); - }); }} disabled={!hasRoleChangeAccess} placement="bottom-end" diff --git a/web/services/workspace.service.ts b/web/services/workspace.service.ts index 812e67735..42cfef450 100644 --- a/web/services/workspace.service.ts +++ b/web/services/workspace.service.ts @@ -166,6 +166,18 @@ export class WorkspaceService extends APIService { }); } + async updateWorkspaceInvitation( + workspaceSlug: string, + invitationId: string, + data: Partial + ): Promise { + return this.patch(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + async deleteWorkspaceInvitations(workspaceSlug: string, invitationId: string): Promise { return this.delete(`/api/workspaces/${workspaceSlug}/invitations/${invitationId}/`) .then((response) => response?.data) diff --git a/web/store/workspace/workspace-member.store.ts b/web/store/workspace/workspace-member.store.ts index cc70f78cf..def30ea59 100644 --- a/web/store/workspace/workspace-member.store.ts +++ b/web/store/workspace/workspace-member.store.ts @@ -19,6 +19,11 @@ export interface IWorkspaceMemberStore { updateMember: (workspaceSlug: string, memberId: string, data: Partial) => Promise; removeMember: (workspaceSlug: string, memberId: string) => Promise; inviteMembersToWorkspace: (workspaceSlug: string, data: IWorkspaceBulkInviteFormData) => Promise; + updateMemberInvitation: ( + workspaceSlug: string, + memberId: string, + data: Partial + ) => Promise; deleteWorkspaceInvitation: (workspaceSlug: string, memberId: string) => Promise; // computed workspaceMembers: IWorkspaceMember[] | null; @@ -53,6 +58,7 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore { updateMember: action, removeMember: action, inviteMembersToWorkspace: action, + updateMemberInvitation: action, deleteWorkspaceInvitation: action, // computed workspaceMembers: computed, @@ -183,6 +189,55 @@ export class WorkspaceMemberStore implements IWorkspaceMemberStore { } }; + /** + * update workspace member invitation using workspace slug and member id and data + * @param workspaceSlug + * @param memberId + * @param data + */ + updateMemberInvitation = async ( + workspaceSlug: string, + memberId: string, + data: Partial + ) => { + const originalMemberInvitations = [...this.memberInvitations?.[workspaceSlug]]; // in case of error, we will revert back to original members + + const memberInvitations = [...this.memberInvitations?.[workspaceSlug]]; + + const index = memberInvitations.findIndex((m) => m.id === memberId); + memberInvitations[index] = { ...memberInvitations[index], ...data }; + + // optimistic update + runInAction(() => { + this.loader = true; + this.error = null; + this.memberInvitations = { + ...this.memberInvitations, + [workspaceSlug]: memberInvitations, + }; + }); + + try { + await this.workspaceService.updateWorkspaceInvitation(workspaceSlug, memberId, data); + + runInAction(() => { + this.loader = false; + this.error = null; + }); + } catch (error) { + runInAction(() => { + this.loader = false; + this.error = error; + this.memberInvitations = { + ...this.memberInvitations, + [workspaceSlug]: originalMemberInvitations, + }; + }); + + throw error; + } + }; + /** * delete the workspace invitation * @param workspaceSlug