Merge branch 'develop' of github.com:makeplane/plane into chore/workspace_invite

This commit is contained in:
NarayanBavisetti 2023-12-05 14:50:25 +05:30
commit e9945ee129
72 changed files with 830 additions and 600 deletions

View File

@ -14,6 +14,11 @@ PGHOST="plane-db"
PGDATABASE="plane" PGDATABASE="plane"
DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE} DATABASE_URL=postgresql://${PGUSER}:${PGPASSWORD}@${PGHOST}/${PGDATABASE}
# Oauth variables
GOOGLE_CLIENT_ID=""
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET=""
# Redis Settings # Redis Settings
REDIS_HOST="plane-redis" REDIS_HOST="plane-redis"
REDIS_PORT="6379" REDIS_PORT="6379"
@ -50,7 +55,6 @@ NGINX_PORT=80
# SignUps # SignUps
ENABLE_SIGNUP="1" ENABLE_SIGNUP="1"
# Enable Email/Password Signup # Enable Email/Password Signup
ENABLE_EMAIL_PASSWORD="1" ENABLE_EMAIL_PASSWORD="1"

View File

@ -30,6 +30,11 @@ class CycleSerializer(BaseSerializer):
model = Cycle model = Cycle
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"id",
"created_at",
"updated_at",
"created_by",
"updated_by",
"workspace", "workspace",
"project", "project",
"owned_by", "owned_by",

View File

@ -1,3 +1,6 @@
from lxml import html
# Django imports # Django imports
from django.utils import timezone from django.utils import timezone
@ -21,7 +24,8 @@ from plane.db.models import (
from .base import BaseSerializer from .base import BaseSerializer
from .cycle import CycleSerializer, CycleLiteSerializer from .cycle import CycleSerializer, CycleLiteSerializer
from .module import ModuleSerializer, ModuleLiteSerializer from .module import ModuleSerializer, ModuleLiteSerializer
from .user import UserLiteSerializer
from .state import StateLiteSerializer
class IssueSerializer(BaseSerializer): class IssueSerializer(BaseSerializer):
assignees = serializers.ListField( assignees = serializers.ListField(
@ -42,7 +46,6 @@ class IssueSerializer(BaseSerializer):
class Meta: class Meta:
model = Issue model = Issue
fields = "__all__"
read_only_fields = [ read_only_fields = [
"id", "id",
"workspace", "workspace",
@ -52,6 +55,10 @@ class IssueSerializer(BaseSerializer):
"created_at", "created_at",
"updated_at", "updated_at",
] ]
exclude = [
"description",
"description_stripped",
]
def validate(self, data): def validate(self, data):
if ( if (
@ -61,6 +68,15 @@ class IssueSerializer(BaseSerializer):
): ):
raise serializers.ValidationError("Start date cannot exceed target date") 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 # Validate assignees are from project
if data.get("assignees", []): if data.get("assignees", []):
data["assignees"] = ProjectMember.objects.filter( data["assignees"] = ProjectMember.objects.filter(
@ -291,7 +307,6 @@ class IssueCommentSerializer(BaseSerializer):
class Meta: class Meta:
model = IssueComment model = IssueComment
fields = "__all__"
read_only_fields = [ read_only_fields = [
"id", "id",
"workspace", "workspace",
@ -302,6 +317,21 @@ class IssueCommentSerializer(BaseSerializer):
"created_at", "created_at",
"updated_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): class IssueActivitySerializer(BaseSerializer):
@ -331,12 +361,23 @@ class ModuleIssueSerializer(BaseSerializer):
] ]
class IssueExpandSerializer(BaseSerializer): class LabelLiteSerializer(BaseSerializer):
# Serialize the related cycle. It's a OneToOne relation.
cycle = CycleLiteSerializer(source="issue_cycle.cycle", read_only=True)
# 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) 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: class Meta:
model = Issue model = Issue

View File

@ -21,6 +21,7 @@ class ProjectSerializer(BaseSerializer):
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"id", "id",
'emoji',
"workspace", "workspace",
"created_at", "created_at",
"updated_at", "updated_at",

View File

@ -16,6 +16,11 @@ class StateSerializer(BaseSerializer):
model = State model = State
fields = "__all__" fields = "__all__"
read_only_fields = [ read_only_fields = [
"id",
"created_by",
"updated_by",
"created_at",
"updated_at",
"workspace", "workspace",
"project", "project",
] ]

View File

@ -11,10 +11,6 @@ class UserLiteSerializer(BaseSerializer):
"first_name", "first_name",
"last_name", "last_name",
"avatar", "avatar",
"is_bot",
"display_name", "display_name",
] ]
read_only_fields = [ read_only_fields = fields
"id",
"is_bot",
]

View File

@ -64,7 +64,7 @@ class StateAPIEndpoint(BaseAPIView):
) )
if state.default: 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 # Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=state_id).exists() issue_exist = Issue.issue_objects.filter(state=state_id).exists()

View File

@ -169,8 +169,8 @@ class ChangePasswordSerializer(serializers.Serializer):
Serializer for password change endpoint. Serializer for password change endpoint.
""" """
old_password = serializers.CharField(required=True) old_password = serializers.CharField(required=True)
new_password = serializers.CharField(required=True) new_password = serializers.CharField(required=True, min_length=8)
confirm_password = serializers.CharField(required=True) confirm_password = serializers.CharField(required=True, min_length=8)
def validate(self, data): def validate(self, data):
if data.get("old_password") == data.get("new_password"): if data.get("old_password") == data.get("new_password"):
@ -187,9 +187,7 @@ class ChangePasswordSerializer(serializers.Serializer):
class ResetPasswordSerializer(serializers.Serializer): class ResetPasswordSerializer(serializers.Serializer):
model = User
""" """
Serializer for password change endpoint. Serializer for password change endpoint.
""" """
new_password = serializers.CharField(required=True) new_password = serializers.CharField(required=True, min_length=8)

View File

@ -105,17 +105,21 @@ class ForgotPasswordEndpoint(BaseAPIView):
def post(self, request): def post(self, request):
email = request.data.get("email") email = request.data.get("email")
if User.objects.filter(email=email).exists(): try:
user = User.objects.get(email=email) validate_email(email)
uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) except ValidationError:
token = PasswordResetTokenGenerator().make_token(user) 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") current_site = request.META.get("HTTP_ORIGIN")
# send the forgot password email
forgot_password.delay( forgot_password.delay(
user.first_name, user.email, uidb64, token, current_site user.first_name, user.email, uidb64, token, current_site
) )
return Response( return Response(
{"message": "Check your email to reset your password"}, {"message": "Check your email to reset your password"},
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
@ -130,14 +134,18 @@ class ResetPasswordEndpoint(BaseAPIView):
def post(self, request, uidb64, token): def post(self, request, uidb64, token):
try: try:
# Decode the id from the uidb64
id = smart_str(urlsafe_base64_decode(uidb64)) id = smart_str(urlsafe_base64_decode(uidb64))
user = User.objects.get(id=id) user = User.objects.get(id=id)
# check if the token is valid for the user
if not PasswordResetTokenGenerator().check_token(user, token): if not PasswordResetTokenGenerator().check_token(user, token):
return Response( return Response(
{"error": "Token is invalid"}, {"error": "Token is invalid"},
status=status.HTTP_401_UNAUTHORIZED, status=status.HTTP_401_UNAUTHORIZED,
) )
# Reset the password
serializer = ResetPasswordSerializer(data=request.data) serializer = ResetPasswordSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
# set_password also hashes the password that the user will get # set_password also hashes the password that the user will get
@ -145,9 +153,9 @@ class ResetPasswordEndpoint(BaseAPIView):
user.is_password_autoset = False user.is_password_autoset = False
user.save() user.save()
# Log the user in
# Generate access token for the user # Generate access token for the user
access_token, refresh_token = get_tokens_for_user(user) access_token, refresh_token = get_tokens_for_user(user)
data = { data = {
"access_token": access_token, "access_token": access_token,
"refresh_token": refresh_token, "refresh_token": refresh_token,
@ -166,7 +174,6 @@ class ResetPasswordEndpoint(BaseAPIView):
class ChangePasswordEndpoint(BaseAPIView): class ChangePasswordEndpoint(BaseAPIView):
def post(self, request): def post(self, request):
serializer = ChangePasswordSerializer(data=request.data) serializer = ChangePasswordSerializer(data=request.data)
user = User.objects.get(pk=request.user.id) user = User.objects.get(pk=request.user.id)
if serializer.is_valid(): if serializer.is_valid():
if not user.check_password(serializer.data.get("old_password")): if not user.check_password(serializer.data.get("old_password")):
@ -218,16 +225,15 @@ class EmailCheckEndpoint(BaseAPIView):
] ]
def post(self, request): def post(self, request):
# get the email
# Check the instance registration # Check the instance registration
instance = Instance.objects.first() instance = Instance.objects.first()
if instance is None: if instance is None or not instance.is_setup_done:
return Response( return Response(
{"error": "Instance is not configured"}, {"error": "Instance is not configured"},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Get the configurations
instance_configuration = InstanceConfiguration.objects.values("key", "value") instance_configuration = InstanceConfiguration.objects.values("key", "value")
email = request.data.get("email", False) email = request.data.get("email", False)
@ -267,7 +273,7 @@ class EmailCheckEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
# Create the user with default values
user = User.objects.create( user = User.objects.create(
email=email, email=email,
username=uuid.uuid4().hex, username=uuid.uuid4().hex,
@ -325,7 +331,7 @@ class EmailCheckEndpoint(BaseAPIView):
first_time=True, first_time=True,
) )
# Automatically send the email # 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 # Existing user
else: else:
if type == "magic_code": if type == "magic_code":

View File

@ -10,14 +10,12 @@ from django.utils import timezone
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import validate_email from django.core.validators import validate_email
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import make_password
# Third party imports # Third party imports
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.permissions import AllowAny from rest_framework.permissions import AllowAny
from rest_framework import status from rest_framework import status
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
from sentry_sdk import capture_message from sentry_sdk import capture_message
# Module imports # Module imports
@ -33,7 +31,6 @@ from plane.settings.redis import redis_instance
from plane.license.models import InstanceConfiguration, Instance from plane.license.models import InstanceConfiguration, Instance
from plane.license.utils.instance_value import get_configuration_value from plane.license.utils.instance_value import get_configuration_value
from plane.bgtasks.event_tracking_task import auth_events 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 from plane.bgtasks.user_count_task import update_user_instance_user_count
@ -49,6 +46,14 @@ class SignUpEndpoint(BaseAPIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
def post(self, request): 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") instance_configuration = InstanceConfiguration.objects.values("key", "value")
email = request.data.get("email", False) email = request.data.get("email", False)
@ -71,6 +76,7 @@ class SignUpEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, 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 ( if (
get_configuration_value( get_configuration_value(
instance_configuration, instance_configuration,
@ -124,6 +130,14 @@ class SignInEndpoint(BaseAPIView):
permission_classes = (AllowAny,) permission_classes = (AllowAny,)
def post(self, request): 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) email = request.data.get("email", False)
password = request.data.get("password", False) password = request.data.get("password", False)
@ -144,14 +158,6 @@ class SignInEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, 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 # Get the user
user = User.objects.filter(email=email).first() user = User.objects.filter(email=email).first()
@ -288,6 +294,7 @@ class MagicSignInEndpoint(BaseAPIView):
] ]
def post(self, request): def post(self, request):
# Check if the instance configuration is done
instance = Instance.objects.first() instance = Instance.objects.first()
if instance is None or not instance.is_setup_done: if instance is None or not instance.is_setup_done:
return Response( return Response(

View File

@ -303,14 +303,6 @@ class OauthEndpoint(BaseAPIView):
instance_configuration = InstanceConfiguration.objects.values( instance_configuration = InstanceConfiguration.objects.values(
"key", "value" "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 ( if (
get_configuration_value( get_configuration_value(
instance_configuration, instance_configuration,

View File

@ -77,7 +77,7 @@ class StateViewSet(BaseViewSet):
) )
if state.default: 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 # Check for any issues in the state
issue_exist = Issue.issue_objects.filter(state=pk).exists() issue_exist = Issue.issue_objects.filter(state=pk).exists()

View File

@ -1,7 +1,8 @@
# Python imports # Python imports
import csv import csv
import io import io
import os import requests
import json
# Django imports # Django imports
from django.core.mail import EmailMultiAlternatives, get_connection 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.db.models import Issue
from plane.utils.analytics_plot import build_graph_plot from plane.utils.analytics_plot import build_graph_plot
from plane.utils.issue_filters import issue_filters from plane.utils.issue_filters import issue_filters
from plane.license.models import InstanceConfiguration from plane.license.models import InstanceConfiguration, Instance
from plane.license.utils.instance_value import get_configuration_value from plane.license.utils.instance_value import get_email_configuration
row_mapping = { row_mapping = {
"state__name": "State", "state__name": "State",
@ -43,7 +44,7 @@ CYCLE_ID = "issue_cycle__cycle_id"
MODULE_ID = "issue_module__module_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.""" """Helper function to send export email."""
subject = "Your Export is ready" subject = "Your Export is ready"
html_content = render_to_string("emails/exports/analytics.html", {}) 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( instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_" key__startswith="EMAIL_"
).values("key", "value") ).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( connection = get_connection(
host=get_configuration_value( host=EMAIL_HOST,
instance_configuration, "EMAIL_HOST", os.environ.get("EMAIL_HOST") port=int(EMAIL_PORT),
), username=EMAIL_HOST_USER,
port=int( password=EMAIL_HOST_PASSWORD,
get_configuration_value( use_tls=bool(EMAIL_USE_TLS),
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"),
)
),
) )
msg = EmailMultiAlternatives( msg = EmailMultiAlternatives(
subject=subject, subject=subject,
body=text_content, body=text_content,
from_email=get_configuration_value( from_email=EMAIL_FROM,
instance_configuration,
"EMAIL_FROM",
os.environ.get("EMAIL_FROM", "Team Plane <team@mailer.plane.so>"),
),
to=[email], to=[email],
connection=connection, connection=connection,
) )
msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue()) msg.attach(f"{slug}-analytics.csv", csv_buffer.getvalue())
msg.send(fail_silently=False) msg.send(fail_silently=False)
return
def get_assignee_details(slug, filters): def get_assignee_details(slug, filters):
@ -463,8 +475,11 @@ def analytic_export_task(email, data, slug):
) )
csv_buffer = generate_csv_from_rows(rows) 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: except Exception as e:
print(e)
if settings.DEBUG: if settings.DEBUG:
print(e) print(e)
capture_exception(e) capture_exception(e)
return

View File

@ -40,13 +40,10 @@ def forgot_password(first_name, email, uidb64, token, current_site):
) = get_email_configuration(instance_configuration=instance_configuration) ) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured # 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 # Check the instance registration
instance = Instance.objects.first() instance = Instance.objects.first()
# send the emails through control center
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False)
# headers # headers
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -61,7 +58,7 @@ def forgot_password(first_name, email, uidb64, token, current_site):
} }
_ = requests.post( _ = 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, headers=headers,
data=json.dumps(payload), data=json.dumps(payload),
) )

View File

@ -21,7 +21,6 @@ from plane.license.utils.instance_value import get_email_configuration
@shared_task @shared_task
def magic_link(email, key, token, current_site): def magic_link(email, key, token, current_site):
try: try:
instance_configuration = InstanceConfiguration.objects.filter( instance_configuration = InstanceConfiguration.objects.filter(
key__startswith="EMAIL_" key__startswith="EMAIL_"
).values("key", "value") ).values("key", "value")
@ -36,13 +35,10 @@ def magic_link(email, key, token, current_site):
) = get_email_configuration(instance_configuration=instance_configuration) ) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured # 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 # Check the instance registration
instance = Instance.objects.first() 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", "Content-Type": "application/json",
"x-instance-id": instance.instance_id, "x-instance-id": instance.instance_id,
@ -55,7 +51,7 @@ def magic_link(email, key, token, current_site):
} }
_ = requests.post( _ = 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, headers=headers,
data=json.dumps(payload), data=json.dumps(payload),
) )

