diff --git a/apiserver/.env.example b/apiserver/.env.example index ace1e07b1..37178b398 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -14,6 +14,11 @@ PGHOST="plane-db" PGDATABASE="plane" DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} +# Oauth variables +GOOGLE_CLIENT_ID="" +GITHUB_CLIENT_ID="" +GITHUB_CLIENT_SECRET="" + # Redis Settings REDIS_HOST="plane-redis" REDIS_PORT="6379" @@ -50,7 +55,6 @@ NGINX_PORT=80 # SignUps ENABLE_SIGNUP="1" - # Enable Email/Password Signup ENABLE_EMAIL_PASSWORD="1" diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index 5895a1bfc..eaff8181a 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -30,6 +30,11 @@ class CycleSerializer(BaseSerializer): model = Cycle fields = "__all__" read_only_fields = [ + "id", + "created_at", + "updated_at", + "created_by", + "updated_by", "workspace", "project", "owned_by", diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 2dbdddfc6..ab61ae523 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -1,3 +1,6 @@ +from lxml import html + + # Django imports from django.utils import timezone @@ -21,7 +24,8 @@ from plane.db.models import ( from .base import BaseSerializer from .cycle import CycleSerializer, CycleLiteSerializer from .module import ModuleSerializer, ModuleLiteSerializer - +from .user import UserLiteSerializer +from .state import StateLiteSerializer class IssueSerializer(BaseSerializer): assignees = serializers.ListField( @@ -42,7 +46,6 @@ class IssueSerializer(BaseSerializer): class Meta: model = Issue - fields = "__all__" read_only_fields = [ "id", "workspace", @@ -52,6 +55,10 @@ class IssueSerializer(BaseSerializer): "created_at", "updated_at", ] + exclude = [ + "description", + "description_stripped", + ] def validate(self, data): if ( @@ -60,6 +67,15 @@ class IssueSerializer(BaseSerializer): and data.get("start_date", None) > data.get("target_date", None) ): raise serializers.ValidationError("Start date cannot exceed target date") + + try: + if(data.get("description_html", None) is not None): + parsed = html.fromstring(data["description_html"]) + parsed_str = html.tostring(parsed, encoding='unicode') + data["description_html"] = parsed_str + + except Exception as e: + raise serializers.ValidationError(f"Invalid HTML: {str(e)}") # Validate assignees are from project if data.get("assignees", []): @@ -291,7 +307,6 @@ class IssueCommentSerializer(BaseSerializer): class Meta: model = IssueComment - fields = "__all__" read_only_fields = [ "id", "workspace", @@ -302,6 +317,21 @@ class IssueCommentSerializer(BaseSerializer): "created_at", "updated_at", ] + exclude = [ + "comment_stripped", + "comment_json", + ] + + def validate(self, data): + try: + if(data.get("comment_html", None) is not None): + parsed = html.fromstring(data["comment_html"]) + parsed_str = html.tostring(parsed, encoding='unicode') + data["comment_html"] = parsed_str + + except Exception as e: + raise serializers.ValidationError(f"Invalid HTML: {str(e)}") + return data class IssueActivitySerializer(BaseSerializer): @@ -331,12 +361,23 @@ class ModuleIssueSerializer(BaseSerializer): ] -class IssueExpandSerializer(BaseSerializer): - # Serialize the related cycle. It's a OneToOne relation. - cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True) +class LabelLiteSerializer(BaseSerializer): - # Serialize the related module. It's a OneToOne relation. + class Meta: + model = Label + fields = [ + "id", + "name", + "color", + ] + + +class IssueExpandSerializer(BaseSerializer): + cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True) module = ModuleLiteSerializer(source="issue_module.module", read_only=True) + labels = LabelLiteSerializer(read_only=True, many=True) + assignees = UserLiteSerializer(read_only=True, many=True) + state = StateLiteSerializer(read_only=True) class Meta: model = Issue @@ -349,4 +390,4 @@ class IssueExpandSerializer(BaseSerializer): "updated_by", "created_at", "updated_at", - ] \ No newline at end of file + ] diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index 932597799..c394a080d 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -21,6 +21,7 @@ class ProjectSerializer(BaseSerializer): fields = "__all__" read_only_fields = [ "id", + 'emoji', "workspace", "created_at", "updated_at", diff --git a/apiserver/plane/api/serializers/state.py b/apiserver/plane/api/serializers/state.py index 4c7f05ab8..9d08193d8 100644 --- a/apiserver/plane/api/serializers/state.py +++ b/apiserver/plane/api/serializers/state.py @@ -16,6 +16,11 @@ class StateSerializer(BaseSerializer): model = State fields = "__all__" read_only_fields = [ + "id", + "created_by", + "updated_by", + "created_at", + "updated_at", "workspace", "project", ] diff --git a/apiserver/plane/api/serializers/user.py b/apiserver/plane/api/serializers/user.py index e5a77da93..42b6c3967 100644 --- a/apiserver/plane/api/serializers/user.py +++ b/apiserver/plane/api/serializers/user.py @@ -11,10 +11,6 @@ class UserLiteSerializer(BaseSerializer): "first_name", "last_name", "avatar", - "is_bot", "display_name", ] - read_only_fields = [ - "id", - "is_bot", - ] \ No newline at end of file + read_only_fields = fields \ No newline at end of file diff --git a/apiserver/plane/api/views/state.py b/apiserver/plane/api/views/state.py index 679c12964..3d2861778 100644 --- a/apiserver/plane/api/views/state.py +++ b/apiserver/plane/api/views/state.py @@ -64,7 +64,7 @@ class StateAPIEndpoint(BaseAPIView): ) if state.default: - return Response({"error": "Default state cannot be deleted"}, status=False) + return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST) # Check for any issues in the state issue_exist = Issue.issue_objects.filter(state=state_id).exists() diff --git a/apiserver/plane/app/serializers/user.py b/apiserver/plane/app/serializers/user.py index 5c9c69e5c..1b94758e8 100644 --- a/apiserver/plane/app/serializers/user.py +++ b/apiserver/plane/app/serializers/user.py @@ -169,8 +169,8 @@ class ChangePasswordSerializer(serializers.Serializer): Serializer for password change endpoint. """ old_password = serializers.CharField(required=True) - new_password = serializers.CharField(required=True) - confirm_password = serializers.CharField(required=True) + new_password = serializers.CharField(required=True, min_length=8) + confirm_password = serializers.CharField(required=True, min_length=8) def validate(self, data): if data.get("old_password") == data.get("new_password"): @@ -187,9 +187,7 @@ class ChangePasswordSerializer(serializers.Serializer): class ResetPasswordSerializer(serializers.Serializer): - model = User - """ Serializer for password change endpoint. """ - new_password = serializers.CharField(required=True) + new_password = serializers.CharField(required=True, min_length=8) diff --git a/apiserver/plane/app/views/auth_extended.py b/apiserver/plane/app/views/auth_extended.py index 1de511f89..2dc0fa983 100644 --- a/apiserver/plane/app/views/auth_extended.py +++ b/apiserver/plane/app/views/auth_extended.py @@ -105,17 +105,21 @@ class ForgotPasswordEndpoint(BaseAPIView): def post(self, request): email = request.data.get("email") - if User.objects.filter(email=email).exists(): - user = User.objects.get(email=email) - uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) - token = PasswordResetTokenGenerator().make_token(user) + try: + validate_email(email) + except ValidationError: + return Response({"error": "Please enter a valid email"}, status=status.HTTP_400_BAD_REQUEST) + # Get the user + user = User.objects.filter(email=email).first() + if user: + # Get the reset token for user + uidb64, token = get_tokens_for_user(user=user) current_site = request.META.get("HTTP_ORIGIN") - + # send the forgot password email forgot_password.delay( user.first_name, user.email, uidb64, token, current_site ) - return Response( {"message": "Check your email to reset your password"}, status=status.HTTP_200_OK, @@ -130,14 +134,18 @@ class ResetPasswordEndpoint(BaseAPIView): def post(self, request, uidb64, token): try: + # Decode the id from the uidb64 id = smart_str(urlsafe_base64_decode(uidb64)) user = User.objects.get(id=id) + + # check if the token is valid for the user if not PasswordResetTokenGenerator().check_token(user, token): return Response( {"error": "Token is invalid"}, status=status.HTTP_401_UNAUTHORIZED, ) + # Reset the password serializer = ResetPasswordSerializer(data=request.data) if serializer.is_valid(): # set_password also hashes the password that the user will get @@ -145,9 +153,9 @@ class ResetPasswordEndpoint(BaseAPIView): user.is_password_autoset = False user.save() + # Log the user in # Generate access token for the user access_token, refresh_token = get_tokens_for_user(user) - data = { "access_token": access_token, "refresh_token": refresh_token, @@ -166,7 +174,6 @@ class ResetPasswordEndpoint(BaseAPIView): class ChangePasswordEndpoint(BaseAPIView): def post(self, request): serializer = ChangePasswordSerializer(data=request.data) - user = User.objects.get(pk=request.user.id) if serializer.is_valid(): if not user.check_password(serializer.data.get("old_password")): @@ -218,16 +225,15 @@ class EmailCheckEndpoint(BaseAPIView): ] def post(self, request): - # get the email - # Check the instance registration instance = Instance.objects.first() - if instance is None: + if instance is None or not instance.is_setup_done: return Response( {"error": "Instance is not configured"}, status=status.HTTP_400_BAD_REQUEST, ) + # Get the configurations instance_configuration = InstanceConfiguration.objects.values("key", "value") email = request.data.get("email", False) @@ -267,7 +273,7 @@ class EmailCheckEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - + # Create the user with default values user = User.objects.create( email=email, username=uuid.uuid4().hex, @@ -325,7 +331,7 @@ class EmailCheckEndpoint(BaseAPIView): first_time=True, ) # Automatically send the email - return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_400_BAD_REQUEST) + return Response({"is_password_autoset": user.is_password_autoset}, status=status.HTTP_200_OK) # Existing user else: if type == "magic_code": diff --git a/apiserver/plane/app/views/authentication.py b/apiserver/plane/app/views/authentication.py index 87118b7d5..487cdae48 100644 --- a/apiserver/plane/app/views/authentication.py +++ b/apiserver/plane/app/views/authentication.py @@ -10,14 +10,12 @@ from django.utils import timezone from django.core.exceptions import ValidationError from django.core.validators import validate_email from django.conf import settings -from django.contrib.auth.hashers import make_password # Third party imports from rest_framework.response import Response from rest_framework.permissions import AllowAny from rest_framework import status from rest_framework_simplejwt.tokens import RefreshToken - from sentry_sdk import capture_message # Module imports @@ -33,7 +31,6 @@ from plane.settings.redis import redis_instance from plane.license.models import InstanceConfiguration, Instance from plane.license.utils.instance_value import get_configuration_value from plane.bgtasks.event_tracking_task import auth_events -from plane.bgtasks.magic_link_code_task import magic_link from plane.bgtasks.user_count_task import update_user_instance_user_count @@ -49,6 +46,14 @@ class SignUpEndpoint(BaseAPIView): permission_classes = (AllowAny,) def post(self, request): + # Check if the instance configuration is done + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + instance_configuration = InstanceConfiguration.objects.values("key", "value") email = request.data.get("email", False) @@ -71,6 +76,7 @@ class SignUpEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) + # 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, @@ -124,6 +130,14 @@ class SignInEndpoint(BaseAPIView): permission_classes = (AllowAny,) def post(self, request): + # Check if the instance configuration is done + instance = Instance.objects.first() + if instance is None or not instance.is_setup_done: + return Response( + {"error": "Instance is not configured"}, + status=status.HTTP_400_BAD_REQUEST, + ) + email = request.data.get("email", False) password = request.data.get("password", False) @@ -144,14 +158,6 @@ class SignInEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - # Check if the instance setup is done or not - instance = Instance.objects.first() - if instance is None or not instance.is_setup_done: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - # Get the user user = User.objects.filter(email=email).first() @@ -288,6 +294,7 @@ class MagicSignInEndpoint(BaseAPIView): ] def post(self, request): + # Check if the instance configuration is done instance = Instance.objects.first() if instance is None or not instance.is_setup_done: return Response( diff --git a/apiserver/plane/app/views/oauth.py b/apiserver/plane/app/views/oauth.py index 0dd7fbaf0..826ec4b05 100644 --- a/apiserver/plane/app/views/oauth.py +++ b/apiserver/plane/app/views/oauth.py @@ -303,14 +303,6 @@ class OauthEndpoint(BaseAPIView): instance_configuration = InstanceConfiguration.objects.values( "key", "value" ) - # Check if instance is registered or not - instance = Instance.objects.first() - if instance is None and not instance.is_setup_done: - return Response( - {"error": "Instance is not configured"}, - status=status.HTTP_400_BAD_REQUEST, - ) - if ( get_configuration_value( instance_configuration, diff --git a/apiserver/plane/app/views/state.py b/apiserver/plane/app/views/state.py index 5867edb68..f7226ba6e 100644 --- a/apiserver/plane/app/views/state.py +++ b/apiserver/plane/app/views/state.py @@ -77,7 +77,7 @@ class StateViewSet(BaseViewSet): ) if state.default: - return Response({"error": "Default state cannot be deleted"}, status=False) + return Response({"error": "Default state cannot be deleted"}, status=status.HTTP_400_BAD_REQUEST) # Check for any issues in the state issue_exist = Issue.issue_objects.filter(state=pk).exists() diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index e32c2fd68..3d5724605 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -1,7 +1,8 @@ # Python imports import csv import io -import os +import requests +import json # Django imports from django.core.mail import EmailMultiAlternatives, get_connection @@ -17,8 +18,8 @@ from sentry_sdk import capture_exception from plane.db.models import Issue from plane.utils.analytics_plot import build_graph_plot from plane.utils.issue_filters import issue_filters -from plane.license.models import InstanceConfiguration -from plane.license.utils.instance_value import get_configuration_value +from plane.license.models import InstanceConfiguration, Instance +from plane.license.utils.instance_value import get_email_configuration row_mapping = { "state__name": "State", @@ -43,7 +44,7 @@ CYCLE_ID = "issue_cycle__cycle_id" MODULE_ID = "issue_module__module_id" -def send_export_email(email, slug, csv_buffer): +def send_export_email(email, slug, csv_buffer, rows): """Helper function to send export email.""" subject = "Your Export is ready" html_content = render_to_string("emails/exports/analytics.html", {}) @@ -55,47 +56,58 @@ def send_export_email(email, slug, csv_buffer): instance_configuration = InstanceConfiguration.objects.filter( key__startswith="EMAIL_" ).values("key", "value") + + ( + EMAIL_HOST, + EMAIL_HOST_USER, + EMAIL_HOST_PASSWORD, + EMAIL_PORT, + EMAIL_USE_TLS, + EMAIL_FROM, + ) = get_email_configuration(instance_configuration=instance_configuration) + + # Send the email if the users don't have smtp configured + if EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD: + # Check the instance registration + instance = Instance.objects.first() + + headers = { + "Content-Type": "application/json", + "x-instance-id": instance.instance_id, + "x-api-key": instance.api_key, + } + + payload = { + "email": email, + "slug": slug, + "rows": rows, + } + + _ = requests.post( + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/analytics/", + headers=headers, + json=payload, + ) + return + connection = get_connection( - host=get_configuration_value( - instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST") - ), - port=int( - get_configuration_value( - instance_configuration, "EMAIL_PORT", os.environ.get("EMAIL_PORT") - ) - ), - username=get_configuration_value( - instance_configuration, - "EMAIL_HOST_USER", - os.environ.get("EMAIL_HOST_USER"), - ), - password=get_configuration_value( - instance_configuration, - "EMAIL_HOST_PASSWORD", - os.environ.get("EMAIL_HOST_PASSWORD"), - ), - use_tls=bool( - get_configuration_value( - instance_configuration, - "EMAIL_USE_TLS", - os.environ.get("EMAIL_USE_TLS", "1"), - ) - ), + host=EMAIL_HOST, + port=int(EMAIL_PORT), + username=EMAIL_HOST_USER, + password=EMAIL_HOST_PASSWORD, + use_tls=bool(EMAIL_USE_TLS), ) msg = EmailMultiAlternatives( subject=subject, body=text_content, - from_email=get_configuration_value( - instance_configuration, - "EMAIL_FROM", - os.environ.get("EMAIL_FROM", "Team Plane "), - ), + from_email=EMAIL_FROM, to=[email], connection=connection, ) msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue()) msg.send(fail_silently=False) + return def get_assignee_details(slug, filters): @@ -463,8 +475,11 @@ def analytic_export_task(email, data, slug): ) csv_buffer = generate_csv_from_rows(rows) - send_export_email(email, slug, csv_buffer) + send_export_email(email, slug, csv_buffer, rows) + return except Exception as e: + print(e) if settings.DEBUG: print(e) capture_exception(e) + return diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index 5348a05da..33cd40dc8 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -40,13 +40,10 @@ def forgot_password(first_name, email, uidb64, token, current_site): ) = get_email_configuration(instance_configuration=instance_configuration) # Send the email if the users don't have smtp configured - if not EMAIL_HOST or not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD: + if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD): # Check the instance registration instance = Instance.objects.first() - # send the emails through control center - license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) - # headers headers = { "Content-Type": "application/json", @@ -61,7 +58,7 @@ def forgot_password(first_name, email, uidb64, token, current_site): } _ = requests.post( - f"{license_engine_base_url}/api/instances/users/forgot-password/", + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/forgot-password/", headers=headers, data=json.dumps(payload), ) diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 2caec2b60..a152b4c7f 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -21,7 +21,6 @@ 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") @@ -36,13 +35,10 @@ def magic_link(email, key, token, current_site): ) = get_email_configuration(instance_configuration=instance_configuration) # Send the email if the users don't have smtp configured - if not EMAIL_HOST or not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD: + if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD): # Check the instance registration instance = Instance.objects.first() - # send the emails through control center - license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) - headers = { "Content-Type": "application/json", "x-instance-id": instance.instance_id, @@ -55,7 +51,7 @@ def magic_link(email, key, token, current_site): } _ = requests.post( - f"{license_engine_base_url}/api/instances/users/magic-code/", + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/magic-code/", headers=headers, data=json.dumps(payload), ) diff --git a/apiserver/plane/bgtasks/user_count_task.py b/apiserver/plane/bgtasks/user_count_task.py index f93c3364e..dd8b19e7d 100644 --- a/apiserver/plane/bgtasks/user_count_task.py +++ b/apiserver/plane/bgtasks/user_count_task.py @@ -32,13 +32,9 @@ def update_user_instance_user_count(): "x-api-key": instance.api_key, } - license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL") - if not license_engine_base_url: - raise Exception("License Engine base url is required") - # Update the license engine _ = requests.post( - f"{license_engine_base_url}/api/instances/", + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", headers=headers, data=json.dumps(payload), ) diff --git a/apiserver/plane/bgtasks/webhook_task.py b/apiserver/plane/bgtasks/webhook_task.py index f5ee96256..3681f002d 100644 --- a/apiserver/plane/bgtasks/webhook_task.py +++ b/apiserver/plane/bgtasks/webhook_task.py @@ -109,7 +109,7 @@ def webhook_task(self, webhook, slug, event, event_data, action): if webhook.secret_key: hmac_signature = hmac.new( webhook.secret_key.encode("utf-8"), - json.dumps(payload, sort_keys=True).encode("utf-8"), + json.dumps(payload).encode("utf-8"), hashlib.sha256, ) signature = hmac_signature.hexdigest() diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index 2cbfdaff7..fe8708ed7 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -51,15 +51,10 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): ) = get_email_configuration(instance_configuration=instance_configuration) # Send the email if the users don't have smtp configured - if not EMAIL_HOST or not EMAIL_HOST_USER or not EMAIL_HOST_PASSWORD: + if not (EMAIL_HOST and EMAIL_HOST_USER and EMAIL_HOST_PASSWORD): # Check the instance registration instance = Instance.objects.first() - # send the emails through control center - license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) - if not license_engine_base_url: - raise Exception("License engine base url is required") - headers = { "Content-Type": "application/json", "x-instance-id": instance.instance_id, @@ -73,7 +68,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor): "email": email, } _ = requests.post( - f"{license_engine_base_url}/api/instances/users/workspace-invitation/", + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/workspace-invitation/", headers=headers, data=json.dumps(payload), ) diff --git a/apiserver/plane/license/api/views/instance.py b/apiserver/plane/license/api/views/instance.py index 7edc50b27..7dbc5e4b4 100644 --- a/apiserver/plane/license/api/views/instance.py +++ b/apiserver/plane/license/api/views/instance.py @@ -11,6 +11,7 @@ from django.utils import timezone from django.contrib.auth.hashers import make_password from django.core.validators import validate_email from django.core.exceptions import ValidationError +from django.conf import settings # Third party imports from rest_framework import status @@ -34,7 +35,6 @@ from plane.db.models import User from plane.license.utils.encryption import encrypt_data from plane.settings.redis import redis_instance from plane.bgtasks.magic_link_code_task import magic_link -from plane.license.utils.instance_value import get_configuration_value class InstanceEndpoint(BaseAPIView): @@ -57,25 +57,17 @@ class InstanceEndpoint(BaseAPIView): # 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 = { - "instance_key": os.environ.get("INSTANCE_KEY"), + "instance_key":settings.INSTANCE_KEY, "version": data.get("version", 0.1), "machine_signature": os.environ.get("MACHINE_SIGNATURE"), "user_count": User.objects.filter(is_bot=False).count(), } response = requests.post( - f"{license_engine_base_url}/api/instances/", + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", headers=headers, data=json.dumps(payload), ) @@ -130,6 +122,24 @@ class InstanceEndpoint(BaseAPIView): serializer = InstanceSerializer(instance, data=request.data, partial=True) if serializer.is_valid(): serializer.save() + # Save the user in control center + headers = { + "Content-Type": "application/json", + "x-instance-id": instance.instance_id, + "x-api-key": instance.api_key, + } + # Update instance settings in the license engine + _ = requests.patch( + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", + headers=headers, + data=json.dumps( + { + "is_support_required": serializer.data["is_support_required"], + "is_telemetry_enabled": serializer.data["is_telemetry_enabled"], + "version": serializer.data["version"], + } + ), + ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -251,7 +261,6 @@ class AdminMagicSignInGenerateEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - if not email: return Response( {"error": "Please provide a valid email address"}, @@ -409,13 +418,6 @@ class AdminSetUserPasswordEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) - if not license_engine_base_url: - return Response( - {"error": "License engine base url is required"}, - status=status.HTTP_400_BAD_REQUEST, - ) - # Save the user in control center headers = { "Content-Type": "application/json", @@ -423,14 +425,14 @@ class AdminSetUserPasswordEndpoint(BaseAPIView): "x-api-key": instance.api_key, } _ = requests.patch( - f"{license_engine_base_url}/api/instances/", + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", headers=headers, data=json.dumps({"is_setup_done": True}), ) # Also register the user as admin _ = requests.post( - f"{license_engine_base_url}/api/instances/users/register/", + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/register/", headers=headers, data=json.dumps( { @@ -472,24 +474,20 @@ class SignUpScreenVisitedEndpoint(BaseAPIView): status=status.HTTP_400_BAD_REQUEST, ) - license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) - - if not license_engine_base_url: - return Response( - {"error": "License engine base url is required"}, - status=status.HTTP_400_BAD_REQUEST, + if not instance.is_signup_screen_visited: + instance.is_signup_screen_visited = True + instance.save() + # set the headers + headers = { + "Content-Type": "application/json", + "x-instance-id": instance.instance_id, + "x-api-key": instance.api_key, + } + # create the payload + payload = {"is_signup_screen_visited": True} + _ = requests.patch( + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", + headers=headers, + data=json.dumps(payload), ) - - headers = { - "Content-Type": "application/json", - "x-instance-id": instance.instance_id, - "x-api-key": instance.api_key, - } - - payload = {"is_signup_screen_visited": True} - response = requests.patch( - f"{license_engine_base_url}/api/instances/", - headers=headers, - data=json.dumps(payload), - ) return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/license/management/commands/register_instance.py b/apiserver/plane/license/management/commands/register_instance.py index b486ecb0b..0970d8093 100644 --- a/apiserver/plane/license/management/commands/register_instance.py +++ b/apiserver/plane/license/management/commands/register_instance.py @@ -6,6 +6,7 @@ import requests # Django imports from django.core.management.base import BaseCommand, CommandError from django.utils import timezone +from django.conf import settings # Module imports from plane.license.models import Instance @@ -30,31 +31,22 @@ class Command(BaseCommand): data = json.load(file) machine_signature = options.get("machine_signature", False) - instance_key = os.environ.get("INSTANCE_KEY", False) - - # Raise an exception if the admin email is not provided - if not instance_key: - raise CommandError("INSTANCE_KEY is required") + if not machine_signature: raise CommandError("Machine signature is required") - 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 = { - "instance_key": instance_key, + "instance_key": settings.INSTANCE_KEY, "version": data.get("version", 0.1), "machine_signature": machine_signature, "user_count": User.objects.filter(is_bot=False).count(), } response = requests.post( - f"{license_engine_base_url}/api/instances/", + f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/", headers=headers, data=json.dumps(payload), ) diff --git a/apiserver/plane/settings/common.py b/apiserver/plane/settings/common.py index ba2c941ef..fff6b9e90 100644 --- a/apiserver/plane/settings/common.py +++ b/apiserver/plane/settings/common.py @@ -290,7 +290,7 @@ CELERY_IMPORTS = ( # Sentry Settings # Enable Sentry Settings -if bool(os.environ.get("SENTRY_DSN", False)): +if bool(os.environ.get("SENTRY_DSN", False)) and os.environ.get("SENTRY_DSN").startswith("https://"): sentry_sdk.init( dsn=os.environ.get("SENTRY_DSN", ""), integrations=[ @@ -324,4 +324,10 @@ USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1 # Posthog settings POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False) -POSTHOG_HOST = os.environ.get("POSTHOG_HOST", False) \ No newline at end of file +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") diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 75437fbee..2da24092a 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -63,7 +63,7 @@ def date_filter(filter, date_term, queries): duration=int(digit), subsequent=date_query[1], term=term, - date_filter="created_at__date", + date_filter=date_term, offset=date_query[2], ) else: diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 5342da85d..b6059bcd5 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -38,3 +38,4 @@ beautifulsoup4==4.12.2 dj-database-url==2.1.0 posthog==3.0.2 cryptography==41.0.5 +lxml==4.9.3 diff --git a/apiserver/templates/emails/auth/magic_signin.html b/apiserver/templates/emails/auth/magic_signin.html index 4e47487bd..ba469db7e 100644 --- a/apiserver/templates/emails/auth/magic_signin.html +++ b/apiserver/templates/emails/auth/magic_signin.html @@ -149,20 +149,20 @@ padding-top: 10px !important; } .r13-o { - border-bottom-color: #efefef !important; + border-bottom-color: #d9e4ff !important; border-bottom-width: 1px !important; - border-left-color: #efefef !important; + border-left-color: #d9e4ff !important; border-left-width: 1px !important; - border-right-color: #efefef !important; + border-right-color: #d9e4ff !important; border-right-width: 1px !important; border-style: solid !important; - border-top-color: #efefef !important; + border-top-color: #d9e4ff !important; border-top-width: 1px !important; margin: 0 auto 0 0 !important; width: 100% !important; } .r14-i { - background-color: #e3e6f1 !important; + background-color: #ecf1ff !important; padding-bottom: 10px !important; padding-left: 10px !important; padding-right: 10px !important; @@ -225,16 +225,11 @@ padding-top: 5px !important; } .r24-o { - border-style: solid !important; - margin-right: 8px !important; - width: 32px !important; - } - .r25-o { border-style: solid !important; margin-right: 0px !important; width: 32px !important; } - .r26-i { + .r25-i { padding-bottom: 0px !important; padding-top: 5px !important; text-align: center !important; @@ -664,17 +659,17 @@ width="100%" class="r13-o" style=" - background-color: #e3e6f1; - border-bottom-color: #efefef; + background-color: #ecf1ff; + border-bottom-color: #d9e4ff; border-bottom-width: 1px; border-collapse: separate; - border-left-color: #efefef; + border-left-color: #d9e4ff; border-left-width: 1px; border-radius: 5px; - border-right-color: #efefef; + border-right-color: #d9e4ff; border-right-width: 1px; border-style: solid; - border-top-color: #efefef; + border-top-color: #d9e4ff; border-top-width: 1px; table-layout: fixed; width: 100%; @@ -690,7 +685,7 @@ font-family: georgia, serif; font-size: 16px; word-break: break-word; - background-color: #e3e6f1; + background-color: #ecf1ff; border-radius: 4px; line-height: 3; padding-bottom: 10px; @@ -714,10 +709,10 @@

Please copy and paste this on the screen where you @@ -1251,13 +1246,14 @@ role="presentation" width="100%" class="r22-o" - class="r24-o" style=" + table-layout: fixed; + width: 100%; + " > - - Dear {{username}},
- Your requested Issue's data has been successfully exported from Plane. The export includes all relevant information about issues you requested from your selected projects.
- Please find the attachment and download the CSV file. If you have any questions or need further assistance, please don't hesitate to contact our support team at engineering@plane.so. We're here to help!
- Thank you for using Plane. We hope this export will aid you in effectively managing your projects.
- Regards, - Team Plane - diff --git a/deploy/coolify/coolify-docker-compose.yml b/deploy/coolify/coolify-docker-compose.yml index 6dd361d34..2a21c61a8 100644 --- a/deploy/coolify/coolify-docker-compose.yml +++ b/deploy/coolify/coolify-docker-compose.yml @@ -71,7 +71,6 @@ services: - ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0} - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} - WEB_URL=$SERVICE_FQDN_PLANE_8082 - - LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-"https://control-center.plane.so"} depends_on: - plane-db - plane-redis @@ -117,7 +116,6 @@ services: - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} - - LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-"https://control-center.plane.so"} depends_on: - api - plane-db @@ -164,7 +162,6 @@ services: - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} - - LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-"https://control-center.plane.so"} depends_on: - api - plane-db diff --git a/deploy/selfhost/docker-compose.yml b/deploy/selfhost/docker-compose.yml index 7eec3fd5a..ba0c28827 100644 --- a/deploy/selfhost/docker-compose.yml +++ b/deploy/selfhost/docker-compose.yml @@ -5,15 +5,16 @@ x-app-env : &app-env - NGINX_PORT=${NGINX_PORT:-80} - WEB_URL=${WEB_URL:-http://localhost} - DEBUG=${DEBUG:-0} - - NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0} - - NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} + - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.production} # deprecated + - NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0} # deprecated + - NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} # deprecated - SENTRY_DSN=${SENTRY_DSN:-""} + - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"} + - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-""} + - GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-""} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""} - DOCKERIZED=${DOCKERIZED:-1} # deprecated - CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""} - - SENTRY_ENVIRONMENT=${SENTRY_ENVIRONMENT:-"production"} - - ADMIN_EMAIL=${ADMIN_EMAIL:-""} - - LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-""} # Gunicorn Workers - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2} #DB SETTINGS @@ -28,12 +29,12 @@ x-app-env : &app-env - REDIS_HOST=${REDIS_HOST:-plane-redis} - REDIS_PORT=${REDIS_PORT:-6379} - REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}:6379/} - # EMAIL SETTINGS + # EMAIL SETTINGS - Deprecated can be configured through admin panel - EMAIL_HOST=${EMAIL_HOST:-""} - EMAIL_HOST_USER=${EMAIL_HOST_USER:-""} - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""} - EMAIL_PORT=${EMAIL_PORT:-587} - - EMAIL_FROM=${EMAIL_FROM:-"Team Plane <team@mailer.plane.so>"} + - EMAIL_FROM=${EMAIL_FROM:-"Team Plane "} - EMAIL_USE_TLS=${EMAIL_USE_TLS:-1} - EMAIL_USE_SSL=${EMAIL_USE_SSL:-0} - DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so} @@ -42,10 +43,11 @@ x-app-env : &app-env - OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1} - OPENAI_API_KEY=${OPENAI_API_KEY:-"sk-"} - GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"} - # LOGIN/SIGNUP SETTINGS + # LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} - ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1} - ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0} + # Application secret - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} # DATA STORE SETTINGS - USE_MINIO=${USE_MINIO:-1} diff --git a/deploy/selfhost/variables.env b/deploy/selfhost/variables.env index 10ca42879..6be9ca2f4 100644 --- a/deploy/selfhost/variables.env +++ b/deploy/selfhost/variables.env @@ -10,10 +10,12 @@ DEBUG=0 NEXT_PUBLIC_ENABLE_OAUTH=0 NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces SENTRY_DSN="" +SENTRY_ENVIRONMENT="production" +GOOGLE_CLIENT_ID="" +GITHUB_CLIENT_ID="" GITHUB_CLIENT_SECRET="" DOCKERIZED=1 # deprecated CORS_ALLOWED_ORIGINS="http://localhost" -SENTRY_ENVIRONMENT="production" #DB SETTINGS PGHOST=plane-db @@ -34,7 +36,7 @@ EMAIL_HOST="" EMAIL_HOST_USER="" EMAIL_HOST_PASSWORD="" EMAIL_PORT=587 -EMAIL_FROM="Team Plane <team@mailer.plane.so>" +EMAIL_FROM="Team Plane " EMAIL_USE_TLS=1 EMAIL_USE_SSL=0 @@ -63,9 +65,3 @@ FILE_SIZE_LIMIT=5242880 # Gunicorn Workers GUNICORN_WORKERS=2 - -# Admin Email -ADMIN_EMAIL="" - -# License Engine url -LICENSE_ENGINE_BASE_URL="" \ No newline at end of file diff --git a/web/components/account/sign-in-forms/email-form.tsx b/web/components/account/sign-in-forms/email-form.tsx index f704d4134..d9dc1c396 100644 --- a/web/components/account/sign-in-forms/email-form.tsx +++ b/web/components/account/sign-in-forms/email-form.tsx @@ -84,7 +84,7 @@ export const EmailForm: React.FC = (props) => { return ( <>

- Get on your flight deck! + Get on your flight deck

Sign in with the email you used to sign up for Plane diff --git a/web/components/account/sign-in-forms/password.tsx b/web/components/account/sign-in-forms/password.tsx index 547f59e9d..44c91a51f 100644 --- a/web/components/account/sign-in-forms/password.tsx +++ b/web/components/account/sign-in-forms/password.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState } from "react"; import Link from "next/link"; import { Controller, useForm } from "react-hook-form"; import { XCircle } from "lucide-react"; @@ -36,6 +36,8 @@ const authService = new AuthService(); export const PasswordForm: React.FC = (props) => { const { email, updateEmail, handleStepChange, handleSignInRedirection } = props; + // states + const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false); // toast alert const { setToastAlert } = useToast(); // form info @@ -113,6 +115,8 @@ export const PasswordForm: React.FC = (props) => { return; } + setIsSendingResetPasswordLink(true); + authService .sendResetPasswordLink({ email: emailFormValue }) .then(() => handleStepChange(ESignInSteps.SET_PASSWORD_LINK)) @@ -122,7 +126,8 @@ export const PasswordForm: React.FC = (props) => { title: "Error!", message: err?.error ?? "Something went wrong. Please try again.", }) - ); + ) + .finally(() => setIsSendingResetPasswordLink(false)); }; return ( @@ -189,9 +194,12 @@ export const PasswordForm: React.FC = (props) => { diff --git a/web/components/account/sign-in-forms/root.tsx b/web/components/account/sign-in-forms/root.tsx index 9797c8573..90e60e2b1 100644 --- a/web/components/account/sign-in-forms/root.tsx +++ b/web/components/account/sign-in-forms/root.tsx @@ -1,4 +1,6 @@ import React, { useState } from "react"; +// hooks +import useSignInRedirection from "hooks/use-sign-in-redirection"; // components import { EmailForm, @@ -19,33 +21,27 @@ export enum ESignInSteps { CREATE_PASSWORD = "CREATE_PASSWORD", } -type Props = { - handleSignInRedirection: () => Promise; -}; - const OAUTH_HIDDEN_STEPS = [ESignInSteps.OPTIONAL_SET_PASSWORD, ESignInSteps.CREATE_PASSWORD]; -export const SignInRoot: React.FC = (props) => { - const { handleSignInRedirection } = props; +export const SignInRoot = () => { // states const [signInStep, setSignInStep] = useState(ESignInSteps.EMAIL); const [email, setEmail] = useState(""); + // sign in redirection hook + const { handleRedirection } = useSignInRedirection(); return ( <>

{signInStep === ESignInSteps.EMAIL && ( - setSignInStep(step)} - updateEmail={(newEmail) => setEmail(newEmail)} - /> + setSignInStep(step)} updateEmail={(newEmail) => setEmail(newEmail)} /> )} {signInStep === ESignInSteps.PASSWORD && ( setEmail(newEmail)} - handleStepChange={(step: ESignInSteps) => setSignInStep(step)} - handleSignInRedirection={handleSignInRedirection} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} /> )} {signInStep === ESignInSteps.SET_PASSWORD_LINK && ( @@ -55,30 +51,30 @@ export const SignInRoot: React.FC = (props) => { setEmail(newEmail)} - handleStepChange={(step: ESignInSteps) => setSignInStep(step)} - handleSignInRedirection={handleSignInRedirection} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} /> )} {signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && ( setSignInStep(step)} - handleSignInRedirection={handleSignInRedirection} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} /> )} {signInStep === ESignInSteps.CREATE_PASSWORD && ( setSignInStep(step)} - handleSignInRedirection={handleSignInRedirection} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} /> )}
{!OAUTH_HIDDEN_STEPS.includes(signInStep) && ( setEmail(newEmail)} - handleStepChange={(step: ESignInSteps) => setSignInStep(step)} - handleSignInRedirection={handleSignInRedirection} + handleStepChange={(step) => setSignInStep(step)} + handleSignInRedirection={handleRedirection} /> )} diff --git a/web/components/account/sign-in-forms/set-password-link.tsx b/web/components/account/sign-in-forms/set-password-link.tsx index f683a26fc..21cc8db17 100644 --- a/web/components/account/sign-in-forms/set-password-link.tsx +++ b/web/components/account/sign-in-forms/set-password-link.tsx @@ -30,7 +30,7 @@ export const SetPasswordLink: React.FC = (props) => { const { control, formState: { errors, isValid }, - watch, + handleSubmit, } = useForm({ defaultValues: { email, @@ -39,11 +39,13 @@ export const SetPasswordLink: React.FC = (props) => { reValidateMode: "onChange", }); - const handleSendNewLink = async () => { + const handleSendNewLink = async (formData: { email: string }) => { setIsSendingNewLink(true); + updateEmail(formData.email); + const payload: IEmailCheckData = { - email: watch("email"), + email: formData.email, type: "password", }; @@ -76,7 +78,7 @@ export const SetPasswordLink: React.FC = (props) => { password

-
+
= (props) => { name="email" type="email" value={value} - onChange={(e) => { - updateEmail(e.target.value); - onChange(e.target.value); - }} + onChange={onChange} ref={ref} hasError={Boolean(errors.email)} placeholder="orville.wright@firstflight.com" @@ -112,11 +111,10 @@ export const SetPasswordLink: React.FC = (props) => { />
{!isCompleted && ( - { - setTrackElement("CYCLE_PAGE_SIDEBAR"); - setCycleDeleteModal(true) - } - }> + { + setTrackElement("CYCLE_PAGE_SIDEBAR"); + setCycleDeleteModal(true); + }} + > Delete cycle diff --git a/web/components/cycles/transfer-issues-modal.tsx b/web/components/cycles/transfer-issues-modal.tsx index bcbd8efef..55555e221 100644 --- a/web/components/cycles/transfer-issues-modal.tsx +++ b/web/components/cycles/transfer-issues-modal.tsx @@ -2,10 +2,12 @@ import React, { useState } from "react"; import { useRouter } from "next/router"; import useSWR from "swr"; import { Dialog, Transition } from "@headlessui/react"; +import { observer } from "mobx-react-lite"; // services import { CycleService } from "services/cycle.service"; // hooks import useToast from "hooks/use-toast"; +import { useMobxStore } from "lib/mobx/store-provider"; //icons import { ContrastIcon, TransferIcon } from "@plane/ui"; import { AlertCircle, Search, X } from "lucide-react"; @@ -23,17 +25,19 @@ type Props = { const cycleService = new CycleService(); -export const TransferIssuesModal: React.FC = ({ isOpen, handleClose }) => { +export const TransferIssuesModal: React.FC = observer(({ isOpen, handleClose }) => { const [query, setQuery] = useState(""); + const { cycleIssues: cycleIssueStore } = useMobxStore(); + const router = useRouter(); const { workspaceSlug, projectId, cycleId } = router.query; const { setToastAlert } = useToast(); const transferIssue = async (payload: any) => { - await cycleService - .transferIssues(workspaceSlug as string, projectId as string, cycleId as string, payload) + await cycleIssueStore + .transferIssuesFromCycle(workspaceSlug as string, projectId as string, cycleId as string, payload) .then(() => { setToastAlert({ type: "success", @@ -159,4 +163,4 @@ export const TransferIssuesModal: React.FC = ({ isOpen, handleClose }) => ); -}; +}); diff --git a/web/components/inbox/actions-header.tsx b/web/components/inbox/actions-header.tsx index be077df5c..83cdf1446 100644 --- a/web/components/inbox/actions-header.tsx +++ b/web/components/inbox/actions-header.tsx @@ -22,6 +22,7 @@ import { Button } from "@plane/ui"; import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Inbox, Trash2, XCircle } from "lucide-react"; // types import type { TInboxStatus } from "types"; +import { EUserWorkspaceRoles } from "constants/workspace"; export const InboxActionsHeader = observer(() => { const [date, setDate] = useState(new Date()); @@ -71,7 +72,7 @@ export const InboxActionsHeader = observer(() => { }, [issue]); const issueStatus = issue?.issue_inbox[0].status; - const isAllowed = userRole === 15 || userRole === 20; + const isAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER; const today = new Date(); const tomorrow = new Date(today); diff --git a/web/components/inbox/main-content.tsx b/web/components/inbox/main-content.tsx index 02ead34cb..193f59263 100644 --- a/web/components/inbox/main-content.tsx +++ b/web/components/inbox/main-content.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from "react"; +import { useCallback, useEffect, useState } from "react"; import Router, { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR from "swr"; @@ -8,14 +8,15 @@ import { AlertTriangle, CheckCircle2, Clock, Copy, ExternalLink, Inbox, XCircle // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // components -import { IssueDescriptionForm, IssueDetailsSidebar, IssueReaction } from "components/issues"; +import { IssueDescriptionForm, IssueDetailsSidebar, IssueReaction, IssueUpdateStatus } from "components/issues"; import { InboxIssueActivity } from "components/inbox"; // ui -import { Loader } from "@plane/ui"; +import { Loader, StateGroupIcon } from "@plane/ui"; // helpers import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; // types import { IInboxIssue, IIssue } from "types"; +import { EUserWorkspaceRoles } from "constants/workspace"; const defaultValues: Partial = { name: "", @@ -30,7 +31,15 @@ export const InboxMainContent: React.FC = observer(() => { const router = useRouter(); const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query; - const { inboxIssues: inboxIssuesStore, inboxIssueDetails: inboxIssueDetailsStore, user: userStore } = useMobxStore(); + // states + const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); + + const { + inboxIssues: inboxIssuesStore, + inboxIssueDetails: inboxIssueDetailsStore, + user: userStore, + projectState: { states }, + } = useMobxStore(); const user = userStore.currentUser; const userRole = userStore.currentProjectRole; @@ -54,6 +63,9 @@ export const InboxMainContent: React.FC = observer(() => { const issuesList = inboxId ? inboxIssuesStore.inboxIssues[inboxId.toString()] : undefined; const issueDetails = inboxIssueId ? inboxIssueDetailsStore.issueDetails[inboxIssueId.toString()] : undefined; + const currentIssueState = projectId + ? states[projectId.toString()]?.find((s) => s.id === issueDetails?.state) + : undefined; const submitChanges = useCallback( async (formData: Partial) => { @@ -144,6 +156,8 @@ export const InboxMainContent: React.FC = observer(() => { ); + const isAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER; + return ( <> {issueDetails ? ( @@ -214,15 +228,27 @@ export const InboxMainContent: React.FC = observer(() => { ) : null} +
+ {currentIssueState && ( + + )} + +
setIsSubmitting(value)} + isSubmitting={isSubmitting} workspaceSlug={workspaceSlug as string} issue={{ name: issueDetails.name, description_html: issueDetails.description_html, }} handleFormSubmit={submitChanges} - isAllowed={userRole === 15 || userRole === 20 || user?.id === issueDetails.created_by} + isAllowed={isAllowed || user?.id === issueDetails.created_by} />
diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx index 43600533b..ef26e22d8 100644 --- a/web/components/issues/description-form.tsx +++ b/web/components/issues/description-form.tsx @@ -26,14 +26,15 @@ export interface IssueDetailsProps { workspaceSlug: string; handleFormSubmit: (value: IssueDescriptionFormValues) => Promise; isAllowed: boolean; + isSubmitting: "submitting" | "submitted" | "saved"; + setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void; } const fileService = new FileService(); export const IssueDescriptionForm: FC = (props) => { - const { issue, handleFormSubmit, workspaceSlug, isAllowed } = props; + const { issue, handleFormSubmit, workspaceSlug, isAllowed, isSubmitting, setIsSubmitting } = props; // states - const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); const [characterLimit, setCharacterLimit] = useState(false); const { setShowAlert } = useReloadConfirmations(); @@ -166,13 +167,6 @@ export const IssueDescriptionForm: FC = (props) => { /> )} /> -
- {isSubmitting === "submitting" ? "Saving..." : "Saved"} -
); diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index 0cf3c8bda..f8f0ba003 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -15,6 +15,7 @@ export * from "./sidebar"; export * from "./label"; export * from "./issue-reaction"; export * from "./confirm-issue-discard"; +export * from "./issue-update-status"; // draft issue export * from "./draft-issue-form"; diff --git a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx index b2a75e2d3..bf7ed2b42 100644 --- a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx +++ b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx @@ -25,6 +25,7 @@ import { IViewIssuesStore, } from "store/issues"; import { TUnGroupedIssues } from "store/issues/types"; +import { EUserWorkspaceRoles } from "constants/workspace"; interface IBaseGanttRoot { issueFiltersStore: @@ -69,7 +70,7 @@ export const BaseGanttRoot: React.FC = observer((props: IBaseGan ); }; - const isAllowed = currentProjectRole && currentProjectRole >= 15; + const isAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; return ( <> diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index e63b6e745..e5d279809 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -31,6 +31,7 @@ import { KanBan } from "./default"; import { KanBanSwimLanes } from "./swimlanes"; import { EProjectStore } from "store/command-palette.store"; import { IssuePeekOverview } from "components/issues"; +import { EUserWorkspaceRoles } from "constants/workspace"; export interface IBaseKanBanLayout { issueStore: @@ -93,7 +94,7 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas } = useMobxStore(); const { currentProjectRole } = userStore; - const isEditingAllowed = [15, 20].includes(currentProjectRole || 0); + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; const issues = issueStore?.getIssues || {}; const issueIds = issueStore?.getIssuesIds || []; @@ -223,7 +224,7 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas isDragStarted={isDragStarted} quickAddCallback={issueStore?.quickAddIssue} viewId={viewId} - disableIssueCreation={!enableIssueCreation} + disableIssueCreation={!enableIssueCreation || !isEditingAllowed} isReadOnly={!enableInlineEditing || !isEditingAllowed} currentStore={currentStore} addIssuesToView={addIssuesToView} diff --git a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx index f58001402..8fc2c6e17 100644 --- a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; // components import { CustomMenu } from "@plane/ui"; import { CreateUpdateIssueModal } from "components/issues/modal"; +import { CreateUpdateDraftIssueModal } from "components/issues/draft-issue-modal"; import { ExistingIssuesListModal } from "components/core"; // lucide icons import { Minimize2, Maximize2, Circle, Plus } from "lucide-react"; @@ -51,6 +52,8 @@ export const HeaderGroupByCard: FC = observer((props) => { const router = useRouter(); const { workspaceSlug, projectId, moduleId, cycleId } = router.query; + const isDraftIssue = router.pathname.includes("draft-issue"); + const { setToastAlert } = useToast(); const renderExistingIssueModal = moduleId || cycleId; @@ -73,12 +76,21 @@ export const HeaderGroupByCard: FC = observer((props) => { return ( <> - setIsOpen(false)} - prePopulateData={issuePayload} - currentStore={currentStore} - /> + {isDraftIssue ? ( + setIsOpen(false)} + prePopulateData={issuePayload} + fieldsToShow={["all"]} + /> + ) : ( + setIsOpen(false)} + prePopulateData={issuePayload} + currentStore={currentStore} + /> + )} {renderExistingIssueModal && ( { } = useMobxStore(); const { currentProjectRole } = userStore; - const isEditingAllowed = [15, 20].includes(currentProjectRole || 0); + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; const issueIds = issueStore?.getIssuesIds || []; const issues = issueStore?.getIssues; @@ -147,7 +148,7 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { quickAddCallback={issueStore?.quickAddIssue} enableIssueQuickAdd={!!enableQuickAdd} isReadonly={!enableInlineEditing || !isEditingAllowed} - disableIssueCreation={!enableIssueCreation} + disableIssueCreation={!enableIssueCreation || !isEditingAllowed} currentStore={currentStore} addIssuesToView={addIssuesToView} /> diff --git a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx index c49d33d1e..24dbf435d 100644 --- a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -3,6 +3,7 @@ import { useRouter } from "next/router"; // lucide icons import { CircleDashed, Plus } from "lucide-react"; // components +import { CreateUpdateDraftIssueModal } from "components/issues/draft-issue-modal"; import { CreateUpdateIssueModal } from "components/issues/modal"; import { ExistingIssuesListModal } from "components/core"; import { CustomMenu } from "@plane/ui"; @@ -32,6 +33,8 @@ export const HeaderGroupByCard = observer( const [openExistingIssueListModal, setOpenExistingIssueListModal] = React.useState(false); + const isDraftIssue = router.pathname.includes("draft-issue"); + const { setToastAlert } = useToast(); const renderExistingIssueModal = moduleId || cycleId; @@ -90,12 +93,21 @@ export const HeaderGroupByCard = observer( ))} - setIsOpen(false)} - currentStore={currentStore} - prePopulateData={issuePayload} - /> + {isDraftIssue ? ( + setIsOpen(false)} + prePopulateData={issuePayload} + fieldsToShow={["all"]} + /> + ) : ( + setIsOpen(false)} + currentStore={currentStore} + prePopulateData={issuePayload} + /> + )} {renderExistingIssueModal && ( { } = useMobxStore(); const { currentProjectRole } = userStore; - const isEditingAllowed = [15, 20].includes(currentProjectRole || 0); + const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; const issuesResponse = issueStore.getIssues; const issueIds = (issueStore.getIssuesIds ?? []) as TUnGroupedIssues; diff --git a/web/components/issues/issue-peek-overview/issue-detail.tsx b/web/components/issues/issue-peek-overview/issue-detail.tsx index 60d20483e..3e90c8b8d 100644 --- a/web/components/issues/issue-peek-overview/issue-detail.tsx +++ b/web/components/issues/issue-peek-overview/issue-detail.tsx @@ -14,6 +14,7 @@ import { IIssue } from "types"; // services import { FileService } from "services/file.service"; import { useMobxStore } from "lib/mobx/store-provider"; +import { EUserWorkspaceRoles } from "constants/workspace"; const fileService = new FileService(); @@ -25,16 +26,27 @@ interface IPeekOverviewIssueDetails { issueUpdate: (issue: Partial) => void; issueReactionCreate: (reaction: string) => void; issueReactionRemove: (reaction: string) => void; + isSubmitting: "submitting" | "submitted" | "saved"; + setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void; } export const PeekOverviewIssueDetails: FC = (props) => { - const { workspaceSlug, issue, issueReactions, user, issueUpdate, issueReactionCreate, issueReactionRemove } = props; + const { + workspaceSlug, + issue, + issueReactions, + user, + issueUpdate, + issueReactionCreate, + issueReactionRemove, + isSubmitting, + setIsSubmitting, + } = props; // store const { user: userStore } = useMobxStore(); const { currentProjectRole } = userStore; - const isAllowed = [15, 20].includes(currentProjectRole || 0); + const isAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER; // states - const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); const [characterLimit, setCharacterLimit] = useState(false); // hooks const { setShowAlert } = useReloadConfirmations(); @@ -171,13 +183,6 @@ export const PeekOverviewIssueDetails: FC = (props) = /> )} /> -
- {isSubmitting === "submitting" ? "Saving..." : "Saved"} -
= observer((props) => { } }; - const userRole = userStore.currentProjectRole ?? 5; + const userRole = userStore.currentProjectRole ?? EUserWorkspaceRoles.GUEST; return ( diff --git a/web/components/issues/issue-peek-overview/view.tsx b/web/components/issues/issue-peek-overview/view.tsx index 25b8884cf..a05ec9ac1 100644 --- a/web/components/issues/issue-peek-overview/view.tsx +++ b/web/components/issues/issue-peek-overview/view.tsx @@ -8,8 +8,7 @@ import { PeekOverviewIssueDetails } from "./issue-detail"; import { PeekOverviewProperties } from "./properties"; import { IssueComment } from "./activity"; import { Button, CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon, Spinner } from "@plane/ui"; -import { DeleteIssueModal } from "../delete-issue-modal"; -import { DeleteArchivedIssueModal } from "../delete-archived-issue-modal"; +import { DeleteIssueModal, DeleteArchivedIssueModal, IssueUpdateStatus } from "components/issues/"; // types import { IIssue } from "types"; // hooks @@ -93,6 +92,7 @@ export const IssueView: FC = observer((props) => { const [peekMode, setPeekMode] = useState("side-peek"); const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); const updateRoutePeekId = () => { if (issueId != peekIssueId) { @@ -216,33 +216,35 @@ export const IssueView: FC = observer((props) => { )} - -
- {issue?.created_by !== user?.id && - !issue?.assignees.includes(user?.id ?? "") && - !router.pathname.includes("[archivedIssueId]") && ( - - )} - - {!disableUserActions && ( - + )} + - )} + {!disableUserActions && ( + + )} +
@@ -261,6 +263,8 @@ export const IssueView: FC = observer((props) => {
)} setIsSubmitting(value)} + isSubmitting={isSubmitting} workspaceSlug={workspaceSlug} issue={issue} issueUpdate={issueUpdate} @@ -295,6 +299,8 @@ export const IssueView: FC = observer((props) => {
setIsSubmitting(value)} + isSubmitting={isSubmitting} workspaceSlug={workspaceSlug} issue={issue} issueReactions={issueReactions} diff --git a/web/components/issues/issue-update-status.tsx b/web/components/issues/issue-update-status.tsx new file mode 100644 index 000000000..e6852936e --- /dev/null +++ b/web/components/issues/issue-update-status.tsx @@ -0,0 +1,32 @@ +import React from "react"; +import { RefreshCw } from "lucide-react"; +// types +import { IIssue } from "types"; + +type Props = { + isSubmitting: "submitting" | "submitted" | "saved"; + issueDetail?: IIssue; +}; + +export const IssueUpdateStatus: React.FC = (props) => { + const { isSubmitting, issueDetail } = props; + return ( + <> + {issueDetail && ( +

+ {issueDetail.project_detail?.identifier}-{issueDetail.sequence_id} +

+ )} +
+ {isSubmitting !== "submitted" && isSubmitting !== "saved" && ( + + )} + {isSubmitting === "submitting" ? "Saving..." : "Saved"} +
+ + ); +}; diff --git a/web/components/issues/main-content.tsx b/web/components/issues/main-content.tsx index b8e1ce871..bd18cb73b 100644 --- a/web/components/issues/main-content.tsx +++ b/web/components/issues/main-content.tsx @@ -2,6 +2,7 @@ import Link from "next/link"; import { useRouter } from "next/router"; import { observer } from "mobx-react-lite"; import useSWR, { mutate } from "swr"; +import { MinusCircle } from "lucide-react"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; // services @@ -16,16 +17,17 @@ import { IssueAttachments, IssueDescriptionForm, IssueReaction, + IssueUpdateStatus, } from "components/issues"; +import { useState } from "react"; import { SubIssuesRoot } from "./sub-issues"; // ui -import { CustomMenu, LayersIcon } from "@plane/ui"; -// icons -import { MinusCircle } from "lucide-react"; +import { CustomMenu, LayersIcon, StateGroupIcon } from "@plane/ui"; // types import { IIssue, IIssueComment } from "types"; // fetch-keys import { PROJECT_ISSUES_ACTIVITY, SUB_ISSUES } from "constants/fetch-keys"; +import { EUserWorkspaceRoles } from "constants/workspace"; type Props = { issueDetails: IIssue; @@ -40,15 +42,25 @@ const issueCommentService = new IssueCommentService(); export const IssueMainContent: React.FC = observer((props) => { const { issueDetails, submitChanges, uneditable = false } = props; + // states + const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); + const router = useRouter(); const { workspaceSlug, projectId, issueId } = router.query; const { setToastAlert } = useToast(); - const { user: userStore, project: projectStore } = useMobxStore(); + const { + user: userStore, + project: projectStore, + projectState: { states }, + } = useMobxStore(); const user = userStore.currentUser ?? undefined; const userRole = userStore.currentProjectRole; const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : undefined; + const currentIssueState = projectId + ? states[projectId.toString()]?.find((s) => s.id === issueDetails.state) + : undefined; const { data: siblingIssues } = useSWR( workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null, @@ -100,6 +112,8 @@ export const IssueMainContent: React.FC = observer((props) => { ); }; + const isAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER; + return ( <>
@@ -162,11 +176,23 @@ export const IssueMainContent: React.FC = observer((props) => {
) : null} +
+ {currentIssueState && ( + + )} + +
setIsSubmitting(value)} + isSubmitting={isSubmitting} workspaceSlug={workspaceSlug as string} issue={issueDetails} handleFormSubmit={submitChanges} - isAllowed={userRole === 20 || userRole === 15 || !uneditable} + isAllowed={isAllowed || !uneditable} /> diff --git a/web/components/issues/sidebar.tsx b/web/components/issues/sidebar.tsx index c0bb2da18..b0315304d 100644 --- a/web/components/issues/sidebar.tsx +++ b/web/components/issues/sidebar.tsx @@ -33,13 +33,14 @@ import { import { CustomDatePicker } from "components/ui"; // icons import { Bell, CalendarDays, LinkIcon, Plus, Signal, Tag, Trash2, Triangle, User2 } from "lucide-react"; -import { Button, ContrastIcon, DiceIcon, DoubleCircleIcon, UserGroupIcon } from "@plane/ui"; +import { Button, ContrastIcon, DiceIcon, DoubleCircleIcon, StateGroupIcon, UserGroupIcon } from "@plane/ui"; // helpers import { copyTextToClipboard } from "helpers/string.helper"; // types import type { IIssue, IIssueLink, linkDetails } from "types"; // fetch-keys import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; +import { EUserWorkspaceRoles } from "constants/workspace"; type Props = { control: any; @@ -79,12 +80,15 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { const [linkModal, setLinkModal] = useState(false); const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState(null); - const { user: userStore } = useMobxStore(); + const { + user: userStore, + projectState: { states }, + } = useMobxStore(); const user = userStore.currentUser; const userRole = userStore.currentProjectRole; const router = useRouter(); - const { workspaceSlug, projectId, issueId } = router.query; + const { workspaceSlug, projectId, issueId, inboxIssueId } = router.query; const { isEstimateActive } = useEstimateOption(); @@ -245,7 +249,11 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { setLinkModal(true); }; - const isNotAllowed = userRole === 5 || userRole === 10; + const isAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER; + + const currentIssueState = projectId + ? states[projectId.toString()]?.find((s) => s.id === issueDetail?.state) + : undefined; return ( <> @@ -265,9 +273,20 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { )}
-

- {issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id} -

+
+ {currentIssueState ? ( + + ) : inboxIssueId ? ( + + ) : null} +

+ {issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id} +

+
{issueDetail?.created_by !== user?.id && !issueDetail?.assignees.includes(user?.id ?? "") && @@ -295,7 +314,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { )} - {!isNotAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && ( + {isAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && (
@@ -586,7 +605,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {
@@ -605,7 +624,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { issueDetails={issueDetail} labelList={issueDetail?.labels ?? []} submitChanges={submitChanges} - isNotAllowed={isNotAllowed} + isNotAllowed={!isAllowed} uneditable={uneditable ?? false} />
@@ -615,7 +634,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => {

Links

- {!isNotAllowed && ( + {isAllowed && ( )} diff --git a/web/components/project/project-settings-member-defaults.tsx b/web/components/project/project-settings-member-defaults.tsx index fd0f806c3..f45e1fcfa 100644 --- a/web/components/project/project-settings-member-defaults.tsx +++ b/web/components/project/project-settings-member-defaults.tsx @@ -15,6 +15,7 @@ import { Loader } from "@plane/ui"; import { IProject, IUserLite, IWorkspace } from "types"; // fetch-keys import { PROJECT_MEMBERS } from "constants/fetch-keys"; +import { EUserWorkspaceRoles } from "constants/workspace"; const defaultValues: Partial = { project_lead: null, @@ -29,7 +30,7 @@ export const ProjectSettingsMemberDefaults: React.FC = observer(() => { const { user: userStore, project: projectStore } = useMobxStore(); const { currentProjectDetails } = projectStore; const { currentProjectRole } = userStore; - const isAdmin = currentProjectRole === 20; + const isAdmin = currentProjectRole === EUserWorkspaceRoles.ADMIN; // hooks const { setToastAlert } = useToast(); // form info diff --git a/web/components/project/send-project-invitation-modal.tsx b/web/components/project/send-project-invitation-modal.tsx index 8ad55ebec..dab389f74 100644 --- a/web/components/project/send-project-invitation-modal.tsx +++ b/web/components/project/send-project-invitation-modal.tsx @@ -15,7 +15,7 @@ import useToast from "hooks/use-toast"; // types import { IProjectMember, TUserProjectRole } from "types"; // constants -import { ROLE } from "constants/workspace"; +import { EUserWorkspaceRoles, ROLE } from "constants/workspace"; type Props = { isOpen: boolean; @@ -246,7 +246,8 @@ export const SendProjectInvitationModal: React.FC = observer((props) => { width="w-full" > {Object.entries(ROLE).map(([key, label]) => { - if (parseInt(key) > (currentProjectRole ?? 5)) return null; + if (parseInt(key) > (currentProjectRole ?? EUserWorkspaceRoles.GUEST)) + return null; return ( diff --git a/web/components/project/settings/features-list.tsx b/web/components/project/settings/features-list.tsx index d562f19f7..b8a776bb0 100644 --- a/web/components/project/settings/features-list.tsx +++ b/web/components/project/settings/features-list.tsx @@ -9,6 +9,7 @@ import { useMobxStore } from "lib/mobx/store-provider"; import useToast from "hooks/use-toast"; // types import { IProject } from "types"; +import { EUserWorkspaceRoles } from "constants/workspace"; type Props = {}; @@ -56,7 +57,7 @@ export const ProjectFeaturesList: FC = observer(() => { user: { currentUser, currentProjectRole }, trackEvent: { setTrackElement, postHogEventTracker }, } = useMobxStore(); - const isAdmin = currentProjectRole === 20; + const isAdmin = currentProjectRole === EUserWorkspaceRoles.ADMIN; // hooks const { setToastAlert } = useToast(); @@ -97,7 +98,7 @@ export const ProjectFeaturesList: FC = observer(() => { project_id: currentProjectDetails?.id, project_name: currentProjectDetails?.name, project_identifier: currentProjectDetails?.identifier, - enabled: !currentProjectDetails?.[feature.property as keyof IProject] + enabled: !currentProjectDetails?.[feature.property as keyof IProject], }); handleSubmit({ [feature.property]: !currentProjectDetails?.[feature.property as keyof IProject], diff --git a/web/components/project/sidebar-list-item.tsx b/web/components/project/sidebar-list-item.tsx index 488ae571a..5ad757e5f 100644 --- a/web/components/project/sidebar-list-item.tsx +++ b/web/components/project/sidebar-list-item.tsx @@ -284,7 +284,7 @@ export const ProjectSidebarListItem: React.FC = observer((props) => {
- Leave Project + Leave project
)} diff --git a/web/components/workspace/settings/members-list-item.tsx b/web/components/workspace/settings/members-list-item.tsx index 7536e78c9..751fc14e1 100644 --- a/web/components/workspace/settings/members-list-item.tsx +++ b/web/components/workspace/settings/members-list-item.tsx @@ -243,7 +243,7 @@ export const WorkspaceMembersListItem: FC = observer((props) => { : "opacity-0 pointer-events-none" } > - +
diff --git a/web/helpers/user.helper.ts b/web/helpers/user.helper.ts index 569da6018..7af73b021 100644 --- a/web/helpers/user.helper.ts +++ b/web/helpers/user.helper.ts @@ -1,12 +1,14 @@ -export const getUserRole = (role: number) => { +import { EUserWorkspaceRoles } from "constants/workspace"; + +export const getUserRole = (role: EUserWorkspaceRoles) => { switch (role) { - case 5: + case EUserWorkspaceRoles.GUEST: return "GUEST"; - case 10: + case EUserWorkspaceRoles.VIEWER: return "VIEWER"; - case 15: + case EUserWorkspaceRoles.MEMBER: return "MEMBER"; - case 20: + case EUserWorkspaceRoles.ADMIN: return "ADMIN"; } }; diff --git a/web/hooks/use-sign-in-redirection.ts b/web/hooks/use-sign-in-redirection.ts new file mode 100644 index 000000000..1863e510e --- /dev/null +++ b/web/hooks/use-sign-in-redirection.ts @@ -0,0 +1,74 @@ +import { useCallback, useState } from "react"; +import { useRouter } from "next/router"; +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; +// types +import { IUser, IUserSettings } from "types"; + +type UseSignInRedirectionProps = { + error: any | null; + isRedirecting: boolean; + handleRedirection: () => Promise; +}; + +const useSignInRedirection = (): UseSignInRedirectionProps => { + // states + const [isRedirecting, setIsRedirecting] = useState(true); + const [error, setError] = useState(null); + // router + const router = useRouter(); + const { next_url } = router.query; + // mobx store + const { + user: { fetchCurrentUser, fetchCurrentUserSettings }, + } = useMobxStore(); + + const handleSignInRedirection = useCallback( + async (user: IUser) => { + // if the user is not onboarded, redirect them to the onboarding page + if (!user.is_onboarded) { + router.push("/onboarding"); + return; + } + // if next_url is provided, redirect the user to that url + if (next_url) { + router.push(next_url.toString()); + return; + } + + // if the user is onboarded, fetch their last workspace details + await fetchCurrentUserSettings() + .then((userSettings: IUserSettings) => { + const workspaceSlug = + userSettings?.workspace?.last_workspace_slug || userSettings?.workspace?.fallback_workspace_slug; + if (workspaceSlug) router.push(`/${workspaceSlug}`); + else router.push("/profile"); + }) + .catch((err) => setError(err)); + }, + [fetchCurrentUserSettings, router, next_url] + ); + + const updateUserInfo = useCallback(async () => { + setIsRedirecting(true); + + await fetchCurrentUser() + .then(async (user) => { + await handleSignInRedirection(user) + .catch((err) => setError(err)) + .finally(() => setIsRedirecting(false)); + }) + .catch((err) => { + setError(err); + setIsRedirecting(false); + }); + }, [fetchCurrentUser, handleSignInRedirection]); + + return { + error, + isRedirecting, + handleRedirection: updateUserInfo, + }; +}; + +export default useSignInRedirection; diff --git a/web/layouts/settings-layout/project/layout.tsx b/web/layouts/settings-layout/project/layout.tsx index 7a0bee0eb..d823cbe70 100644 --- a/web/layouts/settings-layout/project/layout.tsx +++ b/web/layouts/settings-layout/project/layout.tsx @@ -1,15 +1,27 @@ import { FC, ReactNode } from "react"; // components import { ProjectSettingsSidebar } from "./sidebar"; +import { useMobxStore } from "lib/mobx/store-provider"; +import { EUserWorkspaceRoles } from "constants/workspace"; +import { NotAuthorizedView } from "components/auth-screens"; +import { observer } from "mobx-react-lite"; export interface IProjectSettingLayout { children: ReactNode; } -export const ProjectSettingLayout: FC = (props) => { +export const ProjectSettingLayout: FC = observer((props) => { const { children } = props; - return ( + const { + user: { currentProjectRole }, + } = useMobxStore(); + + const restrictViewSettings = currentProjectRole && currentProjectRole <= EUserWorkspaceRoles.VIEWER; + + return restrictViewSettings ? ( + + ) : (
@@ -17,4 +29,4 @@ export const ProjectSettingLayout: FC = (props) => { {children}
); -}; +}); diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx index 7889deb5a..6ae324495 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/automations.tsx @@ -14,6 +14,7 @@ import { ProjectSettingHeader } from "components/headers"; // types import { NextPageWithLayout } from "types/app"; import { IProject } from "types"; +import { EUserWorkspaceRoles } from "constants/workspace"; const AutomationSettingsPage: NextPageWithLayout = observer(() => { const router = useRouter(); @@ -39,7 +40,7 @@ const AutomationSettingsPage: NextPageWithLayout = observer(() => { }); }; - const isAdmin = currentProjectRole === 20; + const isAdmin = currentProjectRole === EUserWorkspaceRoles.ADMIN; return (
diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/estimates.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/estimates.tsx index 783243c4a..ba4475148 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/estimates.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/estimates.tsx @@ -7,12 +7,23 @@ import { ProjectSettingHeader } from "components/headers"; import { EstimatesList } from "components/estimates"; // types import { NextPageWithLayout } from "types/app"; +import { useMobxStore } from "lib/mobx/store-provider"; +import { EUserWorkspaceRoles } from "constants/workspace"; +import { observer } from "mobx-react-lite"; -const EstimatesSettingsPage: NextPageWithLayout = () => ( -
- -
-); +const EstimatesSettingsPage: NextPageWithLayout = observer(() => { + const { + user: { currentProjectRole }, + } = useMobxStore(); + + const isAdmin = currentProjectRole === EUserWorkspaceRoles.ADMIN; + + return ( +
+ +
+ ); +}); EstimatesSettingsPage.getLayout = function getLayout(page: ReactElement) { return ( diff --git a/web/pages/accounts/password.tsx b/web/pages/accounts/password.tsx index f85840cda..db6c28a7e 100644 --- a/web/pages/accounts/password.tsx +++ b/web/pages/accounts/password.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useCallback } from "react"; +import { ReactElement } from "react"; import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -9,6 +9,7 @@ import { Controller, useForm } from "react-hook-form"; import { AuthService } from "services/auth.service"; // hooks import useToast from "hooks/use-toast"; +import useSignInRedirection from "hooks/use-sign-in-redirection"; // layouts import DefaultLayout from "layouts/default-layout"; // ui @@ -20,9 +21,6 @@ import latestFeatures from "public/onboarding/onboarding-pages.svg"; import { checkEmailValidity } from "helpers/string.helper"; // type import { NextPageWithLayout } from "types/app"; -import { useMobxStore } from "lib/mobx/store-provider"; -// types -import { IUser, IUserSettings } from "types"; type TResetPasswordFormValues = { email: string; @@ -45,10 +43,8 @@ const HomePage: NextPageWithLayout = () => { const { resolvedTheme } = useTheme(); // toast const { setToastAlert } = useToast(); - // mobx store - const { - user: { fetchCurrentUser, fetchCurrentUserSettings }, - } = useMobxStore(); + // sign in redirection hook + const { handleRedirection } = useSignInRedirection(); // form info const { control, @@ -61,31 +57,6 @@ const HomePage: NextPageWithLayout = () => { }, }); - const handleSignInRedirection = useCallback( - async (user: IUser) => { - // if the user is not onboarded, redirect them to the onboarding page - if (!user.is_onboarded) { - router.push("/onboarding"); - return; - } - - // if the user is onboarded, fetch their last workspace details - await fetchCurrentUserSettings().then((userSettings: IUserSettings) => { - const workspaceSlug = - userSettings?.workspace?.last_workspace_slug || userSettings?.workspace?.fallback_workspace_slug; - if (workspaceSlug) router.push(`/${workspaceSlug}`); - else router.push("/profile"); - }); - }, - [fetchCurrentUserSettings, router] - ); - - const mutateUserInfo = useCallback(async () => { - await fetchCurrentUser().then(async (user) => { - await handleSignInRedirection(user); - }); - }, [fetchCurrentUser, handleSignInRedirection]); - const handleResetPassword = async (formData: TResetPasswordFormValues) => { if (!uidb64 || !token || !email) return; @@ -95,7 +66,7 @@ const HomePage: NextPageWithLayout = () => { await authService .resetPassword(uidb64.toString(), token.toString(), payload) - .then(() => mutateUserInfo()) + .then(() => handleRedirection()) .catch((err) => setToastAlert({ type: "error", diff --git a/web/public/onboarding/onboarding-pages.svg b/web/public/onboarding/onboarding-pages.svg index 93118ebe0..6b0e77a3c 100644 --- a/web/public/onboarding/onboarding-pages.svg +++ b/web/public/onboarding/onboarding-pages.svg @@ -1,62 +1,62 @@ - - - + + + - - + + - + - + - + - - + + - + - + - + - + - - + + - + - - + + diff --git a/web/store/inbox/inbox_filters.store.ts b/web/store/inbox/inbox_filters.store.ts index c5758ad9d..abd125939 100644 --- a/web/store/inbox/inbox_filters.store.ts +++ b/web/store/inbox/inbox_filters.store.ts @@ -5,6 +5,7 @@ import { RootStore } from "../root"; import { InboxService } from "services/inbox.service"; // types import { IInbox, IInboxFilterOptions, IInboxQueryParams } from "types"; +import { EUserWorkspaceRoles } from "constants/workspace"; export interface IInboxFiltersStore { // states @@ -132,8 +133,8 @@ export class InboxFiltersStore implements IInboxFiltersStore { }; }); - const userRole = this.rootStore.user?.projectMemberInfo?.[projectId]?.role || 0; - if (userRole > 10) { + const userRole = this.rootStore.user?.currentProjectRole || EUserWorkspaceRoles.GUEST; + if (userRole > EUserWorkspaceRoles.VIEWER) { await this.inboxService.patchInbox(workspaceSlug, projectId, inboxId, { view_props: newViewProps }); } } catch (error) { diff --git a/web/store/issues/project-issues/cycle/issue.store.ts b/web/store/issues/project-issues/cycle/issue.store.ts index 341013ce4..a951914a1 100644 --- a/web/store/issues/project-issues/cycle/issue.store.ts +++ b/web/store/issues/project-issues/cycle/issue.store.ts @@ -62,7 +62,14 @@ export interface ICycleIssuesStore { issueId: string, issueBridgeId: string ) => Promise; - + transferIssuesFromCycle: ( + workspaceSlug: string, + projectId: string, + cycleId: string, + payload: { + new_cycle_id: string; + } + ) => Promise; viewFlags: ViewFlags; } @@ -103,6 +110,7 @@ export class CycleIssuesStore extends IssueBaseStore implements ICycleIssuesStor quickAddIssue: action, addIssueToCycle: action, removeIssueFromCycle: action, + transferIssuesFromCycle: action, }); this.rootStore = _rootStore; @@ -348,4 +356,28 @@ export class CycleIssuesStore extends IssueBaseStore implements ICycleIssuesStor throw error; } }; + + transferIssuesFromCycle = async ( + workspaceSlug: string, + projectId: string, + cycleId: string, + payload: { + new_cycle_id: string; + } + ) => { + try { + const response = await this.cycleService.transferIssues( + workspaceSlug as string, + projectId as string, + cycleId as string, + payload + ); + await this.fetchIssues(workspaceSlug, projectId, "mutation", cycleId); + + return response; + } catch (error) { + this.fetchIssues(workspaceSlug, projectId, "mutation", cycleId); + throw error; + } + }; } diff --git a/web/store/issues/project-issues/draft/issue.store.ts b/web/store/issues/project-issues/draft/issue.store.ts index 0333c066c..ddcebc3f0 100644 --- a/web/store/issues/project-issues/draft/issue.store.ts +++ b/web/store/issues/project-issues/draft/issue.store.ts @@ -36,7 +36,7 @@ export class ProjectDraftIssuesStore extends IssueBaseStore implements IProjectD //viewData viewFlags = { enableQuickAdd: false, - enableIssueCreation: false, + enableIssueCreation: true, enableInlineEditing: false, };