View File

@ -32,13 +32,9 @@ def update_user_instance_user_count():
"x-api-key": instance.api_key, "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 # Update the license engine
_ = requests.post( _ = requests.post(
f"{license_engine_base_url}/api/instances/", f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers, headers=headers,
data=json.dumps(payload), data=json.dumps(payload),
) )

View File

@ -109,7 +109,7 @@ def webhook_task(self, webhook, slug, event, event_data, action):
if webhook.secret_key: if webhook.secret_key:
hmac_signature = hmac.new( hmac_signature = hmac.new(
webhook.secret_key.encode("utf-8"), webhook.secret_key.encode("utf-8"),
json.dumps(payload, sort_keys=True).encode("utf-8"), json.dumps(payload).encode("utf-8"),
hashlib.sha256, hashlib.sha256,
) )
signature = hmac_signature.hexdigest() signature = hmac_signature.hexdigest()

View File

@ -51,15 +51,10 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
) = get_email_configuration(instance_configuration=instance_configuration) ) = get_email_configuration(instance_configuration=instance_configuration)
# Send the email if the users don't have smtp configured # 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 # Check the instance registration
instance = Instance.objects.first() 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 = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"x-instance-id": instance.instance_id, "x-instance-id": instance.instance_id,
@ -73,7 +68,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
"email": email, "email": email,
} }
_ = requests.post( _ = 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, headers=headers,
data=json.dumps(payload), data=json.dumps(payload),
) )

View File

@ -11,6 +11,7 @@ from django.utils import timezone
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.core.validators import validate_email from django.core.validators import validate_email
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.conf import settings
# Third party imports # Third party imports
from rest_framework import status 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.license.utils.encryption import encrypt_data
from plane.settings.redis import redis_instance from plane.settings.redis import redis_instance
from plane.bgtasks.magic_link_code_task import magic_link from plane.bgtasks.magic_link_code_task import magic_link
from plane.license.utils.instance_value import get_configuration_value
class InstanceEndpoint(BaseAPIView): class InstanceEndpoint(BaseAPIView):
@ -57,25 +57,17 @@ class InstanceEndpoint(BaseAPIView):
# Load JSON content from the file # Load JSON content from the file
data = json.load(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"} headers = {"Content-Type": "application/json"}
payload = { payload = {
"instance_key": os.environ.get("INSTANCE_KEY"), "instance_key":settings.INSTANCE_KEY,
"version": data.get("version", 0.1), "version": data.get("version", 0.1),
"machine_signature": os.environ.get("MACHINE_SIGNATURE"), "machine_signature": os.environ.get("MACHINE_SIGNATURE"),
"user_count": User.objects.filter(is_bot=False).count(), "user_count": User.objects.filter(is_bot=False).count(),
} }
response = requests.post( response = requests.post(
f"{license_engine_base_url}/api/instances/", f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers, headers=headers,
data=json.dumps(payload), data=json.dumps(payload),
) )
@ -130,6 +122,24 @@ class InstanceEndpoint(BaseAPIView):
serializer = InstanceSerializer(instance, data=request.data, partial=True) serializer = InstanceSerializer(instance, data=request.data, partial=True)
if serializer.is_valid(): if serializer.is_valid():
serializer.save() 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.data, status=status.HTTP_200_OK)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
@ -251,7 +261,6 @@ class AdminMagicSignInGenerateEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
if not email: if not email:
return Response( return Response(
{"error": "Please provide a valid email address"}, {"error": "Please provide a valid email address"},
@ -409,13 +418,6 @@ class AdminSetUserPasswordEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, 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 # Save the user in control center
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -423,14 +425,14 @@ class AdminSetUserPasswordEndpoint(BaseAPIView):
"x-api-key": instance.api_key, "x-api-key": instance.api_key,
} }
_ = requests.patch( _ = requests.patch(
f"{license_engine_base_url}/api/instances/", f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers, headers=headers,
data=json.dumps({"is_setup_done": True}), data=json.dumps({"is_setup_done": True}),
) )
# Also register the user as admin # Also register the user as admin
_ = requests.post( _ = requests.post(
f"{license_engine_base_url}/api/instances/users/register/", f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/users/register/",
headers=headers, headers=headers,
data=json.dumps( data=json.dumps(
{ {
@ -472,23 +474,19 @@ class SignUpScreenVisitedEndpoint(BaseAPIView):
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
license_engine_base_url = os.environ.get("LICENSE_ENGINE_BASE_URL", False) if not instance.is_signup_screen_visited:
instance.is_signup_screen_visited = True
if not license_engine_base_url: instance.save()
return Response( # set the headers
{"error": "License engine base url is required"},
status=status.HTTP_400_BAD_REQUEST,
)
headers = { headers = {
"Content-Type": "application/json", "Content-Type": "application/json",
"x-instance-id": instance.instance_id, "x-instance-id": instance.instance_id,
"x-api-key": instance.api_key, "x-api-key": instance.api_key,
} }
# create the payload
payload = {"is_signup_screen_visited": True} payload = {"is_signup_screen_visited": True}
response = requests.patch( _ = requests.patch(
f"{license_engine_base_url}/api/instances/", f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers, headers=headers,
data=json.dumps(payload), data=json.dumps(payload),
) )

View File

@ -6,6 +6,7 @@ import requests
# Django imports # Django imports
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone from django.utils import timezone
from django.conf import settings
# Module imports # Module imports
from plane.license.models import Instance from plane.license.models import Instance
@ -30,31 +31,22 @@ class Command(BaseCommand):
data = json.load(file) data = json.load(file)
machine_signature = options.get("machine_signature", False) 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: if not machine_signature:
raise CommandError("Machine signature is required") 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"} headers = {"Content-Type": "application/json"}
payload = { payload = {
"instance_key": instance_key, "instance_key": settings.INSTANCE_KEY,
"version": data.get("version", 0.1), "version": data.get("version", 0.1),
"machine_signature": machine_signature, "machine_signature": machine_signature,
"user_count": User.objects.filter(is_bot=False).count(), "user_count": User.objects.filter(is_bot=False).count(),
} }
response = requests.post( response = requests.post(
f"{license_engine_base_url}/api/instances/", f"{settings.LICENSE_ENGINE_BASE_URL}/api/instances/",
headers=headers, headers=headers,
data=json.dumps(payload), data=json.dumps(payload),
) )

View File

@ -290,7 +290,7 @@ CELERY_IMPORTS = (
# Sentry Settings # Sentry Settings
# Enable 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( sentry_sdk.init(
dsn=os.environ.get("SENTRY_DSN", ""), dsn=os.environ.get("SENTRY_DSN", ""),
integrations=[ integrations=[
@ -325,3 +325,9 @@ USE_MINIO = int(os.environ.get("USE_MINIO", 0)) == 1
# Posthog settings # Posthog settings
POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False) POSTHOG_API_KEY = os.environ.get("POSTHOG_API_KEY", False)
POSTHOG_HOST = os.environ.get("POSTHOG_HOST", 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")

View File

@ -63,7 +63,7 @@ def date_filter(filter, date_term, queries):
duration=int(digit), duration=int(digit),
subsequent=date_query[1], subsequent=date_query[1],
term=term, term=term,
date_filter="created_at__date", date_filter=date_term,
offset=date_query[2], offset=date_query[2],
) )
else: else:

View File

@ -38,3 +38,4 @@ beautifulsoup4==4.12.2
dj-database-url==2.1.0 dj-database-url==2.1.0
posthog==3.0.2 posthog==3.0.2
cryptography==41.0.5 cryptography==41.0.5
lxml==4.9.3

View File

@ -149,20 +149,20 @@
padding-top: 10px !important; padding-top: 10px !important;
} }
.r13-o { .r13-o {
border-bottom-color: #efefef !important; border-bottom-color: #d9e4ff !important;
border-bottom-width: 1px !important; border-bottom-width: 1px !important;
border-left-color: #efefef !important; border-left-color: #d9e4ff !important;
border-left-width: 1px !important; border-left-width: 1px !important;
border-right-color: #efefef !important; border-right-color: #d9e4ff !important;
border-right-width: 1px !important; border-right-width: 1px !important;
border-style: solid !important; border-style: solid !important;
border-top-color: #efefef !important; border-top-color: #d9e4ff !important;
border-top-width: 1px !important; border-top-width: 1px !important;
margin: 0 auto 0 0 !important; margin: 0 auto 0 0 !important;
width: 100% !important; width: 100% !important;
} }
.r14-i { .r14-i {
background-color: #e3e6f1 !important; background-color: #ecf1ff !important;
padding-bottom: 10px !important; padding-bottom: 10px !important;
padding-left: 10px !important; padding-left: 10px !important;
padding-right: 10px !important; padding-right: 10px !important;
@ -225,16 +225,11 @@
padding-top: 5px !important; padding-top: 5px !important;
} }
.r24-o { .r24-o {
border-style: solid !important;
margin-right: 8px !important;
width: 32px !important;
}
.r25-o {
border-style: solid !important; border-style: solid !important;
margin-right: 0px !important; margin-right: 0px !important;
width: 32px !important; width: 32px !important;
} }
.r26-i { .r25-i {
padding-bottom: 0px !important; padding-bottom: 0px !important;
padding-top: 5px !important; padding-top: 5px !important;
text-align: center !important; text-align: center !important;
@ -664,17 +659,17 @@
width="100%" width="100%"
class="r13-o" class="r13-o"
style=" style="
background-color: #e3e6f1; background-color: #ecf1ff;
border-bottom-color: #efefef; border-bottom-color: #d9e4ff;
border-bottom-width: 1px; border-bottom-width: 1px;
border-collapse: separate; border-collapse: separate;
border-left-color: #efefef; border-left-color: #d9e4ff;
border-left-width: 1px; border-left-width: 1px;
border-radius: 5px; border-radius: 5px;
border-right-color: #efefef; border-right-color: #d9e4ff;
border-right-width: 1px; border-right-width: 1px;
border-style: solid; border-style: solid;
border-top-color: #efefef; border-top-color: #d9e4ff;
border-top-width: 1px; border-top-width: 1px;
table-layout: fixed; table-layout: fixed;
width: 100%; width: 100%;
@ -690,7 +685,7 @@
font-family: georgia, serif; font-family: georgia, serif;
font-size: 16px; font-size: 16px;
word-break: break-word; word-break: break-word;
background-color: #e3e6f1; background-color: #ecf1ff;
border-radius: 4px; border-radius: 4px;
line-height: 3; line-height: 3;
padding-bottom: 10px; padding-bottom: 10px;
@ -714,10 +709,10 @@
<p style="margin: 0"> <p style="margin: 0">
<span <span
style=" style="
color: #716c6c; color: #5f5e5e;
font-family: Arial, font-family: Arial,
helvetica, sans-serif; helvetica, sans-serif;
font-size: 15px; font-size: 14px;
" "
>Please copy and paste this >Please copy and paste this
on the screen where you on the screen where you
@ -1251,13 +1246,14 @@
role="presentation" role="presentation"
width="100%" width="100%"
class="r22-o" class="r22-o"
class="r24-o"
style=" style="
table-layout: fixed;
width: 100%;
"
> >
<tr> <tr>
<td <td
class="r23-i" class="r23-i"
class="r17-i"
style=" style="
font-size: 0px; font-size: 0px;
line-height: 0px; line-height: 0px;
@ -1309,7 +1305,7 @@
border="0" border="0"
role="presentation" role="presentation"
width="100%" width="100%"
class="r25-o" class="r24-o"
style=" style="
table-layout: fixed; table-layout: fixed;
width: 100%; width: 100%;
@ -1441,7 +1437,7 @@
<td <td
align="center" align="center"
valign="top" valign="top"
class="r26-i nl2go-default-textstyle" class="r25-i nl2go-default-textstyle"
style=" style="
color: #3b3f44; color: #3b3f44;
font-family: georgia, serif; font-family: georgia, serif;

View File

@ -1,9 +0,0 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
Dear {{username}},<br/>
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.</br>
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 <a href = "mailto: engineering@plane.com">engineering@plane.so</a>. We're here to help!</br>
Thank you for using Plane. We hope this export will aid you in effectively managing your projects.</br>
Regards,
Team Plane
</html>

View File

@ -71,7 +71,6 @@ services:
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0} - ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
- WEB_URL=$SERVICE_FQDN_PLANE_8082 - WEB_URL=$SERVICE_FQDN_PLANE_8082
- LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-"https://control-center.plane.so"}
depends_on: depends_on:
- plane-db - plane-db
- plane-redis - plane-redis
@ -117,7 +116,6 @@ services:
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
- LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-"https://control-center.plane.so"}
depends_on: depends_on:
- api - api
- plane-db - plane-db
@ -164,7 +162,6 @@ services:
- DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123} - DEFAULT_PASSWORD=${DEFAULT_PASSWORD:-password123}
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
- LICENSE_ENGINE_BASE_URL=${LICENSE_ENGINE_BASE_URL:-"https://control-center.plane.so"}
depends_on: depends_on:
- api - api
- plane-db - plane-db

View File

@ -5,15 +5,16 @@ x-app-env : &app-env
- NGINX_PORT=${NGINX_PORT:-80} - NGINX_PORT=${NGINX_PORT:-80}
- WEB_URL=${WEB_URL:-http://localhost} - WEB_URL=${WEB_URL:-http://localhost}
- DEBUG=${DEBUG:-0} - DEBUG=${DEBUG:-0}
- NEXT_PUBLIC_ENABLE_OAUTH=${NEXT_PUBLIC_ENABLE_OAUTH:-0} - DJANGO_SETTINGS_MODULE=${DJANGO_SETTINGS_MODULE:-plane.settings.production} # deprecated
- NEXT_PUBLIC_DEPLOY_URL=${NEXT_PUBLIC_DEPLOY_URL:-http://localhost/spaces} - 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_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:-""} - GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-""}
- DOCKERIZED=${DOCKERIZED:-1} # deprecated - DOCKERIZED=${DOCKERIZED:-1} # deprecated
- CORS_ALLOWED_ORIGINS=${CORS_ALLOWED_ORIGINS:-""} - 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=${GUNICORN_WORKERS:-2} - GUNICORN_WORKERS=${GUNICORN_WORKERS:-2}
#DB SETTINGS #DB SETTINGS
@ -28,12 +29,12 @@ x-app-env : &app-env
- REDIS_HOST=${REDIS_HOST:-plane-redis} - REDIS_HOST=${REDIS_HOST:-plane-redis}
- REDIS_PORT=${REDIS_PORT:-6379} - REDIS_PORT=${REDIS_PORT:-6379}
- REDIS_URL=${REDIS_URL:-redis://${REDIS_HOST}: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=${EMAIL_HOST:-""}
- EMAIL_HOST_USER=${EMAIL_HOST_USER:-""} - EMAIL_HOST_USER=${EMAIL_HOST_USER:-""}
- EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""} - EMAIL_HOST_PASSWORD=${EMAIL_HOST_PASSWORD:-""}
- EMAIL_PORT=${EMAIL_PORT:-587} - EMAIL_PORT=${EMAIL_PORT:-587}
- EMAIL_FROM=${EMAIL_FROM:-"Team Plane &lt;team@mailer.plane.so&gt;"} - EMAIL_FROM=${EMAIL_FROM:-"Team Plane <team@mailer.plane.so>"}
- EMAIL_USE_TLS=${EMAIL_USE_TLS:-1} - EMAIL_USE_TLS=${EMAIL_USE_TLS:-1}
- EMAIL_USE_SSL=${EMAIL_USE_SSL:-0} - EMAIL_USE_SSL=${EMAIL_USE_SSL:-0}
- DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so} - DEFAULT_EMAIL=${DEFAULT_EMAIL:-captain@plane.so}
@ -42,10 +43,11 @@ x-app-env : &app-env
- OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1} - OPENAI_API_BASE=${OPENAI_API_BASE:-https://api.openai.com/v1}
- OPENAI_API_KEY=${OPENAI_API_KEY:-"sk-"} - OPENAI_API_KEY=${OPENAI_API_KEY:-"sk-"}
- GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"} - GPT_ENGINE=${GPT_ENGINE:-"gpt-3.5-turbo"}
# LOGIN/SIGNUP SETTINGS # LOGIN/SIGNUP SETTINGS - Deprecated can be configured through admin panel
- ENABLE_SIGNUP=${ENABLE_SIGNUP:-1} - ENABLE_SIGNUP=${ENABLE_SIGNUP:-1}
- ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1} - ENABLE_EMAIL_PASSWORD=${ENABLE_EMAIL_PASSWORD:-1}
- ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0} - ENABLE_MAGIC_LINK_LOGIN=${ENABLE_MAGIC_LINK_LOGIN:-0}
# Application secret
- SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5} - SECRET_KEY=${SECRET_KEY:-60gp0byfz2dvffa45cxl20p1scy9xbpf6d8c5y0geejgkyp1b5}
# DATA STORE SETTINGS # DATA STORE SETTINGS
- USE_MINIO=${USE_MINIO:-1} - USE_MINIO=${USE_MINIO:-1}

View File

@ -10,10 +10,12 @@ DEBUG=0
NEXT_PUBLIC_ENABLE_OAUTH=0 NEXT_PUBLIC_ENABLE_OAUTH=0
NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces NEXT_PUBLIC_DEPLOY_URL=http://localhost/spaces
SENTRY_DSN="" SENTRY_DSN=""
SENTRY_ENVIRONMENT="production"
GOOGLE_CLIENT_ID=""
GITHUB_CLIENT_ID=""
GITHUB_CLIENT_SECRET="" GITHUB_CLIENT_SECRET=""
DOCKERIZED=1 # deprecated DOCKERIZED=1 # deprecated
CORS_ALLOWED_ORIGINS="http://localhost" CORS_ALLOWED_ORIGINS="http://localhost"
SENTRY_ENVIRONMENT="production"
#DB SETTINGS #DB SETTINGS
PGHOST=plane-db PGHOST=plane-db
@ -34,7 +36,7 @@ EMAIL_HOST=""
EMAIL_HOST_USER="" EMAIL_HOST_USER=""
EMAIL_HOST_PASSWORD="" EMAIL_HOST_PASSWORD=""
EMAIL_PORT=587 EMAIL_PORT=587
EMAIL_FROM="Team Plane &lt;team@mailer.plane.so&gt;" EMAIL_FROM="Team Plane <team@mailer.plane.so>"
EMAIL_USE_TLS=1 EMAIL_USE_TLS=1
EMAIL_USE_SSL=0 EMAIL_USE_SSL=0
@ -63,9 +65,3 @@ FILE_SIZE_LIMIT=5242880
# Gunicorn Workers # Gunicorn Workers
GUNICORN_WORKERS=2 GUNICORN_WORKERS=2
# Admin Email
ADMIN_EMAIL=""
# License Engine url
LICENSE_ENGINE_BASE_URL=""

View File

@ -84,7 +84,7 @@ export const EmailForm: React.FC<Props> = (props) => {
return ( return (
<> <>
<h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100"> <h1 className="text-center text-2xl sm:text-2.5xl font-medium text-onboarding-text-100">
Get on your flight deck! Get on your flight deck
</h1> </h1>
<p className="text-center text-sm text-onboarding-text-200 mt-3"> <p className="text-center text-sm text-onboarding-text-200 mt-3">
Sign in with the email you used to sign up for Plane Sign in with the email you used to sign up for Plane

View File

@ -1,4 +1,4 @@
import React from "react"; import React, { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Controller, useForm } from "react-hook-form"; import { Controller, useForm } from "react-hook-form";
import { XCircle } from "lucide-react"; import { XCircle } from "lucide-react";
@ -36,6 +36,8 @@ const authService = new AuthService();
export const PasswordForm: React.FC<Props> = (props) => { export const PasswordForm: React.FC<Props> = (props) => {
const { email, updateEmail, handleStepChange, handleSignInRedirection } = props; const { email, updateEmail, handleStepChange, handleSignInRedirection } = props;
// states
const [isSendingResetPasswordLink, setIsSendingResetPasswordLink] = useState(false);
// toast alert // toast alert
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// form info // form info
@ -113,6 +115,8 @@ export const PasswordForm: React.FC<Props> = (props) => {
return; return;
} }
setIsSendingResetPasswordLink(true);
authService authService
.sendResetPasswordLink({ email: emailFormValue }) .sendResetPasswordLink({ email: emailFormValue })
.then(() => handleStepChange(ESignInSteps.SET_PASSWORD_LINK)) .then(() => handleStepChange(ESignInSteps.SET_PASSWORD_LINK))
@ -122,7 +126,8 @@ export const PasswordForm: React.FC<Props> = (props) => {
title: "Error!", title: "Error!",
message: err?.error ?? "Something went wrong. Please try again.", message: err?.error ?? "Something went wrong. Please try again.",
}) })
); )
.finally(() => setIsSendingResetPasswordLink(false));
}; };
return ( return (
@ -189,9 +194,12 @@ export const PasswordForm: React.FC<Props> = (props) => {
<button <button
type="button" type="button"
onClick={handleForgotPassword} onClick={handleForgotPassword}
className="text-xs font-medium text-custom-primary-100" className={`text-xs font-medium ${
isSendingResetPasswordLink ? "text-onboarding-text-300" : "text-custom-primary-100"
}`}
disabled={isSendingResetPasswordLink}
> >
Forgot your password? {isSendingResetPasswordLink ? "Sending link..." : "Forgot your password?"}
</button> </button>
</div> </div>
</div> </div>

View File

@ -1,4 +1,6 @@
import React, { useState } from "react"; import React, { useState } from "react";
// hooks
import useSignInRedirection from "hooks/use-sign-in-redirection";
// components // components
import { import {
EmailForm, EmailForm,
@ -19,33 +21,27 @@ export enum ESignInSteps {
CREATE_PASSWORD = "CREATE_PASSWORD", CREATE_PASSWORD = "CREATE_PASSWORD",
} }
type Props = {
handleSignInRedirection: () => Promise<void>;
};
const OAUTH_HIDDEN_STEPS = [ESignInSteps.OPTIONAL_SET_PASSWORD, ESignInSteps.CREATE_PASSWORD]; const OAUTH_HIDDEN_STEPS = [ESignInSteps.OPTIONAL_SET_PASSWORD, ESignInSteps.CREATE_PASSWORD];
export const SignInRoot: React.FC<Props> = (props) => { export const SignInRoot = () => {
const { handleSignInRedirection } = props;
// states // states
const [signInStep, setSignInStep] = useState<ESignInSteps>(ESignInSteps.EMAIL); const [signInStep, setSignInStep] = useState<ESignInSteps>(ESignInSteps.EMAIL);
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
// sign in redirection hook
const { handleRedirection } = useSignInRedirection();
return ( return (
<> <>
<div className="mx-auto flex flex-col"> <div className="mx-auto flex flex-col">
{signInStep === ESignInSteps.EMAIL && ( {signInStep === ESignInSteps.EMAIL && (
<EmailForm <EmailForm handleStepChange={(step) => setSignInStep(step)} updateEmail={(newEmail) => setEmail(newEmail)} />
handleStepChange={(step: ESignInSteps) => setSignInStep(step)}
updateEmail={(newEmail) => setEmail(newEmail)}
/>
)} )}
{signInStep === ESignInSteps.PASSWORD && ( {signInStep === ESignInSteps.PASSWORD && (
<PasswordForm <PasswordForm
email={email} email={email}
updateEmail={(newEmail) => setEmail(newEmail)} updateEmail={(newEmail) => setEmail(newEmail)}
handleStepChange={(step: ESignInSteps) => setSignInStep(step)} handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleSignInRedirection} handleSignInRedirection={handleRedirection}
/> />
)} )}
{signInStep === ESignInSteps.SET_PASSWORD_LINK && ( {signInStep === ESignInSteps.SET_PASSWORD_LINK && (
@ -55,30 +51,30 @@ export const SignInRoot: React.FC<Props> = (props) => {
<UniqueCodeForm <UniqueCodeForm
email={email} email={email}
updateEmail={(newEmail) => setEmail(newEmail)} updateEmail={(newEmail) => setEmail(newEmail)}
handleStepChange={(step: ESignInSteps) => setSignInStep(step)} handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleSignInRedirection} handleSignInRedirection={handleRedirection}
/> />
)} )}
{signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && ( {signInStep === ESignInSteps.OPTIONAL_SET_PASSWORD && (
<OptionalSetPasswordForm <OptionalSetPasswordForm
email={email} email={email}
handleStepChange={(step: ESignInSteps) => setSignInStep(step)} handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleSignInRedirection} handleSignInRedirection={handleRedirection}
/> />
)} )}
{signInStep === ESignInSteps.CREATE_PASSWORD && ( {signInStep === ESignInSteps.CREATE_PASSWORD && (
<CreatePasswordForm <CreatePasswordForm
email={email} email={email}
handleStepChange={(step: ESignInSteps) => setSignInStep(step)} handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleSignInRedirection} handleSignInRedirection={handleRedirection}
/> />
)} )}
</div> </div>
{!OAUTH_HIDDEN_STEPS.includes(signInStep) && ( {!OAUTH_HIDDEN_STEPS.includes(signInStep) && (
<OAuthOptions <OAuthOptions
updateEmail={(newEmail) => setEmail(newEmail)} updateEmail={(newEmail) => setEmail(newEmail)}
handleStepChange={(step: ESignInSteps) => setSignInStep(step)} handleStepChange={(step) => setSignInStep(step)}
handleSignInRedirection={handleSignInRedirection} handleSignInRedirection={handleRedirection}
/> />
)} )}
</> </>

View File

@ -30,7 +30,7 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
const { const {
control, control,
formState: { errors, isValid }, formState: { errors, isValid },
watch, handleSubmit,
} = useForm({ } = useForm({
defaultValues: { defaultValues: {
email, email,
@ -39,11 +39,13 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
reValidateMode: "onChange", reValidateMode: "onChange",
}); });
const handleSendNewLink = async () => { const handleSendNewLink = async (formData: { email: string }) => {
setIsSendingNewLink(true); setIsSendingNewLink(true);
updateEmail(formData.email);
const payload: IEmailCheckData = { const payload: IEmailCheckData = {
email: watch("email"), email: formData.email,
type: "password", type: "password",
}; };
@ -76,7 +78,7 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
password password
</p> </p>
<form className="mt-5 sm:w-96 mx-auto space-y-4"> <form onSubmit={handleSubmit(handleSendNewLink)} className="mt-5 sm:w-96 mx-auto space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<Controller <Controller
control={control} control={control}
@ -92,10 +94,7 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
name="email" name="email"
type="email" type="email"
value={value} value={value}
onChange={(e) => { onChange={onChange}
updateEmail(e.target.value);
onChange(e.target.value);
}}
ref={ref} ref={ref}
hasError={Boolean(errors.email)} hasError={Boolean(errors.email)}
placeholder="orville.wright@firstflight.com" placeholder="orville.wright@firstflight.com"
@ -112,11 +111,10 @@ export const SetPasswordLink: React.FC<Props> = (props) => {
/> />
</div> </div>
<Button <Button
type="button" type="submit"
variant="primary" variant="primary"
className="w-full" className="w-full"
size="xl" size="xl"
onClick={handleSendNewLink}
disabled={!isValid} disabled={!isValid}
loading={isSendingNewLink} loading={isSendingNewLink}
> >

View File

@ -11,6 +11,7 @@ import { ArchiveRestore } from "lucide-react";
import { PROJECT_AUTOMATION_MONTHS } from "constants/project"; import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
// types // types
import { IProject } from "types"; import { IProject } from "types";
import { EUserWorkspaceRoles } from "constants/workspace";
type Props = { type Props = {
handleChange: (formData: Partial<IProject>) => Promise<void>; handleChange: (formData: Partial<IProject>) => Promise<void>;
@ -28,6 +29,8 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
const projectDetails = projectStore.currentProjectDetails; const projectDetails = projectStore.currentProjectDetails;
const userRole = userStore.currentProjectRole; const userRole = userStore.currentProjectRole;
const isAdmin = userRole === EUserWorkspaceRoles.ADMIN;
return ( return (
<> <>
<SelectMonthModal <SelectMonthModal
@ -56,7 +59,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
projectDetails?.archive_in === 0 ? handleChange({ archive_in: 1 }) : handleChange({ archive_in: 0 }) projectDetails?.archive_in === 0 ? handleChange({ archive_in: 1 }) : handleChange({ archive_in: 0 })
} }
size="sm" size="sm"
disabled={userRole !== 20} disabled={!isAdmin}
/> />
</div> </div>
@ -74,7 +77,7 @@ export const AutoArchiveAutomation: React.FC<Props> = observer((props) => {
}} }}
input input
width="w-full" width="w-full"
disabled={userRole !== 20} disabled={!isAdmin}
> >
<> <>
{PROJECT_AUTOMATION_MONTHS.map((month) => ( {PROJECT_AUTOMATION_MONTHS.map((month) => (

View File

@ -11,6 +11,7 @@ import { ArchiveX } from "lucide-react";
import { IProject } from "types"; import { IProject } from "types";
// fetch keys // fetch keys
import { PROJECT_AUTOMATION_MONTHS } from "constants/project"; import { PROJECT_AUTOMATION_MONTHS } from "constants/project";
import { EUserWorkspaceRoles } from "constants/workspace";
type Props = { type Props = {
handleChange: (formData: Partial<IProject>) => Promise<void>; handleChange: (formData: Partial<IProject>) => Promise<void>;
@ -53,6 +54,8 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
default_state: defaultState, default_state: defaultState,
}; };
const isAdmin = userRole === EUserWorkspaceRoles.ADMIN;
return ( return (
<> <>
<SelectMonthModal <SelectMonthModal
@ -83,7 +86,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
: handleChange({ close_in: 0, default_state: null }) : handleChange({ close_in: 0, default_state: null })
} }
size="sm" size="sm"
disabled={userRole !== 20} disabled={!isAdmin}
/> />
</div> </div>
@ -102,7 +105,7 @@ export const AutoCloseAutomation: React.FC<Props> = observer((props) => {
}} }}
input input
width="w-full" width="w-full"
disabled={userRole !== 20} disabled={!isAdmin}
> >
<> <>
{PROJECT_AUTOMATION_MONTHS.map((month) => ( {PROJECT_AUTOMATION_MONTHS.map((month) => (

View File

@ -52,7 +52,10 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, peekCycle } = router.query; const { workspaceSlug, projectId, peekCycle } = router.query;
const { cycle: cycleDetailsStore, trackEvent: { setTrackElement, postHogEventTracker } } = useMobxStore(); const {
cycle: cycleDetailsStore,
trackEvent: { setTrackElement, postHogEventTracker },
} = useMobxStore();
const cycleDetails = cycleDetailsStore.cycle_details[cycleId] ?? undefined; const cycleDetails = cycleDetailsStore.cycle_details[cycleId] ?? undefined;
@ -70,31 +73,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
const submitChanges = (data: Partial<ICycle>) => { const submitChanges = (data: Partial<ICycle>) => {
if (!workspaceSlug || !projectId || !cycleId) return; if (!workspaceSlug || !projectId || !cycleId) return;
mutate<ICycle>(CYCLE_DETAILS(cycleId as string), (prevData) => ({ ...(prevData as ICycle), ...data }), false); cycleDetailsStore.patchCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), data);
cycleService
.patchCycle(workspaceSlug as string, projectId as string, cycleId as string, data)
.then((res) => {
mutate(CYCLE_DETAILS(cycleId as string));
postHogEventTracker(
"CYCLE_UPDATE",
{
...res,
state: "SUCCESS"
}
);
}
)
.catch((e) => {
console.log(e);
postHogEventTracker(
"CYCLE_UPDATE",
{
state: "FAILED"
}
);
}
);
}; };
const handleCopyText = () => { const handleCopyText = () => {
@ -337,11 +316,12 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
</button> </button>
{!isCompleted && ( {!isCompleted && (
<CustomMenu width="lg" placement="bottom-end" ellipsis> <CustomMenu width="lg" placement="bottom-end" ellipsis>
<CustomMenu.MenuItem onClick={() => { <CustomMenu.MenuItem
onClick={() => {
setTrackElement("CYCLE_PAGE_SIDEBAR"); setTrackElement("CYCLE_PAGE_SIDEBAR");
setCycleDeleteModal(true) setCycleDeleteModal(true);
} }}
}> >
<span className="flex items-center justify-start gap-2"> <span className="flex items-center justify-start gap-2">
<Trash2 className="h-3 w-3" /> <Trash2 className="h-3 w-3" />
<span>Delete cycle</span> <span>Delete cycle</span>

View File

@ -2,10 +2,12 @@ import React, { useState } from "react";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import useSWR from "swr"; import useSWR from "swr";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { observer } from "mobx-react-lite";
// services // services
import { CycleService } from "services/cycle.service"; import { CycleService } from "services/cycle.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import { useMobxStore } from "lib/mobx/store-provider";
//icons //icons
import { ContrastIcon, TransferIcon } from "@plane/ui"; import { ContrastIcon, TransferIcon } from "@plane/ui";
import { AlertCircle, Search, X } from "lucide-react"; import { AlertCircle, Search, X } from "lucide-react";
@ -23,17 +25,19 @@ type Props = {
const cycleService = new CycleService(); const cycleService = new CycleService();
export const TransferIssuesModal: React.FC<Props> = ({ isOpen, handleClose }) => { export const TransferIssuesModal: React.FC<Props> = observer(({ isOpen, handleClose }) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const { cycleIssues: cycleIssueStore } = useMobxStore();
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, cycleId } = router.query; const { workspaceSlug, projectId, cycleId } = router.query;
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const transferIssue = async (payload: any) => { const transferIssue = async (payload: any) => {
await cycleService await cycleIssueStore
.transferIssues(workspaceSlug as string, projectId as string, cycleId as string, payload) .transferIssuesFromCycle(workspaceSlug as string, projectId as string, cycleId as string, payload)
.then(() => { .then(() => {
setToastAlert({ setToastAlert({
type: "success", type: "success",
@ -159,4 +163,4 @@ export const TransferIssuesModal: React.FC<Props> = ({ isOpen, handleClose }) =>
</Dialog> </Dialog>
</Transition.Root> </Transition.Root>
); );
}; });

View File

@ -22,6 +22,7 @@ import { Button } from "@plane/ui";
import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Inbox, Trash2, XCircle } from "lucide-react"; import { CheckCircle2, ChevronDown, ChevronUp, Clock, FileStack, Inbox, Trash2, XCircle } from "lucide-react";
// types // types
import type { TInboxStatus } from "types"; import type { TInboxStatus } from "types";
import { EUserWorkspaceRoles } from "constants/workspace";
export const InboxActionsHeader = observer(() => { export const InboxActionsHeader = observer(() => {
const [date, setDate] = useState(new Date()); const [date, setDate] = useState(new Date());
@ -71,7 +72,7 @@ export const InboxActionsHeader = observer(() => {
}, [issue]); }, [issue]);
const issueStatus = issue?.issue_inbox[0].status; const issueStatus = issue?.issue_inbox[0].status;
const isAllowed = userRole === 15 || userRole === 20; const isAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER;
const today = new Date(); const today = new Date();
const tomorrow = new Date(today); const tomorrow = new Date(today);

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect } from "react"; import { useCallback, useEffect, useState } from "react";
import Router, { useRouter } from "next/router"; import Router, { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import useSWR from "swr"; import useSWR from "swr";
@ -8,14 +8,15 @@ import { AlertTriangle, CheckCircle2, Clock, Copy, ExternalLink, Inbox, XCircle
// mobx store // mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// components // components
import { IssueDescriptionForm, IssueDetailsSidebar, IssueReaction } from "components/issues"; import { IssueDescriptionForm, IssueDetailsSidebar, IssueReaction, IssueUpdateStatus } from "components/issues";
import { InboxIssueActivity } from "components/inbox"; import { InboxIssueActivity } from "components/inbox";
// ui // ui
import { Loader } from "@plane/ui"; import { Loader, StateGroupIcon } from "@plane/ui";
// helpers // helpers
import { renderShortDateWithYearFormat } from "helpers/date-time.helper"; import { renderShortDateWithYearFormat } from "helpers/date-time.helper";
// types // types
import { IInboxIssue, IIssue } from "types"; import { IInboxIssue, IIssue } from "types";
import { EUserWorkspaceRoles } from "constants/workspace";
const defaultValues: Partial<IInboxIssue> = { const defaultValues: Partial<IInboxIssue> = {
name: "", name: "",
@ -30,7 +31,15 @@ export const InboxMainContent: React.FC = observer(() => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, inboxId, inboxIssueId } = router.query; 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 user = userStore.currentUser;
const userRole = userStore.currentProjectRole; const userRole = userStore.currentProjectRole;
@ -54,6 +63,9 @@ export const InboxMainContent: React.FC = observer(() => {
const issuesList = inboxId ? inboxIssuesStore.inboxIssues[inboxId.toString()] : undefined; const issuesList = inboxId ? inboxIssuesStore.inboxIssues[inboxId.toString()] : undefined;
const issueDetails = inboxIssueId ? inboxIssueDetailsStore.issueDetails[inboxIssueId.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( const submitChanges = useCallback(
async (formData: Partial<IInboxIssue>) => { async (formData: Partial<IInboxIssue>) => {
@ -144,6 +156,8 @@ export const InboxMainContent: React.FC = observer(() => {
</div> </div>
); );
const isAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER;
return ( return (
<> <>
{issueDetails ? ( {issueDetails ? (
@ -214,15 +228,27 @@ export const InboxMainContent: React.FC = observer(() => {
</> </>
) : null} ) : null}
</div> </div>
<div className="flex items-center mb-5">
{currentIssueState && (
<StateGroupIcon
className="h-4 w-4 mr-3"
stateGroup={currentIssueState.group}
color={currentIssueState.color}
/>
)}
<IssueUpdateStatus isSubmitting={isSubmitting} issueDetail={issueDetails} />
</div>
<div> <div>
<IssueDescriptionForm <IssueDescriptionForm
setIsSubmitting={(value) => setIsSubmitting(value)}
isSubmitting={isSubmitting}
workspaceSlug={workspaceSlug as string} workspaceSlug={workspaceSlug as string}
issue={{ issue={{
name: issueDetails.name, name: issueDetails.name,
description_html: issueDetails.description_html, description_html: issueDetails.description_html,
}} }}
handleFormSubmit={submitChanges} handleFormSubmit={submitChanges}
isAllowed={userRole === 15 || userRole === 20 || user?.id === issueDetails.created_by} isAllowed={isAllowed || user?.id === issueDetails.created_by}
/> />
</div> </div>

View File

@ -26,14 +26,15 @@ export interface IssueDetailsProps {
workspaceSlug: string; workspaceSlug: string;
handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>; handleFormSubmit: (value: IssueDescriptionFormValues) => Promise<void>;
isAllowed: boolean; isAllowed: boolean;
isSubmitting: "submitting" | "submitted" | "saved";
setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void;
} }
const fileService = new FileService(); const fileService = new FileService();
export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => { export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
const { issue, handleFormSubmit, workspaceSlug, isAllowed } = props; const { issue, handleFormSubmit, workspaceSlug, isAllowed, isSubmitting, setIsSubmitting } = props;
// states // states
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const [characterLimit, setCharacterLimit] = useState(false); const [characterLimit, setCharacterLimit] = useState(false);
const { setShowAlert } = useReloadConfirmations(); const { setShowAlert } = useReloadConfirmations();
@ -166,13 +167,6 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
/> />
)} )}
/> />
<div
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
}`}
>
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
</div>
</div> </div>
</div> </div>
); );

View File

@ -15,6 +15,7 @@ export * from "./sidebar";
export * from "./label"; export * from "./label";
export * from "./issue-reaction"; export * from "./issue-reaction";
export * from "./confirm-issue-discard"; export * from "./confirm-issue-discard";
export * from "./issue-update-status";
// draft issue // draft issue
export * from "./draft-issue-form"; export * from "./draft-issue-form";

View File

@ -25,6 +25,7 @@ import {
IViewIssuesStore, IViewIssuesStore,
} from "store/issues"; } from "store/issues";
import { TUnGroupedIssues } from "store/issues/types"; import { TUnGroupedIssues } from "store/issues/types";
import { EUserWorkspaceRoles } from "constants/workspace";
interface IBaseGanttRoot { interface IBaseGanttRoot {
issueFiltersStore: issueFiltersStore:
@ -69,7 +70,7 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
); );
}; };
const isAllowed = currentProjectRole && currentProjectRole >= 15; const isAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
return ( return (
<> <>

View File

@ -31,6 +31,7 @@ import { KanBan } from "./default";
import { KanBanSwimLanes } from "./swimlanes"; import { KanBanSwimLanes } from "./swimlanes";
import { EProjectStore } from "store/command-palette.store"; import { EProjectStore } from "store/command-palette.store";
import { IssuePeekOverview } from "components/issues"; import { IssuePeekOverview } from "components/issues";
import { EUserWorkspaceRoles } from "constants/workspace";
export interface IBaseKanBanLayout { export interface IBaseKanBanLayout {
issueStore: issueStore:
@ -93,7 +94,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
} = useMobxStore(); } = useMobxStore();
const { currentProjectRole } = userStore; const { currentProjectRole } = userStore;
const isEditingAllowed = [15, 20].includes(currentProjectRole || 0); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
const issues = issueStore?.getIssues || {}; const issues = issueStore?.getIssues || {};
const issueIds = issueStore?.getIssuesIds || []; const issueIds = issueStore?.getIssuesIds || [];
@ -223,7 +224,7 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
isDragStarted={isDragStarted} isDragStarted={isDragStarted}
quickAddCallback={issueStore?.quickAddIssue} quickAddCallback={issueStore?.quickAddIssue}
viewId={viewId} viewId={viewId}
disableIssueCreation={!enableIssueCreation} disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
isReadOnly={!enableInlineEditing || !isEditingAllowed} isReadOnly={!enableInlineEditing || !isEditingAllowed}
currentStore={currentStore} currentStore={currentStore}
addIssuesToView={addIssuesToView} addIssuesToView={addIssuesToView}

View File

@ -3,6 +3,7 @@ import { useRouter } from "next/router";
// components // components
import { CustomMenu } from "@plane/ui"; import { CustomMenu } from "@plane/ui";
import { CreateUpdateIssueModal } from "components/issues/modal"; import { CreateUpdateIssueModal } from "components/issues/modal";
import { CreateUpdateDraftIssueModal } from "components/issues/draft-issue-modal";
import { ExistingIssuesListModal } from "components/core"; import { ExistingIssuesListModal } from "components/core";
// lucide icons // lucide icons
import { Minimize2, Maximize2, Circle, Plus } from "lucide-react"; import { Minimize2, Maximize2, Circle, Plus } from "lucide-react";
@ -51,6 +52,8 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, moduleId, cycleId } = router.query; const { workspaceSlug, projectId, moduleId, cycleId } = router.query;
const isDraftIssue = router.pathname.includes("draft-issue");
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const renderExistingIssueModal = moduleId || cycleId; const renderExistingIssueModal = moduleId || cycleId;
@ -73,12 +76,21 @@ export const HeaderGroupByCard: FC<IHeaderGroupByCard> = observer((props) => {
return ( return (
<> <>
{isDraftIssue ? (
<CreateUpdateDraftIssueModal
isOpen={isOpen}
handleClose={() => setIsOpen(false)}
prePopulateData={issuePayload}
fieldsToShow={["all"]}
/>
) : (
<CreateUpdateIssueModal <CreateUpdateIssueModal
isOpen={isOpen} isOpen={isOpen}
handleClose={() => setIsOpen(false)} handleClose={() => setIsOpen(false)}
prePopulateData={issuePayload} prePopulateData={issuePayload}
currentStore={currentStore} currentStore={currentStore}
/> />
)}
{renderExistingIssueModal && ( {renderExistingIssueModal && (
<ExistingIssuesListModal <ExistingIssuesListModal
isOpen={openExistingIssueListModal} isOpen={openExistingIssueListModal}

View File

@ -25,6 +25,7 @@ import { IIssueResponse } from "store/issues/types";
import { EProjectStore } from "store/command-palette.store"; import { EProjectStore } from "store/command-palette.store";
import { IssuePeekOverview } from "components/issues"; import { IssuePeekOverview } from "components/issues";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { EUserWorkspaceRoles } from "constants/workspace";
enum EIssueActions { enum EIssueActions {
UPDATE = "update", UPDATE = "update",
@ -83,7 +84,7 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
} = useMobxStore(); } = useMobxStore();
const { currentProjectRole } = userStore; const { currentProjectRole } = userStore;
const isEditingAllowed = [15, 20].includes(currentProjectRole || 0); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
const issueIds = issueStore?.getIssuesIds || []; const issueIds = issueStore?.getIssuesIds || [];
const issues = issueStore?.getIssues; const issues = issueStore?.getIssues;
@ -147,7 +148,7 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
quickAddCallback={issueStore?.quickAddIssue} quickAddCallback={issueStore?.quickAddIssue}
enableIssueQuickAdd={!!enableQuickAdd} enableIssueQuickAdd={!!enableQuickAdd}
isReadonly={!enableInlineEditing || !isEditingAllowed} isReadonly={!enableInlineEditing || !isEditingAllowed}
disableIssueCreation={!enableIssueCreation} disableIssueCreation={!enableIssueCreation || !isEditingAllowed}
currentStore={currentStore} currentStore={currentStore}
addIssuesToView={addIssuesToView} addIssuesToView={addIssuesToView}
/> />

View File

@ -3,6 +3,7 @@ import { useRouter } from "next/router";
// lucide icons // lucide icons
import { CircleDashed, Plus } from "lucide-react"; import { CircleDashed, Plus } from "lucide-react";
// components // components
import { CreateUpdateDraftIssueModal } from "components/issues/draft-issue-modal";
import { CreateUpdateIssueModal } from "components/issues/modal"; import { CreateUpdateIssueModal } from "components/issues/modal";
import { ExistingIssuesListModal } from "components/core"; import { ExistingIssuesListModal } from "components/core";
import { CustomMenu } from "@plane/ui"; import { CustomMenu } from "@plane/ui";
@ -32,6 +33,8 @@ export const HeaderGroupByCard = observer(
const [openExistingIssueListModal, setOpenExistingIssueListModal] = React.useState(false); const [openExistingIssueListModal, setOpenExistingIssueListModal] = React.useState(false);
const isDraftIssue = router.pathname.includes("draft-issue");
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const renderExistingIssueModal = moduleId || cycleId; const renderExistingIssueModal = moduleId || cycleId;
@ -90,12 +93,21 @@ export const HeaderGroupByCard = observer(
</div> </div>
))} ))}
{isDraftIssue ? (
<CreateUpdateDraftIssueModal
isOpen={isOpen}
handleClose={() => setIsOpen(false)}
prePopulateData={issuePayload}
fieldsToShow={["all"]}
/>
) : (
<CreateUpdateIssueModal <CreateUpdateIssueModal
isOpen={isOpen} isOpen={isOpen}
handleClose={() => setIsOpen(false)} handleClose={() => setIsOpen(false)}
currentStore={currentStore} currentStore={currentStore}
prePopulateData={issuePayload} prePopulateData={issuePayload}
/> />
)}
{renderExistingIssueModal && ( {renderExistingIssueModal && (
<ExistingIssuesListModal <ExistingIssuesListModal

View File

@ -18,6 +18,7 @@ import { observer } from "mobx-react-lite";
import { EFilterType, TUnGroupedIssues } from "store/issues/types"; import { EFilterType, TUnGroupedIssues } from "store/issues/types";
import { EIssueActions } from "../types"; import { EIssueActions } from "../types";
import { IQuickActionProps } from "../list/list-view-types"; import { IQuickActionProps } from "../list/list-view-types";
import { EUserWorkspaceRoles } from "constants/workspace";
interface IBaseSpreadsheetRoot { interface IBaseSpreadsheetRoot {
issueFiltersStore: issueFiltersStore:
@ -49,7 +50,7 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => {
} = useMobxStore(); } = useMobxStore();
const { currentProjectRole } = userStore; const { currentProjectRole } = userStore;
const isEditingAllowed = [15, 20].includes(currentProjectRole || 0); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
const issuesResponse = issueStore.getIssues; const issuesResponse = issueStore.getIssues;
const issueIds = (issueStore.getIssuesIds ?? []) as TUnGroupedIssues; const issueIds = (issueStore.getIssuesIds ?? []) as TUnGroupedIssues;

View File

@ -14,6 +14,7 @@ import { IIssue } from "types";
// services // services
import { FileService } from "services/file.service"; import { FileService } from "services/file.service";
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
import { EUserWorkspaceRoles } from "constants/workspace";
const fileService = new FileService(); const fileService = new FileService();
@ -25,16 +26,27 @@ interface IPeekOverviewIssueDetails {
issueUpdate: (issue: Partial<IIssue>) => void; issueUpdate: (issue: Partial<IIssue>) => void;
issueReactionCreate: (reaction: string) => void; issueReactionCreate: (reaction: string) => void;
issueReactionRemove: (reaction: string) => void; issueReactionRemove: (reaction: string) => void;
isSubmitting: "submitting" | "submitted" | "saved";
setIsSubmitting: (value: "submitting" | "submitted" | "saved") => void;
} }
export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) => { export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) => {
const { workspaceSlug, issue, issueReactions, user, issueUpdate, issueReactionCreate, issueReactionRemove } = props; const {
workspaceSlug,
issue,
issueReactions,
user,
issueUpdate,
issueReactionCreate,
issueReactionRemove,
isSubmitting,
setIsSubmitting,
} = props;
// store // store
const { user: userStore } = useMobxStore(); const { user: userStore } = useMobxStore();
const { currentProjectRole } = userStore; const { currentProjectRole } = userStore;
const isAllowed = [15, 20].includes(currentProjectRole || 0); const isAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
// states // states
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const [characterLimit, setCharacterLimit] = useState(false); const [characterLimit, setCharacterLimit] = useState(false);
// hooks // hooks
const { setShowAlert } = useReloadConfirmations(); const { setShowAlert } = useReloadConfirmations();
@ -171,13 +183,6 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
/> />
)} )}
/> />
<div
className={`absolute right-5 bottom-5 text-xs text-custom-text-200 border border-custom-border-400 rounded-xl w-[6.5rem] py-1 z-10 flex items-center justify-center ${
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
}`}
>
{isSubmitting === "submitting" ? "Saving..." : "Saved"}
</div>
</div> </div>
<IssueReaction <IssueReaction
issueReactions={issueReactions} issueReactions={issueReactions}

View File

@ -12,6 +12,7 @@ import { IssueView } from "./view";
import { copyUrlToClipboard } from "helpers/string.helper"; import { copyUrlToClipboard } from "helpers/string.helper";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
import { EUserWorkspaceRoles } from "constants/workspace";
interface IIssuePeekOverview { interface IIssuePeekOverview {
workspaceSlug: string; workspaceSlug: string;
@ -118,7 +119,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
} }
}; };
const userRole = userStore.currentProjectRole ?? 5; const userRole = userStore.currentProjectRole ?? EUserWorkspaceRoles.GUEST;
return ( return (
<Fragment> <Fragment>

View File

@ -8,8 +8,7 @@ import { PeekOverviewIssueDetails } from "./issue-detail";
import { PeekOverviewProperties } from "./properties"; import { PeekOverviewProperties } from "./properties";
import { IssueComment } from "./activity"; import { IssueComment } from "./activity";
import { Button, CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon, Spinner } from "@plane/ui"; import { Button, CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon, Spinner } from "@plane/ui";
import { DeleteIssueModal } from "../delete-issue-modal"; import { DeleteIssueModal, DeleteArchivedIssueModal, IssueUpdateStatus } from "components/issues/";
import { DeleteArchivedIssueModal } from "../delete-archived-issue-modal";
// types // types
import { IIssue } from "types"; import { IIssue } from "types";
// hooks // hooks
@ -93,6 +92,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek"); const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek");
const [deleteIssueModal, setDeleteIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false);
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const updateRoutePeekId = () => { const updateRoutePeekId = () => {
if (issueId != peekIssueId) { if (issueId != peekIssueId) {
@ -216,7 +216,8 @@ export const IssueView: FC<IIssueView> = observer((props) => {
</div> </div>
)} )}
</div> </div>
<div className="flex items-center gap-x-4">
<IssueUpdateStatus isSubmitting={isSubmitting} />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{issue?.created_by !== user?.id && {issue?.created_by !== user?.id &&
!issue?.assignees.includes(user?.id ?? "") && !issue?.assignees.includes(user?.id ?? "") &&
@ -236,15 +237,16 @@ export const IssueView: FC<IIssueView> = observer((props) => {
</Button> </Button>
)} )}
<button onClick={handleCopyText}> <button onClick={handleCopyText}>
<Link2 className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200 -rotate-45" /> <Link2 className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200 -rotate-45" />
</button> </button>
{!disableUserActions && ( {!disableUserActions && (
<button onClick={() => setDeleteIssueModal(true)}> <button onClick={() => setDeleteIssueModal(true)}>
<Trash2 className="h-4 w-4 text-custom-text-400 hover:text-custom-text-200" /> <Trash2 className="h-4 w-4 text-custom-text-300 hover:text-custom-text-200" />
</button> </button>
)} )}
</div> </div>
</div> </div>
</div>
{/* content */} {/* content */}
<div className="relative w-full h-full overflow-hidden overflow-y-auto"> <div className="relative w-full h-full overflow-hidden overflow-y-auto">
@ -261,6 +263,8 @@ export const IssueView: FC<IIssueView> = observer((props) => {
<div className="absolute top-0 left-0 h-full min-h-full w-full z-[9] flex items-center justify-center bg-custom-background-100 opacity-60" /> <div className="absolute top-0 left-0 h-full min-h-full w-full z-[9] flex items-center justify-center bg-custom-background-100 opacity-60" />
)} )}
<PeekOverviewIssueDetails <PeekOverviewIssueDetails
setIsSubmitting={(value) => setIsSubmitting(value)}
isSubmitting={isSubmitting}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
issue={issue} issue={issue}
issueUpdate={issueUpdate} issueUpdate={issueUpdate}
@ -295,6 +299,8 @@ export const IssueView: FC<IIssueView> = observer((props) => {
<div className="relative w-full h-full space-y-6 p-4 py-5 overflow-auto"> <div className="relative w-full h-full space-y-6 p-4 py-5 overflow-auto">
<div className={isArchived ? "pointer-events-none" : ""}> <div className={isArchived ? "pointer-events-none" : ""}>
<PeekOverviewIssueDetails <PeekOverviewIssueDetails
setIsSubmitting={(value) => setIsSubmitting(value)}
isSubmitting={isSubmitting}
workspaceSlug={workspaceSlug} workspaceSlug={workspaceSlug}
issue={issue} issue={issue}
issueReactions={issueReactions} issueReactions={issueReactions}

View File

@ -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> = (props) => {
const { isSubmitting, issueDetail } = props;
return (
<>
{issueDetail && (
<h4 className="text-lg text-custom-text-300 font-medium mr-4">
{issueDetail.project_detail?.identifier}-{issueDetail.sequence_id}
</h4>
)}
<div
className={`flex transition-all duration-300 items-center gap-x-2 ${
isSubmitting === "saved" ? "fadeOut" : "fadeIn"
}`}
>
{isSubmitting !== "submitted" && isSubmitting !== "saved" && (
<RefreshCw className="h-4 w-4 stroke-custom-text-300" />
)}
<span className="text-sm text-custom-text-300">{isSubmitting === "submitting" ? "Saving..." : "Saved"}</span>
</div>
</>
);
};

View File

@ -2,6 +2,7 @@ import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import useSWR, { mutate } from "swr"; import useSWR, { mutate } from "swr";
import { MinusCircle } from "lucide-react";
// mobx store // mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// services // services
@ -16,16 +17,17 @@ import {
IssueAttachments, IssueAttachments,
IssueDescriptionForm, IssueDescriptionForm,
IssueReaction, IssueReaction,
IssueUpdateStatus,
} from "components/issues"; } from "components/issues";
import { useState } from "react";
import { SubIssuesRoot } from "./sub-issues"; import { SubIssuesRoot } from "./sub-issues";
// ui // ui
import { CustomMenu, LayersIcon } from "@plane/ui"; import { CustomMenu, LayersIcon, StateGroupIcon } from "@plane/ui";
// icons
import { MinusCircle } from "lucide-react";
// types // types
import { IIssue, IIssueComment } from "types"; import { IIssue, IIssueComment } from "types";
// fetch-keys // fetch-keys
import { PROJECT_ISSUES_ACTIVITY, SUB_ISSUES } from "constants/fetch-keys"; import { PROJECT_ISSUES_ACTIVITY, SUB_ISSUES } from "constants/fetch-keys";
import { EUserWorkspaceRoles } from "constants/workspace";
type Props = { type Props = {
issueDetails: IIssue; issueDetails: IIssue;
@ -40,15 +42,25 @@ const issueCommentService = new IssueCommentService();
export const IssueMainContent: React.FC<Props> = observer((props) => { export const IssueMainContent: React.FC<Props> = observer((props) => {
const { issueDetails, submitChanges, uneditable = false } = props; const { issueDetails, submitChanges, uneditable = false } = props;
// states
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query; const { workspaceSlug, projectId, issueId } = router.query;
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
const { user: userStore, project: projectStore } = useMobxStore(); const {
user: userStore,
project: projectStore,
projectState: { states },
} = useMobxStore();
const user = userStore.currentUser ?? undefined; const user = userStore.currentUser ?? undefined;
const userRole = userStore.currentProjectRole; const userRole = userStore.currentProjectRole;
const projectDetails = projectId ? projectStore.project_details[projectId.toString()] : undefined; 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( const { data: siblingIssues } = useSWR(
workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null, workspaceSlug && projectId && issueDetails?.parent ? SUB_ISSUES(issueDetails.parent) : null,
@ -100,6 +112,8 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
); );
}; };
const isAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER;
return ( return (
<> <>
<div className="rounded-lg"> <div className="rounded-lg">
@ -162,11 +176,23 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
</CustomMenu> </CustomMenu>
</div> </div>
) : null} ) : null}
<div className="flex items-center mb-5">
{currentIssueState && (
<StateGroupIcon
className="h-4 w-4 mr-3"
stateGroup={currentIssueState.group}
color={currentIssueState.color}
/>
)}
<IssueUpdateStatus isSubmitting={isSubmitting} issueDetail={issueDetails} />
</div>
<IssueDescriptionForm <IssueDescriptionForm
setIsSubmitting={(value) => setIsSubmitting(value)}
isSubmitting={isSubmitting}
workspaceSlug={workspaceSlug as string} workspaceSlug={workspaceSlug as string}
issue={issueDetails} issue={issueDetails}
handleFormSubmit={submitChanges} handleFormSubmit={submitChanges}
isAllowed={userRole === 20 || userRole === 15 || !uneditable} isAllowed={isAllowed || !uneditable}
/> />
<IssueReaction workspaceSlug={workspaceSlug} issueId={issueId} projectId={projectId} /> <IssueReaction workspaceSlug={workspaceSlug} issueId={issueId} projectId={projectId} />

View File

@ -33,13 +33,14 @@ import {
import { CustomDatePicker } from "components/ui"; import { CustomDatePicker } from "components/ui";
// icons // icons
import { Bell, CalendarDays, LinkIcon, Plus, Signal, Tag, Trash2, Triangle, User2 } from "lucide-react"; 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 // helpers
import { copyTextToClipboard } from "helpers/string.helper"; import { copyTextToClipboard } from "helpers/string.helper";
// types // types
import type { IIssue, IIssueLink, linkDetails } from "types"; import type { IIssue, IIssueLink, linkDetails } from "types";
// fetch-keys // fetch-keys
import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys";
import { EUserWorkspaceRoles } from "constants/workspace";
type Props = { type Props = {
control: any; control: any;
@ -79,12 +80,15 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
const [linkModal, setLinkModal] = useState(false); const [linkModal, setLinkModal] = useState(false);
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<linkDetails | null>(null); const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<linkDetails | null>(null);
const { user: userStore } = useMobxStore(); const {
user: userStore,
projectState: { states },
} = useMobxStore();
const user = userStore.currentUser; const user = userStore.currentUser;
const userRole = userStore.currentProjectRole; const userRole = userStore.currentProjectRole;
const router = useRouter(); const router = useRouter();
const { workspaceSlug, projectId, issueId } = router.query; const { workspaceSlug, projectId, issueId, inboxIssueId } = router.query;
const { isEstimateActive } = useEstimateOption(); const { isEstimateActive } = useEstimateOption();
@ -245,7 +249,11 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
setLinkModal(true); 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 ( return (
<> <>
@ -265,9 +273,20 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
)} )}
<div className="h-full w-full flex flex-col divide-y-2 divide-custom-border-200 overflow-hidden"> <div className="h-full w-full flex flex-col divide-y-2 divide-custom-border-200 overflow-hidden">
<div className="flex items-center justify-between px-5 pb-3"> <div className="flex items-center justify-between px-5 pb-3">
<h4 className="text-sm font-medium"> <div className="flex items-center gap-x-2">
{currentIssueState ? (
<StateGroupIcon
className="h-4 w-4"
stateGroup={currentIssueState.group}
color={currentIssueState.color}
/>
) : inboxIssueId ? (
<StateGroupIcon className="h-4 w-4" stateGroup="backlog" color="#ff7700" />
) : null}
<h4 className="text-lg text-custom-text-300 font-medium">
{issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id} {issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id}
</h4> </h4>
</div>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
{issueDetail?.created_by !== user?.id && {issueDetail?.created_by !== user?.id &&
!issueDetail?.assignees.includes(user?.id ?? "") && !issueDetail?.assignees.includes(user?.id ?? "") &&
@ -295,7 +314,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<LinkIcon className="h-3.5 w-3.5" /> <LinkIcon className="h-3.5 w-3.5" />
</button> </button>
)} )}
{!isNotAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && ( {isAllowed && (fieldsToShow.includes("all") || fieldsToShow.includes("delete")) && (
<button <button
type="button" type="button"
className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-500/20 focus:outline-none" className="rounded-md border border-red-500 p-2 text-red-500 shadow-sm duration-300 hover:bg-red-500/20 focus:outline-none"
@ -325,7 +344,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<SidebarStateSelect <SidebarStateSelect
value={value} value={value}
onChange={(val: string) => submitChanges({ state: val })} onChange={(val: string) => submitChanges({ state: val })}
disabled={isNotAllowed || uneditable} disabled={!isAllowed || uneditable}
/> />
)} )}
/> />
@ -346,7 +365,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<SidebarAssigneeSelect <SidebarAssigneeSelect
value={value} value={value}
onChange={(val: string[]) => submitChanges({ assignees: val })} onChange={(val: string[]) => submitChanges({ assignees: val })}
disabled={isNotAllowed || uneditable} disabled={!isAllowed || uneditable}
/> />
)} )}
/> />
@ -367,7 +386,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<SidebarPrioritySelect <SidebarPrioritySelect
value={value} value={value}
onChange={(val) => submitChanges({ priority: val })} onChange={(val) => submitChanges({ priority: val })}
disabled={isNotAllowed || uneditable} disabled={!isAllowed || uneditable}
/> />
)} )}
/> />
@ -388,7 +407,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<SidebarEstimateSelect <SidebarEstimateSelect
value={value} value={value}
onChange={(val: number | null) => submitChanges({ estimate_point: val })} onChange={(val: number | null) => submitChanges({ estimate_point: val })}
disabled={isNotAllowed || uneditable} disabled={!isAllowed || uneditable}
/> />
)} )}
/> />
@ -416,7 +435,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
onChange(val); onChange(val);
}} }}
issueDetails={issueDetail} issueDetails={issueDetail}
disabled={isNotAllowed || uneditable} disabled={!isAllowed || uneditable}
/> />
)} )}
/> />
@ -441,7 +460,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
}} }}
watch={watchIssue} watch={watchIssue}
disabled={isNotAllowed || uneditable} disabled={!isAllowed || uneditable}
/> />
)} )}
{(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("blocked")) && (
@ -462,7 +481,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
}} }}
watch={watchIssue} watch={watchIssue}
disabled={isNotAllowed || uneditable} disabled={!isAllowed || uneditable}
/> />
)} )}
{(fieldsToShow.includes("all") || fieldsToShow.includes("duplicate")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("duplicate")) && (
@ -480,7 +499,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
}} }}
watch={watchIssue} watch={watchIssue}
disabled={isNotAllowed || uneditable} disabled={!isAllowed || uneditable}
/> />
)} )}
{(fieldsToShow.includes("all") || fieldsToShow.includes("relates_to")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("relates_to")) && (
@ -498,7 +517,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); mutate(PROJECT_ISSUES_ACTIVITY(issueId as string));
}} }}
watch={watchIssue} watch={watchIssue}
disabled={isNotAllowed || uneditable} disabled={!isAllowed || uneditable}
/> />
)} )}
{(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && ( {(fieldsToShow.includes("all") || fieldsToShow.includes("startDate")) && (
@ -522,7 +541,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
} }
className="bg-custom-background-80 border-none" className="bg-custom-background-80 border-none"
maxDate={maxDate ?? undefined} maxDate={maxDate ?? undefined}
disabled={isNotAllowed || uneditable} disabled={!isAllowed || uneditable}
/> />
)} )}
/> />
@ -550,7 +569,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
} }
className="bg-custom-background-80 border-none" className="bg-custom-background-80 border-none"
minDate={minDate ?? undefined} minDate={minDate ?? undefined}
disabled={isNotAllowed || uneditable} disabled={!isAllowed || uneditable}
/> />
)} )}
/> />
@ -571,7 +590,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<SidebarCycleSelect <SidebarCycleSelect
issueDetail={issueDetail} issueDetail={issueDetail}
handleCycleChange={handleCycleChange} handleCycleChange={handleCycleChange}
disabled={isNotAllowed || uneditable} disabled={!isAllowed || uneditable}
/> />
</div> </div>
</div> </div>
@ -586,7 +605,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<SidebarModuleSelect <SidebarModuleSelect
issueDetail={issueDetail} issueDetail={issueDetail}
handleModuleChange={handleModuleChange} handleModuleChange={handleModuleChange}
disabled={isNotAllowed || uneditable} disabled={!isAllowed || uneditable}
/> />
</div> </div>
</div> </div>
@ -605,7 +624,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
issueDetails={issueDetail} issueDetails={issueDetail}
labelList={issueDetail?.labels ?? []} labelList={issueDetail?.labels ?? []}
submitChanges={submitChanges} submitChanges={submitChanges}
isNotAllowed={isNotAllowed} isNotAllowed={!isAllowed}
uneditable={uneditable ?? false} uneditable={uneditable ?? false}
/> />
</div> </div>
@ -615,7 +634,7 @@ export const IssueDetailsSidebar: React.FC<Props> = observer((props) => {
<div className={`min-h-[116px] py-1 text-xs ${uneditable ? "opacity-60" : ""}`}> <div className={`min-h-[116px] py-1 text-xs ${uneditable ? "opacity-60" : ""}`}>
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<h4>Links</h4> <h4>Links</h4>
{!isNotAllowed && ( {isAllowed && (
<button <button
type="button" type="button"
className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-background-90 ${ className={`grid h-7 w-7 place-items-center rounded p-1 outline-none duration-300 hover:bg-custom-background-90 ${

View File

@ -22,6 +22,7 @@ import { IUser, IIssue, ISearchIssueResponse } from "types";
import { IssueService } from "services/issue"; import { IssueService } from "services/issue";
// fetch keys // fetch keys
import { SUB_ISSUES } from "constants/fetch-keys"; import { SUB_ISSUES } from "constants/fetch-keys";
import { EUserWorkspaceRoles } from "constants/workspace";
export interface ISubIssuesRoot { export interface ISubIssuesRoot {
parentIssue: IIssue; parentIssue: IIssue;
@ -176,7 +177,7 @@ export const SubIssuesRoot: React.FC<ISubIssuesRoot> = observer((props) => {
[updateIssueStructure, projectId, updateIssue, user, workspaceSlug] [updateIssueStructure, projectId, updateIssue, user, workspaceSlug]
); );
const isEditable = userRole === 5 || userRole === 10 ? false : true; const isEditable = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER;
const mutateSubIssues = (parentIssueId: string | null) => { const mutateSubIssues = (parentIssueId: string | null) => {
if (parentIssueId) mutate(SUB_ISSUES(parentIssueId)); if (parentIssueId) mutate(SUB_ISSUES(parentIssueId));

View File

@ -33,6 +33,7 @@ import { linkDetails, IModule, ModuleLink } from "types";
import { MODULE_DETAILS } from "constants/fetch-keys"; import { MODULE_DETAILS } from "constants/fetch-keys";
// constant // constant
import { MODULE_STATUS } from "constants/module"; import { MODULE_STATUS } from "constants/module";
import { EUserWorkspaceRoles } from "constants/workspace";
const defaultValues: Partial<IModule> = { const defaultValues: Partial<IModule> = {
lead: "", lead: "",
@ -74,20 +75,7 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
const submitChanges = (data: Partial<IModule>) => { const submitChanges = (data: Partial<IModule>) => {
if (!workspaceSlug || !projectId || !moduleId) return; if (!workspaceSlug || !projectId || !moduleId) return;
moduleStore.updateModuleDetails(workspaceSlug.toString(), projectId.toString(), moduleId, data);
mutate<IModule>(
MODULE_DETAILS(moduleId as string),
(prevData) => ({
...(prevData as IModule),
...data,
}),
false
);
moduleService
.patchModule(workspaceSlug as string, projectId as string, moduleId as string, data)
.then(() => mutate(MODULE_DETAILS(moduleId as string)))
.catch((e) => console.log(e));
}; };
const handleCreateLink = async (formData: ModuleLink) => { const handleCreateLink = async (formData: ModuleLink) => {
@ -588,10 +576,10 @@ export const ModuleDetailsSidebar: React.FC<Props> = observer((props) => {
handleEditLink={handleEditLink} handleEditLink={handleEditLink}
handleDeleteLink={handleDeleteLink} handleDeleteLink={handleDeleteLink}
userAuth={{ userAuth={{
isGuest: userRole === 5, isGuest: userRole === EUserWorkspaceRoles.GUEST,
isViewer: userRole === 10, isViewer: userRole === EUserWorkspaceRoles.VIEWER,
isMember: userRole === 15, isMember: userRole === EUserWorkspaceRoles.MEMBER,
isOwner: userRole === 20, isOwner: userRole === EUserWorkspaceRoles.ADMIN,
}} }}
/> />
</> </>

View File

@ -1,12 +1,13 @@
import { useState, useEffect, useCallback } from "react"; import { useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import Image from "next/image"; import Image from "next/image";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { useRouter } from "next/router";
import { Lightbulb } from "lucide-react"; import { Lightbulb } from "lucide-react";
// mobx store // mobx store
import { useMobxStore } from "lib/mobx/store-provider"; import { useMobxStore } from "lib/mobx/store-provider";
// hooks
import useSignInRedirection from "hooks/use-sign-in-redirection";
// components // components
import { SignInRoot } from "components/account"; import { SignInRoot } from "components/account";
// ui // ui
@ -14,69 +15,33 @@ import { Loader, Spinner } from "@plane/ui";
// images // images
import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png"; import BluePlaneLogoWithoutText from "public/plane-logos/blue-without-text.png";
import latestFeatures from "public/onboarding/onboarding-pages.svg"; import latestFeatures from "public/onboarding/onboarding-pages.svg";
// types
import { IUser, IUserSettings } from "types";
export type AuthType = "sign-in" | "sign-up"; export type AuthType = "sign-in" | "sign-up";
export const SignInView = observer(() => { export const SignInView = observer(() => {
// store // store
const { const {
user: { fetchCurrentUser, fetchCurrentUserSettings }, user: { currentUser },
appConfig: { envConfig }, appConfig: { envConfig },
} = useMobxStore(); } = useMobxStore();
// router
const router = useRouter();
const { next: next_url } = router.query;
// states
const [isLoading, setLoading] = useState(false);
// next-themes // next-themes
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
// sign in redirection hook
const handleSignInRedirection = useCallback( const { isRedirecting, handleRedirection } = useSignInRedirection();
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(() => setLoading(false));
},
[fetchCurrentUserSettings, router, next_url]
);
const mutateUserInfo = useCallback(async () => {
await fetchCurrentUser().then(async (user) => {
await handleSignInRedirection(user);
});
}, [fetchCurrentUser, handleSignInRedirection]);
useEffect(() => { useEffect(() => {
mutateUserInfo(); handleRedirection();
}, [mutateUserInfo]); }, [handleRedirection]);
if (isRedirecting || currentUser)
return ( return (
<>
{isLoading ? (
<div className="grid place-items-center h-screen"> <div className="grid place-items-center h-screen">
<Spinner /> <Spinner />
</div> </div>
) : ( );
<div className={`bg-onboarding-gradient-100 h-full w-full`}>
return (
<div className="bg-onboarding-gradient-100 h-full w-full">
<div className="flex items-center justify-between sm:py-5 px-8 pb-4 sm:px-16 lg:px-28 "> <div className="flex items-center justify-between sm:py-5 px-8 pb-4 sm:px-16 lg:px-28 ">
<div className="flex gap-x-2 py-10 items-center"> <div className="flex gap-x-2 py-10 items-center">
<Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" /> <Image src={BluePlaneLogoWithoutText} height={30} width={30} alt="Plane Logo" className="mr-2" />
@ -102,7 +67,7 @@ export const SignInView = observer(() => {
</div> </div>
) : ( ) : (
<> <>
<SignInRoot handleSignInRedirection={mutateUserInfo} /> <SignInRoot />
<div className="flex py-2 bg-onboarding-background-100 border border-onboarding-border-200 mx-auto rounded-[3.5px] sm:w-96 mt-16"> <div className="flex py-2 bg-onboarding-background-100 border border-onboarding-border-200 mx-auto rounded-[3.5px] sm:w-96 mt-16">
<Lightbulb className="h-7 w-7 mr-2 mx-3" /> <Lightbulb className="h-7 w-7 mr-2 mx-3" />
@ -129,7 +94,5 @@ export const SignInView = observer(() => {
</div> </div>
</div> </div>
</div> </div>
)}
</>
); );
}); });

View File

@ -26,6 +26,7 @@ import { CustomMenu, Tooltip } from "@plane/ui";
import { CreateUpdatePageModal, DeletePageModal } from "components/pages"; import { CreateUpdatePageModal, DeletePageModal } from "components/pages";
// types // types
import { IPage } from "types"; import { IPage } from "types";
import { EUserWorkspaceRoles } from "constants/workspace";
export interface IPagesListItem { export interface IPagesListItem {
workspaceSlug: string; workspaceSlug: string;
@ -144,7 +145,7 @@ export const PagesListItem: FC<IPagesListItem> = observer((props) => {
setCreateUpdatePageModal(true); setCreateUpdatePageModal(true);
}; };
const userCanEdit = currentProjectRole === 15 || currentProjectRole === 20; const userCanEdit = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
return ( return (
<> <>

View File

@ -13,7 +13,7 @@ import { CustomSelect, Tooltip } from "@plane/ui";
// icons // icons
import { ChevronDown, Dot, XCircle } from "lucide-react"; import { ChevronDown, Dot, XCircle } from "lucide-react";
// constants // constants
import { ROLE } from "constants/workspace"; import { EUserWorkspaceRoles, ROLE } from "constants/workspace";
// types // types
import { IProjectMember, TUserProjectRole } from "types"; import { IProjectMember, TUserProjectRole } from "types";
@ -33,12 +33,13 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
const { const {
user: { currentUser, currentProjectMemberInfo, currentProjectRole, leaveProject }, user: { currentUser, currentProjectMemberInfo, currentProjectRole, leaveProject },
projectMember: { removeMemberFromProject, updateMember }, projectMember: { removeMemberFromProject, updateMember },
project: { fetchProjects },
} = useMobxStore(); } = useMobxStore();
// hooks // hooks
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// derived values // derived values
const isAdmin = currentProjectRole === 20; const isAdmin = currentProjectRole === EUserWorkspaceRoles.ADMIN;
const memberDetails = member.member; const memberDetails = member.member;
const handleRemove = async () => { const handleRemove = async () => {
@ -46,7 +47,11 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
if (memberDetails.id === currentUser?.id) { if (memberDetails.id === currentUser?.id) {
await leaveProject(workspaceSlug.toString(), projectId.toString()) await leaveProject(workspaceSlug.toString(), projectId.toString())
.then(() => router.push(`/${workspaceSlug}/projects`)) .then(async () => {
await fetchProjects(workspaceSlug.toString());
router.push(`/${workspaceSlug}/projects`);
})
.catch((err) => .catch((err) =>
setToastAlert({ setToastAlert({
type: "error", type: "error",
@ -148,12 +153,13 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
disabled={ disabled={
memberDetails.id === currentUser?.id || memberDetails.id === currentUser?.id ||
!member.member || !member.member ||
(currentProjectRole && currentProjectRole !== 20 && currentProjectRole < member.role) !currentProjectRole ||
currentProjectRole < member.role
} }
placement="bottom-end" placement="bottom-end"
> >
{Object.keys(ROLE).map((key) => { {Object.keys(ROLE).map((key) => {
if (currentProjectRole && currentProjectRole !== 20 && currentProjectRole < parseInt(key)) return null; if (currentProjectRole && !isAdmin && currentProjectRole < parseInt(key)) return null;
return ( return (
<CustomSelect.Option key={key} value={parseInt(key, 10)}> <CustomSelect.Option key={key} value={parseInt(key, 10)}>
@ -173,7 +179,7 @@ export const ProjectMemberListItem: React.FC<Props> = observer((props) => {
onClick={() => setRemoveMemberModal(true)} onClick={() => setRemoveMemberModal(true)}
className="opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto" className="opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
> >
<XCircle className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={2} /> <XCircle className="h-3.5 w-3.5 text-red-500" strokeWidth={2} />
</button> </button>
</Tooltip> </Tooltip>
)} )}

View File

@ -15,6 +15,7 @@ import { Loader } from "@plane/ui";
import { IProject, IUserLite, IWorkspace } from "types"; import { IProject, IUserLite, IWorkspace } from "types";
// fetch-keys // fetch-keys
import { PROJECT_MEMBERS } from "constants/fetch-keys"; import { PROJECT_MEMBERS } from "constants/fetch-keys";
import { EUserWorkspaceRoles } from "constants/workspace";
const defaultValues: Partial<IProject> = { const defaultValues: Partial<IProject> = {
project_lead: null, project_lead: null,
@ -29,7 +30,7 @@ export const ProjectSettingsMemberDefaults: React.FC = observer(() => {
const { user: userStore, project: projectStore } = useMobxStore(); const { user: userStore, project: projectStore } = useMobxStore();
const { currentProjectDetails } = projectStore; const { currentProjectDetails } = projectStore;
const { currentProjectRole } = userStore; const { currentProjectRole } = userStore;
const isAdmin = currentProjectRole === 20; const isAdmin = currentProjectRole === EUserWorkspaceRoles.ADMIN;
// hooks // hooks
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// form info // form info

View File

@ -15,7 +15,7 @@ import useToast from "hooks/use-toast";
// types // types
import { IProjectMember, TUserProjectRole } from "types"; import { IProjectMember, TUserProjectRole } from "types";
// constants // constants
import { ROLE } from "constants/workspace"; import { EUserWorkspaceRoles, ROLE } from "constants/workspace";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@ -246,7 +246,8 @@ export const SendProjectInvitationModal: React.FC<Props> = observer((props) => {
width="w-full" width="w-full"
> >
{Object.entries(ROLE).map(([key, label]) => { {Object.entries(ROLE).map(([key, label]) => {
if (parseInt(key) > (currentProjectRole ?? 5)) return null; if (parseInt(key) > (currentProjectRole ?? EUserWorkspaceRoles.GUEST))
return null;
return ( return (
<CustomSelect.Option key={key} value={key}> <CustomSelect.Option key={key} value={key}>

View File

@ -9,6 +9,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
// types // types
import { IProject } from "types"; import { IProject } from "types";
import { EUserWorkspaceRoles } from "constants/workspace";
type Props = {}; type Props = {};
@ -56,7 +57,7 @@ export const ProjectFeaturesList: FC<Props> = observer(() => {
user: { currentUser, currentProjectRole }, user: { currentUser, currentProjectRole },
trackEvent: { setTrackElement, postHogEventTracker }, trackEvent: { setTrackElement, postHogEventTracker },
} = useMobxStore(); } = useMobxStore();
const isAdmin = currentProjectRole === 20; const isAdmin = currentProjectRole === EUserWorkspaceRoles.ADMIN;
// hooks // hooks
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
@ -97,7 +98,7 @@ export const ProjectFeaturesList: FC<Props> = observer(() => {
project_id: currentProjectDetails?.id, project_id: currentProjectDetails?.id,
project_name: currentProjectDetails?.name, project_name: currentProjectDetails?.name,
project_identifier: currentProjectDetails?.identifier, project_identifier: currentProjectDetails?.identifier,
enabled: !currentProjectDetails?.[feature.property as keyof IProject] enabled: !currentProjectDetails?.[feature.property as keyof IProject],
}); });
handleSubmit({ handleSubmit({
[feature.property]: !currentProjectDetails?.[feature.property as keyof IProject], [feature.property]: !currentProjectDetails?.[feature.property as keyof IProject],

View File

@ -284,7 +284,7 @@ export const ProjectSidebarListItem: React.FC<Props> = observer((props) => {
<CustomMenu.MenuItem onClick={handleLeaveProject}> <CustomMenu.MenuItem onClick={handleLeaveProject}>
<div className="flex items-center justify-start gap-2"> <div className="flex items-center justify-start gap-2">
<LogOut className="h-3.5 w-3.5 stroke-[1.5]" /> <LogOut className="h-3.5 w-3.5 stroke-[1.5]" />
<span>Leave Project</span> <span>Leave project</span>
</div> </div>
</CustomMenu.MenuItem> </CustomMenu.MenuItem>
)} )}

View File

@ -243,7 +243,7 @@ export const WorkspaceMembersListItem: FC<Props> = observer((props) => {
: "opacity-0 pointer-events-none" : "opacity-0 pointer-events-none"
} }
> >
<XCircle className="h-3.5 w-3.5 text-custom-text-400" strokeWidth={2} /> <XCircle className="h-3.5 w-3.5 text-red-500" strokeWidth={2} />
</button> </button>
</Tooltip> </Tooltip>
</div> </div>

View File

@ -1,12 +1,14 @@
export const getUserRole = (role: number) => { import { EUserWorkspaceRoles } from "constants/workspace";
export const getUserRole = (role: EUserWorkspaceRoles) => {
switch (role) { switch (role) {
case 5: case EUserWorkspaceRoles.GUEST:
return "GUEST"; return "GUEST";
case 10: case EUserWorkspaceRoles.VIEWER:
return "VIEWER"; return "VIEWER";
case 15: case EUserWorkspaceRoles.MEMBER:
return "MEMBER"; return "MEMBER";
case 20: case EUserWorkspaceRoles.ADMIN:
return "ADMIN"; return "ADMIN";
} }
}; };

View File

@ -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<void>;
};
const useSignInRedirection = (): UseSignInRedirectionProps => {
// states
const [isRedirecting, setIsRedirecting] = useState(true);
const [error, setError] = useState<any | null>(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;

View File

@ -1,15 +1,27 @@
import { FC, ReactNode } from "react"; import { FC, ReactNode } from "react";
// components // components
import { ProjectSettingsSidebar } from "./sidebar"; 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 { export interface IProjectSettingLayout {
children: ReactNode; children: ReactNode;
} }
export const ProjectSettingLayout: FC<IProjectSettingLayout> = (props) => { export const ProjectSettingLayout: FC<IProjectSettingLayout> = observer((props) => {
const { children } = props; const { children } = props;
return ( const {
user: { currentProjectRole },
} = useMobxStore();
const restrictViewSettings = currentProjectRole && currentProjectRole <= EUserWorkspaceRoles.VIEWER;
return restrictViewSettings ? (
<NotAuthorizedView type="project" />
) : (
<div className="flex gap-2 h-full w-full overflow-x-hidden overflow-y-scroll"> <div className="flex gap-2 h-full w-full overflow-x-hidden overflow-y-scroll">
<div className="w-80 pt-8 overflow-y-hidden flex-shrink-0"> <div className="w-80 pt-8 overflow-y-hidden flex-shrink-0">
<ProjectSettingsSidebar /> <ProjectSettingsSidebar />
@ -17,4 +29,4 @@ export const ProjectSettingLayout: FC<IProjectSettingLayout> = (props) => {
{children} {children}
</div> </div>
); );
}; });

View File

@ -14,6 +14,7 @@ import { ProjectSettingHeader } from "components/headers";
// types // types
import { NextPageWithLayout } from "types/app"; import { NextPageWithLayout } from "types/app";
import { IProject } from "types"; import { IProject } from "types";
import { EUserWorkspaceRoles } from "constants/workspace";
const AutomationSettingsPage: NextPageWithLayout = observer(() => { const AutomationSettingsPage: NextPageWithLayout = observer(() => {
const router = useRouter(); const router = useRouter();
@ -39,7 +40,7 @@ const AutomationSettingsPage: NextPageWithLayout = observer(() => {
}); });
}; };
const isAdmin = currentProjectRole === 20; const isAdmin = currentProjectRole === EUserWorkspaceRoles.ADMIN;
return ( return (
<section className={`pr-9 py-8 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}> <section className={`pr-9 py-8 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60"}`}>

View File

@ -7,12 +7,23 @@ import { ProjectSettingHeader } from "components/headers";
import { EstimatesList } from "components/estimates"; import { EstimatesList } from "components/estimates";
// types // types
import { NextPageWithLayout } from "types/app"; 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(() => {
<div className="pr-9 py-8 w-full overflow-y-auto"> const {
user: { currentProjectRole },
} = useMobxStore();
const isAdmin = currentProjectRole === EUserWorkspaceRoles.ADMIN;
return (
<div className={`pr-9 py-8 w-full overflow-y-auto ${isAdmin ? "" : "opacity-60 pointer-events-none"}`}>
<EstimatesList /> <EstimatesList />
</div> </div>
); );
});
EstimatesSettingsPage.getLayout = function getLayout(page: ReactElement) { EstimatesSettingsPage.getLayout = function getLayout(page: ReactElement) {
return ( return (

View File

@ -1,4 +1,4 @@
import { ReactElement, useCallback } from "react"; import { ReactElement } from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
@ -9,6 +9,7 @@ import { Controller, useForm } from "react-hook-form";
import { AuthService } from "services/auth.service"; import { AuthService } from "services/auth.service";
// hooks // hooks
import useToast from "hooks/use-toast"; import useToast from "hooks/use-toast";
import useSignInRedirection from "hooks/use-sign-in-redirection";
// layouts // layouts
import DefaultLayout from "layouts/default-layout"; import DefaultLayout from "layouts/default-layout";
// ui // ui
@ -20,9 +21,6 @@ import latestFeatures from "public/onboarding/onboarding-pages.svg";
import { checkEmailValidity } from "helpers/string.helper"; import { checkEmailValidity } from "helpers/string.helper";
// type // type
import { NextPageWithLayout } from "types/app"; import { NextPageWithLayout } from "types/app";
import { useMobxStore } from "lib/mobx/store-provider";
// types
import { IUser, IUserSettings } from "types";
type TResetPasswordFormValues = { type TResetPasswordFormValues = {
email: string; email: string;
@ -45,10 +43,8 @@ const HomePage: NextPageWithLayout = () => {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
// toast // toast
const { setToastAlert } = useToast(); const { setToastAlert } = useToast();
// mobx store // sign in redirection hook
const { const { handleRedirection } = useSignInRedirection();
user: { fetchCurrentUser, fetchCurrentUserSettings },
} = useMobxStore();
// form info // form info
const { const {
control, 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) => { const handleResetPassword = async (formData: TResetPasswordFormValues) => {
if (!uidb64 || !token || !email) return; if (!uidb64 || !token || !email) return;
@ -95,7 +66,7 @@ const HomePage: NextPageWithLayout = () => {
await authService await authService
.resetPassword(uidb64.toString(), token.toString(), payload) .resetPassword(uidb64.toString(), token.toString(), payload)
.then(() => mutateUserInfo()) .then(() => handleRedirection())
.catch((err) => .catch((err) =>
setToastAlert({ setToastAlert({
type: "error", type: "error",

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 527 KiB

After

Width:  |  Height:  |  Size: 528 KiB

View File

@ -5,6 +5,7 @@ import { RootStore } from "../root";
import { InboxService } from "services/inbox.service"; import { InboxService } from "services/inbox.service";
// types // types
import { IInbox, IInboxFilterOptions, IInboxQueryParams } from "types"; import { IInbox, IInboxFilterOptions, IInboxQueryParams } from "types";
import { EUserWorkspaceRoles } from "constants/workspace";
export interface IInboxFiltersStore { export interface IInboxFiltersStore {
// states // states
@ -132,8 +133,8 @@ export class InboxFiltersStore implements IInboxFiltersStore {
}; };
}); });
const userRole = this.rootStore.user?.projectMemberInfo?.[projectId]?.role || 0; const userRole = this.rootStore.user?.currentProjectRole || EUserWorkspaceRoles.GUEST;
if (userRole > 10) { if (userRole > EUserWorkspaceRoles.VIEWER) {
await this.inboxService.patchInbox(workspaceSlug, projectId, inboxId, { view_props: newViewProps }); await this.inboxService.patchInbox(workspaceSlug, projectId, inboxId, { view_props: newViewProps });
} }
} catch (error) { } catch (error) {

View File

@ -62,7 +62,14 @@ export interface ICycleIssuesStore {
issueId: string, issueId: string,
issueBridgeId: string issueBridgeId: string
) => Promise<IIssue>; ) => Promise<IIssue>;
transferIssuesFromCycle: (
workspaceSlug: string,
projectId: string,
cycleId: string,
payload: {
new_cycle_id: string;
}
) => Promise<IIssue>;
viewFlags: ViewFlags; viewFlags: ViewFlags;
} }
@ -103,6 +110,7 @@ export class CycleIssuesStore extends IssueBaseStore implements ICycleIssuesStor
quickAddIssue: action, quickAddIssue: action,
addIssueToCycle: action, addIssueToCycle: action,
removeIssueFromCycle: action, removeIssueFromCycle: action,
transferIssuesFromCycle: action,
}); });
this.rootStore = _rootStore; this.rootStore = _rootStore;
@ -348,4 +356,28 @@ export class CycleIssuesStore extends IssueBaseStore implements ICycleIssuesStor
throw error; 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;
}
};
} }

View File

@ -36,7 +36,7 @@ export class ProjectDraftIssuesStore extends IssueBaseStore implements IProjectD
//viewData //viewData
viewFlags = { viewFlags = {
enableQuickAdd: false, enableQuickAdd: false,
enableIssueCreation: false, enableIssueCreation: true,
enableInlineEditing: false, enableInlineEditing: false,
}; };